diff options
Diffstat (limited to 'toolkit/components/backgroundtasks')
70 files changed, 5835 insertions, 0 deletions
diff --git a/toolkit/components/backgroundtasks/BackgroundTask_exception.sys.mjs b/toolkit/components/backgroundtasks/BackgroundTask_exception.sys.mjs new file mode 100644 index 0000000000..45371f512c --- /dev/null +++ b/toolkit/components/backgroundtasks/BackgroundTask_exception.sys.mjs @@ -0,0 +1,10 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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/. */ + +export async function runBackgroundTask() { + console.error("runBackgroundTask: exception"); + + throw new Error("test"); +} diff --git a/toolkit/components/backgroundtasks/BackgroundTask_failure.sys.mjs b/toolkit/components/backgroundtasks/BackgroundTask_failure.sys.mjs new file mode 100644 index 0000000000..c619729207 --- /dev/null +++ b/toolkit/components/backgroundtasks/BackgroundTask_failure.sys.mjs @@ -0,0 +1,10 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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/. */ + +export async function runBackgroundTask() { + console.error("runBackgroundTask: failure"); + + return 1; +} diff --git a/toolkit/components/backgroundtasks/BackgroundTask_message.sys.mjs b/toolkit/components/backgroundtasks/BackgroundTask_message.sys.mjs new file mode 100644 index 0000000000..bb9d71b5c0 --- /dev/null +++ b/toolkit/components/backgroundtasks/BackgroundTask_message.sys.mjs @@ -0,0 +1,305 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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/. */ + +// Invoke this task like `firefox.exe --backgroundtask message ...`. +// +// This task is complicated because it's meant for manual testing by QA but also +// for automated testing. We might split these two functions at some point. +// +// First, note that the task takes significant configuration from the command +// line. This is different than the eventual home for this functionality, the +// background update task, which will take this configuration from the default +// browsing profile. +// +// This task accepts the following command line arguments: +// +// --debug: enable very verbose debug logging. Note that the `MOZ_LOG` +// environment variables still apply. +// +// --stage: use stage Remote Settings +// (`https://firefox.settings.services.allizom.org/v1`) rather than production +// (`https://firefox.settings.services.mozilla.com/v1`) +// +// --preview: enable Remote Settings and Experiment previews. +// +// `--url about:studies?...` (as copy-pasted from Experimenter): opt in to +// `optin_branch` of experiment with `optin_slug` from `optin_collection`. +// +// `--url file:///path/to/recipe.json?optin_branch=...` (as downloaded from +// Experimenter): opt in to `optin_branch` of given experiment recipe. +// +// `--experiments file:///path/to/recipe.json` (as downloaded from +// Experimenter): enable given experiment recipe, possibly enrolling into a +// branch via regular bucketing. +// +// `--targeting-snapshot /path/to/targeting.snapshot.json`: take default profile +// targeting information from given JSON file. +// +// `--reset-storage`: clear Activity Stream storage, including lifetime limit +// caps. +// +// The following flags are intended for automated testing. +// +// --sentinel: bracket important output with given sentinel for easy parsing. +// --randomizationId: set Nimbus/Normandy randomization ID for deterministic bucketing. +// --disable-alerts-service: do not show system/OS-level alerts. +// --no-experiments: don't talk to Remote Settings server at all. +// --no-datareporting: set `datareporting.healthreport.uploadEnabled=false` in +// the background task profile. +// --no-optoutstudies: set `app.shield.optoutstudies.enabled=false` in the +// background task profile. + +import { EXIT_CODE } from "resource://gre/modules/BackgroundTasksManager.sys.mjs"; + +const { ASRouter } = ChromeUtils.import( + "resource://activity-stream/lib/ASRouter.jsm" +); +import { BackgroundTasksUtils } from "resource://gre/modules/BackgroundTasksUtils.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ClientEnvironmentBase: + "resource://gre/modules/components-utils/ClientEnvironment.sys.mjs", + ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs", + IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + RemoteSettingsClient: + "resource://services-settings/RemoteSettingsClient.sys.mjs", + Utils: "resource://services-settings/Utils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + ToastNotification: "resource://activity-stream/lib/ToastNotification.jsm", +}); + +const SERVER_STAGE = "https://firefox.settings.services.allizom.org/v1"; + +// Default profile targeting snapshot. +let defaultProfileTargetingSnapshot = {}; + +// Bracket important output with given sentinel for easy parsing. +let outputInfo; +outputInfo = (sentinel, info) => { + dump(`${sentinel}${JSON.stringify(info)}${sentinel}\n`); +}; + +function monkeyPatchRemoteSettingsClient({ + last_modified = new Date().getTime(), + data = [], +}) { + lazy.RemoteSettingsClient.prototype.get = async (options = {}) => { + outputInfo({ "RemoteSettingsClient.get": { options, response: { data } } }); + return data; + }; +} + +async function handleCommandLine(commandLine) { + const CASE_INSENSITIVE = false; + + // Output data over stdout for tests to consume and inspect. + let sentinel = commandLine.handleFlagWithParam("sentinel", CASE_INSENSITIVE); + outputInfo = outputInfo.bind(null, sentinel || ""); + + // We always want `nimbus.debug=true` for `about:studies?...` URLs. + Services.prefs.setBoolPref("nimbus.debug", true); + + // Maybe drive up logging for this testing task. + Services.prefs.clearUserPref("services.settings.preview_enabled"); + Services.prefs.clearUserPref( + "browser.newtabpage.activity-stream.asrouter.debugLogLevel" + ); + Services.prefs.clearUserPref("messaging-system.log"); + Services.prefs.clearUserPref("services.settings.loglevel"); + Services.prefs.clearUserPref("toolkit.backgroundtasks.loglevel"); + if (commandLine.handleFlag("debug", CASE_INSENSITIVE)) { + console.log("Saw --debug, making logging verbose"); + Services.prefs.setBoolPref("services.settings.preview_enabled", true); + Services.prefs.setCharPref( + "browser.newtabpage.activity-stream.asrouter.debugLogLevel", + "debug" + ); + Services.prefs.setCharPref("messaging-system.log", "debug"); + Services.prefs.setCharPref("services.settings.loglevel", "debug"); + Services.prefs.setCharPref("toolkit.backgroundtasks.loglevel", "debug"); + } + + // Always make alert service display message when showing an alert. + // Optionally suppress actually showing OS-level alerts. + let origAlertsService = lazy.ToastNotification.AlertsService; + let disableAlertsService = commandLine.handleFlag( + "disable-alerts-service", + CASE_INSENSITIVE + ); + if (disableAlertsService) { + console.log("Saw --disable-alerts-service, not showing any alerts"); + } + // Remove getter so that we can redefine property. + delete lazy.ToastNotification.AlertsService; + lazy.ToastNotification.AlertsService = { + showAlert(...args) { + outputInfo({ showAlert: { args } }); + if (!disableAlertsService) { + origAlertsService.showAlert(...args); + } + }, + }; + + let targetingSnapshotPath = commandLine.handleFlagWithParam( + "targeting-snapshot", + CASE_INSENSITIVE + ); + if (targetingSnapshotPath) { + defaultProfileTargetingSnapshot = await IOUtils.readJSON( + targetingSnapshotPath + ); + console.log( + `Saw --targeting-snapshot, read snapshot from ${targetingSnapshotPath}` + ); + } + outputInfo({ defaultProfileTargetingSnapshot }); + + lazy.RemoteSettings.enablePreviewMode(false); + Services.prefs.clearUserPref( + "messaging-system.rsexperimentloader.collection_id" + ); + if (commandLine.handleFlag("preview", CASE_INSENSITIVE)) { + console.log( + `Saw --preview, invoking 'RemoteSettings.enablePreviewMode(true)' and ` + + `setting 'messaging-system.rsexperimentloader.collection_id=\"nimbus-preview\"'` + ); + lazy.RemoteSettings.enablePreviewMode(true); + Services.prefs.setCharPref( + "messaging-system.rsexperimentloader.collection_id", + "nimbus-preview" + ); + } + + Services.prefs.clearUserPref("services.settings.server"); + Services.prefs.clearUserPref("services.settings.load_dump"); + if (commandLine.handleFlag("stage", CASE_INSENSITIVE)) { + console.log( + `Saw --stage, setting 'services.settings.server="${SERVER_STAGE}"'` + ); + Services.prefs.setCharPref("services.settings.server", SERVER_STAGE); + Services.prefs.setBoolPref("services.settings.load_dump", false); + + if (lazy.Utils.SERVER_URL !== SERVER_STAGE) { + throw new Error( + "Pref services.settings.server ignored!" + + "remember to set MOZ_REMOTE_SETTINGS_DEVTOOLS=1 in beta and release." + ); + } + } + + // Allow to override Nimbus randomization ID with `--randomizationId ...`. + let randomizationId = commandLine.handleFlagWithParam( + "randomizationId", + CASE_INSENSITIVE + ); + if (randomizationId) { + console.log(`Saw --randomizationId: ${randomizationId}`); + Services.prefs.setCharPref("app.normandy.user_id", randomizationId); + } + outputInfo({ randomizationId: lazy.ClientEnvironmentBase.randomizationId }); + + // Allow to override Nimbus experiments with `--experiments /path/to/data.json`. + let experiments = commandLine.handleFlagWithParam( + "experiments", + CASE_INSENSITIVE + ); + if (experiments) { + let experimentsPath = commandLine.resolveFile(experiments).path; + let data = await IOUtils.readJSON(experimentsPath); + if (!Array.isArray(data)) { + if (data.permissions) { + data = data.data; + } + data = [data]; + } + + monkeyPatchRemoteSettingsClient({ data }); + + console.log(`Saw --experiments, read recipes from ${experimentsPath}`); + } + + // Allow to turn off querying Remote Settings entirely, for tests. + if ( + !experiments && + commandLine.handleFlag("no-experiments", CASE_INSENSITIVE) + ) { + monkeyPatchRemoteSettingsClient({ data: [] }); + + console.log(`Saw --no-experiments, returning [] recipes`); + } + + // Allow to test various red buttons. + Services.prefs.clearUserPref("datareporting.healthreport.uploadEnabled"); + if (commandLine.handleFlag("no-datareporting", CASE_INSENSITIVE)) { + Services.prefs.setBoolPref( + "datareporting.healthreport.uploadEnabled", + false + ); + console.log( + `Saw --no-datareporting, set 'datareporting.healthreport.uploadEnabled=false'` + ); + } + + Services.prefs.clearUserPref("app.shield.optoutstudies.enabled"); + if (commandLine.handleFlag("no-optoutstudies", CASE_INSENSITIVE)) { + Services.prefs.setBoolPref("app.shield.optoutstudies.enabled", false); + console.log( + `Saw --no-datareporting, set 'app.shield.optoutstudies.enabled=false'` + ); + } + + outputInfo({ + taskProfilePrefs: { + "app.shield.optoutstudies.enabled": Services.prefs.getBoolPref( + "app.shield.optoutstudies.enabled" + ), + "datareporting.healthreport.uploadEnabled": Services.prefs.getBoolPref( + "datareporting.healthreport.uploadEnabled" + ), + }, + }); + + if (commandLine.handleFlag("reset-storage", CASE_INSENSITIVE)) { + console.log("Saw --reset-storage, deleting database 'ActivityStream'"); + console.log( + `To hard reset, remove the contents of '${PathUtils.profileDir}'` + ); + await lazy.IndexedDB.deleteDatabase("ActivityStream"); + } +} + +export async function runBackgroundTask(commandLine) { + console.error("runBackgroundTask: message"); + + // Most of the task is arranging configuration. + await handleCommandLine(commandLine); + + // Here's where we actually start Nimbus and the Firefox Messaging + // System. + await BackgroundTasksUtils.enableNimbus( + commandLine, + defaultProfileTargetingSnapshot.environment + ); + + await BackgroundTasksUtils.enableFirefoxMessagingSystem( + defaultProfileTargetingSnapshot.environment + ); + + // At the time of writing, toast notifications are torn down as the + // process exits. Give the user a chance to see the notification. + await lazy.ExtensionUtils.promiseTimeout(1000); + + // Everything in `ASRouter` is asynchronous, so we need to give everything a + // chance to complete. + outputInfo({ ASRouterState: ASRouter.state }); + + return EXIT_CODE.SUCCESS; +} diff --git a/toolkit/components/backgroundtasks/BackgroundTask_removeDirectory.sys.mjs b/toolkit/components/backgroundtasks/BackgroundTask_removeDirectory.sys.mjs new file mode 100644 index 0000000000..e49f925145 --- /dev/null +++ b/toolkit/components/backgroundtasks/BackgroundTask_removeDirectory.sys.mjs @@ -0,0 +1,336 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { EXIT_CODE } from "resource://gre/modules/BackgroundTasksManager.sys.mjs"; + +class Metrics { + /** + * @param {string} metricsId + */ + constructor(metricsId) { + this.metricsId = metricsId; + this.startedTime = new Date(); + + this.wasFirst = true; + this.retryCount = 0; + this.removalCountObj = { value: 0 }; + this.succeeded = true; + + this.suffixRemovalCountObj = { value: 0 }; + this.suffixEverFailed = false; + } + + async report() { + if (!this.metricsId) { + console.warn(`Skipping Glean as no metrics id is passed`); + return; + } + if (AppConstants.MOZ_APP_NAME !== "firefox") { + console.warn( + `Skipping Glean as the app is not Firefox: ${AppConstants.MOZ_APP_NAME}` + ); + return; + } + + const elapsedMs = new Date().valueOf() - this.startedTime.valueOf(); + + // Note(krosylight): This FOG initialization happens within a unique + // temporary directory created for each background task, which will + // be removed after each run. + // That means any failed submission will be lost, but we are fine with + // that as we only have a single submission. + Services.fog.initializeFOG(undefined, "firefox.desktop.background.tasks"); + + const gleanMetrics = Glean[`backgroundTasksRmdir${this.metricsId}`]; + if (!gleanMetrics) { + throw new Error( + `The metrics id "${this.metricsId}" is not available in toolkit/components/backgroundtasks/metrics.yaml. ` + + `Make sure that the id has no typo and is in PascalCase. ` + + `Note that you can omit the id for testing.` + ); + } + + gleanMetrics.elapsedMs.set(elapsedMs); + gleanMetrics.wasFirst.set(this.wasFirst); + gleanMetrics.retryCount.set(this.retryCount); + gleanMetrics.removalCount.set(this.removalCountObj.value); + gleanMetrics.succeeded.set(this.succeeded); + gleanMetrics.suffixRemovalCount.set(this.suffixRemovalCountObj.value); + gleanMetrics.suffixEverFailed.set(this.suffixEverFailed); + + GleanPings.backgroundTasks.submit(); + + // XXX: We wait for arbitrary time for Glean to submit telemetry. + // Bug 1790702 should add a better way. + console.error("Pinged glean, waiting for submission."); + await new Promise(resolve => lazy.setTimeout(resolve, 5000)); + } +} + +// Recursively removes a directory. +// Returns true if it succeeds, false otherwise. +function tryRemoveDir(aFile, countObj) { + try { + aFile.remove(true, countObj); + } catch (e) { + return false; + } + + return true; +} + +const FILE_CHECK_ITERATION_TIMEOUT_MS = 1000; + +function cleanupDirLockFile(aLock, aProfileName) { + let lockFile = aLock.getLockFile(aProfileName); + try { + // Try to clean up the lock file + lockFile.remove(false); + } catch (ex) {} +} + +async function deleteChildDirectory( + parentDirPath, + childDirName, + secondsToWait, + metrics +) { + if (!childDirName || !childDirName.length) { + return; + } + + let targetFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + targetFile.initWithPath(parentDirPath); + targetFile.append(childDirName); + + // We create the lock before the file is actually there so this task + // is the first one to acquire the lock. Otherwise a different task + // could be cleaning suffixes and start deleting the folder while this + // task is waiting for it to show up. + let dirLock = Cc["@mozilla.org/net/CachePurgeLock;1"].createInstance( + Ci.nsICachePurgeLock + ); + + let locked = false; + try { + dirLock.lock(childDirName); + locked = true; + metrics.wasFirst = !dirLock.isOtherInstanceRunning(); + } catch (e) { + console.error("Failed to check dirLock"); + } + + if (!metrics.wasFirst) { + if (locked) { + dirLock.unlock(); + locked = false; + } + console.error("Another instance is already purging this directory"); + return; + } + + // This backgroundtask process is spawned by the call to + // PR_CreateProcessDetached in CacheFileIOManager::SyncRemoveAllCacheFiles + // Only if spawning the process is successful is the cache folder renamed, + // so we need to wait until that is done. + while (!targetFile.exists()) { + if ( + metrics.retryCount * FILE_CHECK_ITERATION_TIMEOUT_MS > + secondsToWait * 1000 + ) { + // We don't know for sure if the folder was renamed or if a different + // task removed it already. The second variant is more likely but to + // be sure we'd have to consult a log file, which introduces + // more complexity. + console.error(`couldn't find cache folder ${targetFile.path}`); + if (locked) { + dirLock.unlock(); + locked = false; + } + return; + } + await new Promise(resolve => + lazy.setTimeout(resolve, FILE_CHECK_ITERATION_TIMEOUT_MS) + ); + metrics.retryCount++; + console.error(`Cache folder attempt no ${metrics.retryCount}`); + } + + if (!targetFile.isDirectory()) { + if (locked) { + dirLock.unlock(); + locked = false; + } + throw new Error("Path was not a directory"); + } + + console.error(`started removing ${targetFile.path}`); + try { + targetFile.remove(true, metrics.removalCountObj); + } catch (err) { + console.error( + `failed removing ${targetFile.path}. removed ${metrics.removalCountObj.value} entries.` + ); + throw err; + } finally { + console.error( + `done removing ${targetFile.path}. removed ${metrics.removalCountObj.value} entries.` + ); + if (locked) { + dirLock.unlock(); + locked = false; + cleanupDirLockFile(dirLock, childDirName); + } + } +} + +async function cleanupOtherDirectories( + parentDirPath, + otherFoldersSuffix, + metrics +) { + if (!otherFoldersSuffix || !otherFoldersSuffix.length) { + return; + } + + let targetFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + targetFile.initWithPath(parentDirPath); + + let entries = targetFile.directoryEntries; + while (entries.hasMoreElements()) { + let entry = entries.nextFile; + + if (!entry.leafName.endsWith(otherFoldersSuffix)) { + continue; + } + + let shouldProcessEntry = false; + // The folder could already be gone, so isDirectory could throw + try { + shouldProcessEntry = entry.isDirectory(); + } catch (e) {} + + if (!shouldProcessEntry) { + continue; + } + + let dirLock = Cc["@mozilla.org/net/CachePurgeLock;1"].createInstance( + Ci.nsICachePurgeLock + ); + let wasFirst = false; + + try { + dirLock.lock(entry.leafName); + wasFirst = !dirLock.isOtherInstanceRunning(); + } catch (e) { + console.error("Failed to check dirlock. Skipping folder"); + dirLock.unlock(); + continue; + } + + if (!wasFirst) { + dirLock.unlock(); + continue; + } + + // Remove directory recursively. + let removedDir = tryRemoveDir(entry, metrics.suffixRemovalCountObj); + if (!removedDir && entry.exists()) { + // If first deletion of the directory failed, then we try again once more + // just in case. + metrics.suffixEverFailed = true; + removedDir = tryRemoveDir(entry, metrics.suffixRemovalCountObj); + } + console.error( + `Deletion of folder ${entry.leafName} - success=${removedDir}` + ); + dirLock.unlock(); + cleanupDirLockFile(dirLock, entry.leafName); + } +} + +// Usage: +// removeDirectory parentDirPath childDirName secondsToWait [otherFoldersSuffix] +// arg0 arg1 arg2 arg3 +// [--test-sleep testSleep] +// [--metrics-id metricsId] +// parentDirPath - The path to the parent directory that includes the target directory +// childDirName - The "leaf name" of the moved cache directory +// If empty, the background task will only purge folders that have the "otherFoldersSuffix". +// secondsToWait - String representing the number of seconds to wait for the cacheDir to be moved +// otherFoldersSuffix - [optional] The suffix of directories that should be removed +// When not empty, this task will also attempt to remove all directories in +// the parent dir that end with this suffix +// testSleep - [optional] A test-only argument to sleep for a given milliseconds before removal. +// This exists to test whether a long-running task can survive. +// metricsId - [optional] The identifier for Glean metrics, in PascalCase. +// It'll be submitted only when the matching identifier exists in +// toolkit/components/backgroundtasks/metrics.yaml. +export async function runBackgroundTask(commandLine) { + const testSleep = Number.parseInt( + commandLine.handleFlagWithParam("test-sleep", false) + ); + const metricsId = commandLine.handleFlagWithParam("metrics-id", false) || ""; + + if (commandLine.length < 3) { + throw new Error("Insufficient arguments"); + } + + const parentDirPath = commandLine.getArgument(0); + const childDirName = commandLine.getArgument(1); + let secondsToWait = parseInt(commandLine.getArgument(2)); + if (isNaN(secondsToWait)) { + secondsToWait = 10; + } + commandLine.removeArguments(0, 2); + + let otherFoldersSuffix = ""; + if (commandLine.length) { + otherFoldersSuffix = commandLine.getArgument(0); + commandLine.removeArguments(0, 0); + } + + if (commandLine.length) { + throw new Error( + `${commandLine.length} unknown command args exist, closing.` + ); + } + + console.error( + parentDirPath, + childDirName, + secondsToWait, + otherFoldersSuffix, + metricsId + ); + + if (!Number.isNaN(testSleep)) { + await new Promise(resolve => lazy.setTimeout(resolve, testSleep)); + } + + const metrics = new Metrics(metricsId); + + try { + await deleteChildDirectory( + parentDirPath, + childDirName, + secondsToWait, + metrics + ); + await cleanupOtherDirectories(parentDirPath, otherFoldersSuffix, metrics); + } catch (err) { + metrics.succeeded = false; + throw err; + } finally { + await metrics.report(); + } + + return EXIT_CODE.SUCCESS; +} diff --git a/toolkit/components/backgroundtasks/BackgroundTask_success.sys.mjs b/toolkit/components/backgroundtasks/BackgroundTask_success.sys.mjs new file mode 100644 index 0000000000..f840e94646 --- /dev/null +++ b/toolkit/components/backgroundtasks/BackgroundTask_success.sys.mjs @@ -0,0 +1,12 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { EXIT_CODE } from "resource://gre/modules/BackgroundTasksManager.sys.mjs"; + +export async function runBackgroundTask() { + console.error("runBackgroundTask: success"); + + return EXIT_CODE.SUCCESS; +} diff --git a/toolkit/components/backgroundtasks/BackgroundTasks.cpp b/toolkit/components/backgroundtasks/BackgroundTasks.cpp new file mode 100644 index 0000000000..347c348024 --- /dev/null +++ b/toolkit/components/backgroundtasks/BackgroundTasks.cpp @@ -0,0 +1,498 @@ +/* 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/. */ + +#include "mozilla/BackgroundTasks.h" + +#include "nsIBackgroundTasksManager.h" +#include "nsICommandLine.h" +#include "nsIFile.h" +#include "nsImportModule.h" +#include "nsPrintfCString.h" +#include "nsProfileLock.h" +#include "nsTSubstring.h" +#include "nsThreadUtils.h" +#include "nsXULAppAPI.h" +#include "prenv.h" +#include "prtime.h" + +#include "mozilla/CmdLineAndEnvUtils.h" +#include "mozilla/LateWriteChecks.h" +#include "mozilla/UniquePtr.h" +#include "mozilla/Unused.h" + +namespace mozilla { + +NS_IMPL_ISUPPORTS(BackgroundTasks, nsIBackgroundTasks); + +BackgroundTasks::BackgroundTasks(Maybe<nsCString> aBackgroundTask) + : mBackgroundTask(std::move(aBackgroundTask)), mIsEphemeralProfile(false) { + // Log when a background task is created. + if (mBackgroundTask.isSome()) { + MOZ_LOG(sBackgroundTasksLog, mozilla::LogLevel::Info, + ("Created background task: %s", mBackgroundTask->get())); + } +} + +void BackgroundTasks::Init(Maybe<nsCString> aBackgroundTask) { + MOZ_RELEASE_ASSERT(XRE_IsParentProcess()); + + MOZ_RELEASE_ASSERT(!sSingleton, + "BackgroundTasks singleton already initialized"); + // The singleton will be cleaned up by `Shutdown()`. + sSingleton = new BackgroundTasks(std::move(aBackgroundTask)); +} + +void BackgroundTasks::Shutdown() { + MOZ_RELEASE_ASSERT(XRE_IsParentProcess()); + + MOZ_LOG(sBackgroundTasksLog, LogLevel::Info, ("Shutdown")); + + if (!sSingleton) { + return; + } + + if (sSingleton->mProfD && + !EnvHasValue("MOZ_BACKGROUNDTASKS_NO_REMOVE_PROFILE")) { + AutoSuspendLateWriteChecks suspend; + + if (sSingleton->mIsEphemeralProfile) { + // Log that the ephemeral profile is being removed. + if (MOZ_LOG_TEST(sBackgroundTasksLog, mozilla::LogLevel::Info)) { + nsAutoString path; + if (NS_SUCCEEDED(sSingleton->mProfD->GetPath(path))) { + MOZ_LOG(sBackgroundTasksLog, mozilla::LogLevel::Info, + ("Removing profile: %s", + NS_LossyConvertUTF16toASCII(path).get())); + } + } + + Unused << sSingleton->mProfD->Remove(/* aRecursive */ true); + } else { + // Log that the non-ephemeral profile is not being removed. + if (MOZ_LOG_TEST(sBackgroundTasksLog, mozilla::LogLevel::Debug)) { + nsAutoString path; + if (NS_SUCCEEDED(sSingleton->mProfD->GetPath(path))) { + MOZ_LOG(sBackgroundTasksLog, mozilla::LogLevel::Debug, + ("Not removing non-ephemeral profile: %s", + NS_LossyConvertUTF16toASCII(path).get())); + } + } + } + } + + sSingleton = nullptr; +} + +BackgroundTasks* BackgroundTasks::GetSingleton() { + if (!sSingleton) { + // xpcshell doesn't set up background tasks: default to no background + // task. + Init(Nothing()); + } + + MOZ_RELEASE_ASSERT(sSingleton, + "BackgroundTasks singleton should have been initialized"); + + return sSingleton.get(); +} + +already_AddRefed<BackgroundTasks> BackgroundTasks::GetSingletonAddRefed() { + return RefPtr<BackgroundTasks>(GetSingleton()).forget(); +} + +Maybe<nsCString> BackgroundTasks::GetBackgroundTasks() { + if (!XRE_IsParentProcess()) { + return Nothing(); + } + + return GetSingleton()->mBackgroundTask; +} + +bool BackgroundTasks::IsBackgroundTaskMode() { + if (!XRE_IsParentProcess()) { + return false; + } + + return GetBackgroundTasks().isSome(); +} + +nsresult BackgroundTasks::CreateEphemeralProfileDirectory( + nsIFile* aRootDir, const nsCString& aProfilePrefix, nsIFile** aFile) { + if (!XRE_IsParentProcess()) { + return NS_ERROR_NOT_AVAILABLE; + } + + Maybe<nsCString> task = GetBackgroundTasks(); + sSingleton->mIsEphemeralProfile = + task.isSome() && IsEphemeralProfileTaskName(task.ref()); + + MOZ_RELEASE_ASSERT(sSingleton->mIsEphemeralProfile); + + nsresult rv = sSingleton->CreateEphemeralProfileDirectoryImpl( + aRootDir, aProfilePrefix, aFile); + + // Log whether the ephemeral profile was created. + if (NS_WARN_IF(NS_FAILED(rv))) { + MOZ_LOG(sBackgroundTasksLog, mozilla::LogLevel::Warning, + ("Failed to create ephemeral profile directory!")); + } else { + if (MOZ_LOG_TEST(sBackgroundTasksLog, mozilla::LogLevel::Info)) { + nsAutoString path; + if (aFile && *aFile && NS_SUCCEEDED((*aFile)->GetPath(path))) { + MOZ_LOG(sBackgroundTasksLog, mozilla::LogLevel::Info, + ("Created ephemeral profile directory: %s", + NS_LossyConvertUTF16toASCII(path).get())); + } + } + } + + return rv; +} + +nsresult BackgroundTasks::CreateNonEphemeralProfileDirectory( + nsIFile* aRootDir, const nsCString& aProfilePrefix, nsIFile** aFile) { + if (!XRE_IsParentProcess()) { + return NS_ERROR_NOT_AVAILABLE; + } + + Maybe<nsCString> task = GetBackgroundTasks(); + sSingleton->mIsEphemeralProfile = + task.isSome() && IsEphemeralProfileTaskName(task.ref()); + + MOZ_RELEASE_ASSERT(!sSingleton->mIsEphemeralProfile); + + nsresult rv = sSingleton->CreateNonEphemeralProfileDirectoryImpl( + aRootDir, aProfilePrefix, aFile); + + // Log whether the non-ephemeral profile was created. + if (NS_WARN_IF(NS_FAILED(rv))) { + MOZ_LOG(sBackgroundTasksLog, mozilla::LogLevel::Warning, + ("Failed to create non-ephemeral profile directory!")); + } else { + if (MOZ_LOG_TEST(sBackgroundTasksLog, mozilla::LogLevel::Info)) { + nsAutoString path; + if (aFile && *aFile && NS_SUCCEEDED((*aFile)->GetPath(path))) { + MOZ_LOG(sBackgroundTasksLog, mozilla::LogLevel::Info, + ("Non-ephemeral profile directory existed or was created: %s", + NS_LossyConvertUTF16toASCII(path).get())); + } + } + } + + return rv; +} + +bool BackgroundTasks::IsEphemeralProfile() { + return sSingleton && sSingleton->mIsEphemeralProfile && sSingleton->mProfD; +} + +class BackgroundTaskLaunchRunnable : public Runnable { + public: + explicit BackgroundTaskLaunchRunnable(nsIBackgroundTasksManager* aManager, + const char* aTaskName, + nsICommandLine* aCmdLine) + : Runnable("BackgroundTaskLaunchRunnable"), + mManager(aManager), + mTaskName(aTaskName), + mCmdLine(aCmdLine) {} + + // MOZ_CAN_RUN_SCRIPT_BOUNDARY until Runnable::Run is MOZ_CAN_RUN_SCRIPT. See + // bug 1535398. + MOZ_CAN_RUN_SCRIPT_BOUNDARY NS_IMETHOD Run() override { + return mManager->RunBackgroundTaskNamed(mTaskName, mCmdLine); + } + + private: + nsCOMPtr<nsIBackgroundTasksManager> mManager; + NS_ConvertASCIItoUTF16 mTaskName; + nsCOMPtr<nsICommandLine> mCmdLine; +}; + +nsresult BackgroundTasks::RunBackgroundTask(nsICommandLine* aCmdLine) { + Maybe<nsCString> task = GetBackgroundTasks(); + if (task.isNothing()) { + return NS_ERROR_NOT_AVAILABLE; + } + + nsCOMPtr<nsIBackgroundTasksManager> manager = + do_GetService("@mozilla.org/backgroundtasksmanager;1"); + + MOZ_RELEASE_ASSERT(manager, "Could not get background tasks manager service"); + + // Give the initial storm of startup runnables a chance to run before our + // payload is going to potentially block the main thread for a while. + auto r = MakeRefPtr<BackgroundTaskLaunchRunnable>(manager, task.ref().get(), + aCmdLine); + return GetCurrentSerialEventTarget()->DelayedDispatch(r.forget(), 100); +} + +bool BackgroundTasks::IsUpdatingTaskName(const nsCString& aName) { + return aName.EqualsLiteral("backgroundupdate") || + aName.EqualsLiteral("shouldprocessupdates"); +} + +bool BackgroundTasks::IsEphemeralProfileTaskName(const nsCString& aName) { + return !(aName.EqualsLiteral("backgroundupdate") || + aName.EqualsLiteral("message") || // Just for development. + aName.EqualsLiteral("not_ephemeral_profile")); +} + +bool BackgroundTasks::IsNoOutputTaskName(const nsCString& aName) { + return aName.EqualsLiteral("pingsender") || + aName.EqualsLiteral("removeDirectory") || + aName.EqualsLiteral("no_output"); // Just for testing. +} + +nsCString BackgroundTasks::GetProfilePrefix(const nsCString& aInstallHash) { + return nsPrintfCString("%sBackgroundTask-%s-%s", MOZ_APP_VENDOR, + aInstallHash.get(), GetBackgroundTasks().ref().get()); +} + +nsresult BackgroundTasks::CreateNonEphemeralProfileDirectoryImpl( + nsIFile* aRootDir, const nsCString& aProfilePrefix, nsIFile** aFile) { + if (mBackgroundTask.isNothing()) { + return NS_ERROR_NOT_AVAILABLE; + } + + nsresult rv; + + nsCOMPtr<nsIFile> file; + if (mProfD) { + rv = mProfD->Clone(getter_AddRefs(file)); + NS_ENSURE_SUCCESS(rv, rv); + } else { + file = aRootDir; + + // The base path is + // /{UAppData}/Background Tasks + // Profiles/[salt].[vendor]BackgroundTask-[pathHash]-[taskName]. + rv = file->AppendNative(aProfilePrefix); + NS_ENSURE_SUCCESS(rv, rv); + + // Create the persistent profile directory if it does not exist. + bool exists; + rv = file->Exists(&exists); + NS_ENSURE_SUCCESS(rv, rv); + + if (!exists) { + rv = file->Create(nsIFile::DIRECTORY_TYPE, 0700); + NS_ENSURE_SUCCESS(rv, rv); + } + + rv = file->Clone(getter_AddRefs(mProfD)); + NS_ENSURE_SUCCESS(rv, rv); + } + + file.forget(aFile); + return NS_OK; +} + +nsresult BackgroundTasks::CreateEphemeralProfileDirectoryImpl( + nsIFile* aRootDir, const nsCString& aProfilePrefix, nsIFile** aFile) { + if (mBackgroundTask.isNothing()) { + return NS_ERROR_NOT_AVAILABLE; + } + + nsresult rv; + + nsCOMPtr<nsIFile> file; + if (mProfD) { + rv = mProfD->Clone(getter_AddRefs(file)); + NS_ENSURE_SUCCESS(rv, rv); + } else { + file = aRootDir; + + // Windows file cleanup is unreliable, so let's take a moment to clean up + // any prior background task profiles. We can continue if there was an error + // as creating a new ephemeral profile does not require cleaning up the old. + rv = RemoveStaleEphemeralProfileDirectories(file, aProfilePrefix); + if (NS_WARN_IF(NS_FAILED(rv))) { + MOZ_LOG(sBackgroundTasksLog, mozilla::LogLevel::Warning, + ("Error cleaning up stale ephemeral profile directories.")); + } + + // The base path is /tmp/[vendor]BackgroundTask-[pathHash]-[taskName]. + rv = file->AppendNative(aProfilePrefix); + NS_ENSURE_SUCCESS(rv, rv); + + // Create a unique profile directory. This can fail if there are too many + // (thousands) of existing directories, which is unlikely to happen. + rv = file->CreateUnique(nsIFile::DIRECTORY_TYPE, 0700); + NS_ENSURE_SUCCESS(rv, rv); + + rv = file->Clone(getter_AddRefs(mProfD)); + NS_ENSURE_SUCCESS(rv, rv); + } + + file.forget(aFile); + return NS_OK; +} + +nsresult BackgroundTasks::RemoveStaleEphemeralProfileDirectories( + nsIFile* const aRoot, const nsCString& aPrefix) { + nsresult rv; + + if (MOZ_LOG_TEST(sBackgroundTasksLog, LogLevel::Info)) { + nsAutoString path; + if (NS_SUCCEEDED(aRoot->GetPath(path))) { + MOZ_LOG(sBackgroundTasksLog, mozilla::LogLevel::Info, + ("Checking \"%s\" for stale profiles matching \"%s\".", + NS_LossyConvertUTF16toASCII(path.get()).get(), aPrefix.get())); + } + } + + // Check how old a background task should be before being deleted. + PRTime timeoutMillis = 60 * 1000; // Default to 1 minute. + bool deleteAll = false; + // MOZ_BACKGROUNDTASKS_PURGE_STALE_PROFILES = ["0"+ in ms, "always", "never"] + nsAutoCString envTimeoutStr( + PR_GetEnv("MOZ_BACKGROUNDTASKS_PURGE_STALE_PROFILES")); + + if (!envTimeoutStr.IsEmpty()) { + int64_t envTimeoutMillis = envTimeoutStr.ToInteger64(&rv); + if (NS_SUCCEEDED(rv)) { + if (envTimeoutMillis >= 0) { + timeoutMillis = envTimeoutMillis; + MOZ_LOG(sBackgroundTasksLog, mozilla::LogLevel::Info, + ("Setting stale profile age to %sms", envTimeoutStr.get())); + } else { + MOZ_LOG(sBackgroundTasksLog, mozilla::LogLevel::Warning, + ("MOZ_BACKGROUNDTASKS_PURGE_STALE_PROFILES is set less than 0, " + "using default timeout instead.")); + } + } else { + if (envTimeoutStr.Equals("always")) { + deleteAll = true; + MOZ_LOG(sBackgroundTasksLog, mozilla::LogLevel::Info, + ("Deleting profiles regardless of age.")); + } else if (envTimeoutStr.Equals("never")) { + MOZ_LOG(sBackgroundTasksLog, mozilla::LogLevel::Info, + ("Skipping cleanup of stale background task profiles.")); + return NS_OK; + } else { + MOZ_LOG(sBackgroundTasksLog, mozilla::LogLevel::Warning, + ("MOZ_BACKGROUNDTASKS_PURGE_STALE_PROFILES is set to invalid " + "value, using default timeout instead.")); + } + } + } + + nsCOMPtr<nsIDirectoryEnumerator> entries; + rv = aRoot->GetDirectoryEntries(getter_AddRefs(entries)); + NS_ENSURE_SUCCESS(rv, rv); + + nsCOMPtr<nsIFile> entry; + int removedProfiles = 0; + // Limit the number of stale ephemeral profiles we clean up so that we don't + // timeout the background task. + const int kMaxRemovedProfiles = 5; + + // Loop over the ephemeral directory entries, deleting folders matching our + // profile prefix. Continue if there is an error interacting with the entry to + // more reliably make progress on cleaning up stale ephemeral profiles. + while (removedProfiles < kMaxRemovedProfiles && + NS_SUCCEEDED(rv = entries->GetNextFile(getter_AddRefs(entry))) && + entry) { + nsCString entryName; + rv = entry->GetNativeLeafName(entryName); + if (NS_FAILED(rv)) { + continue; + } + + // Find profile folders matching our prefix. + if (!StringBeginsWith(entryName, aPrefix)) { + continue; + } + + if (!deleteAll) { + // Skip profiles that were recently created to prevent deleting a profile + // after creating the directory but before creating the lockfile. + PRTime profileModifyTime; + if (NS_FAILED(entry->GetLastModifiedTime(&profileModifyTime))) { + MOZ_LOG(sBackgroundTasksLog, mozilla::LogLevel::Warning, + ("Skipping deletion of %s, unable to retrieve when profile was " + "last modified.", + entryName.get())); + continue; + } + PRTime now = PR_Now() / PR_USEC_PER_MSEC; + // Timeout only needs to be large enough to prevent deleting a ephemeral + // profile between it being created and locked. + if (now - profileModifyTime < timeoutMillis) { + MOZ_LOG(sBackgroundTasksLog, mozilla::LogLevel::Debug, + ("Skipping deletion of %s, profile is not yet stale.", + entryName.get())); + continue; + } + } + + // Check if the profile is locked. If successful drop the lock so we can + // delete the folder. Background tasks' ephemeral profiles are not reused or + // remembered once released, so we don't need to hold this lock while + // deleting it. + nsProfileLock lock; + if (NS_FAILED(lock.Lock(entry, nullptr)) || NS_FAILED(lock.Unlock())) { + MOZ_LOG(sBackgroundTasksLog, mozilla::LogLevel::Warning, + ("Skipping deletion of %s, unable to lock/unlock profile.", + entryName.get())); + continue; + } + + rv = entry->Remove(true); + if (NS_FAILED(rv)) { + if (MOZ_LOG_TEST(sBackgroundTasksLog, mozilla::LogLevel::Warning)) { + nsAutoString path; + if (NS_SUCCEEDED(entry->GetPath(path))) { + MOZ_LOG(sBackgroundTasksLog, mozilla::LogLevel::Warning, + ("Error removing stale ephemeral profile directory: %s", + NS_LossyConvertUTF16toASCII(path).get())); + } + } + + continue; + } + + if (MOZ_LOG_TEST(sBackgroundTasksLog, mozilla::LogLevel::Info)) { + nsAutoString path; + if (NS_SUCCEEDED(entry->GetPath(path))) { + MOZ_LOG(sBackgroundTasksLog, mozilla::LogLevel::Info, + ("Removed stale ephemeral profile directory: %s", + NS_LossyConvertUTF16toASCII(path).get())); + } + } + + removedProfiles++; + } + + return NS_OK; +} + +nsresult BackgroundTasks::GetIsBackgroundTaskMode(bool* result) { + *result = mBackgroundTask.isSome(); + return NS_OK; +} + +nsresult BackgroundTasks::BackgroundTaskName(nsAString& name) { + name.SetIsVoid(true); + if (mBackgroundTask.isSome()) { + name.AssignASCII(mBackgroundTask.ref()); + } + return NS_OK; +} + +nsresult BackgroundTasks::OverrideBackgroundTaskNameForTesting( + const nsAString& name) { + if (name.IsVoid()) { + mBackgroundTask = Nothing(); + } else { + mBackgroundTask = Some(NS_LossyConvertUTF16toASCII(name)); + } + return NS_OK; +} + +StaticRefPtr<BackgroundTasks> BackgroundTasks::sSingleton; + +LazyLogModule BackgroundTasks::sBackgroundTasksLog("BackgroundTasks"); + +} // namespace mozilla diff --git a/toolkit/components/backgroundtasks/BackgroundTasks.h b/toolkit/components/backgroundtasks/BackgroundTasks.h new file mode 100644 index 0000000000..030a209c79 --- /dev/null +++ b/toolkit/components/backgroundtasks/BackgroundTasks.h @@ -0,0 +1,124 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#ifndef mozilla_BackgroundTasks_h +#define mozilla_BackgroundTasks_h + +#include "nsCOMPtr.h" +#include "nsIBackgroundTasks.h" +#include "nsISupports.h" +#include "nsString.h" + +#include "mozilla/Logging.h" +#include "mozilla/Maybe.h" +#include "mozilla/StaticPtr.h" + +class nsICommandLine; +class nsIFile; + +namespace mozilla { + +class BackgroundTasks final : public nsIBackgroundTasks { + public: + NS_DECL_ISUPPORTS + NS_DECL_NSIBACKGROUNDTASKS + + public: + explicit BackgroundTasks(Maybe<nsCString> aBackgroundTask); + + static void Init(Maybe<nsCString> aBackgroundTask); + + static void Shutdown(); + + /** + * Return a raw pointer to the singleton instance. Use this accessor in C++ + * code that just wants to call a method on the instance, but does not need to + * hold a reference. + */ + static BackgroundTasks* GetSingleton(); + + /** + * Return an addRef'd pointer to the singleton instance. This is used by the + * XPCOM constructor that exists to support usage from JS. + */ + static already_AddRefed<BackgroundTasks> GetSingletonAddRefed(); + + static Maybe<nsCString> GetBackgroundTasks(); + + static bool IsBackgroundTaskMode(); + + static nsresult CreateEphemeralProfileDirectory( + nsIFile* aRootDir, const nsCString& aProfilePrefix, nsIFile** aFile); + + static nsresult CreateNonEphemeralProfileDirectory( + nsIFile* aRootDir, const nsCString& aProfilePrefix, nsIFile** aFile); + + static bool IsEphemeralProfile(); + + static nsresult RunBackgroundTask(nsICommandLine* aCmdLine); + + /** + * Whether the given task name should process updates. Most tasks should not + * process updates to avoid Firefox being updated unexpectedly. + * + * At the time of writing, we only process updates for the `backgroundupdate` + * task and the test-only `shouldprocessupdates` task. + */ + static bool IsUpdatingTaskName(const nsCString& aName); + + /** + * Whether the given task name should use a temporary ephemeral + * profile. Most tasks should use a temporary ephemeral profile to + * allow concurrent task invocation and to simplify reasoning. + * + * At the time of writing, we use temporary ephemeral profiles for all tasks + * save the `backgroundupdate` task and the test-only `notephemeralprofile` + * task. + */ + static bool IsEphemeralProfileTaskName(const nsCString& aName); + + /** + * Whether the given task name should produce no output. This is achieved by + * redirecting stdout and stderr to /dev/null (or, on Windows, nul:). + * profile. Most tasks should produce output. + * + * At the time of writing, we produce no output for the `pingsender` task and + * the test-only `no_output` task. + */ + static bool IsNoOutputTaskName(const nsCString& aName); + + /** + * Get the installation-specific profile prefix for the current task name and + * the given install hash. + */ + static nsCString GetProfilePrefix(const nsCString& aInstallHash); + + protected: + static StaticRefPtr<BackgroundTasks> sSingleton; + static LazyLogModule sBackgroundTasksLog; + + Maybe<nsCString> mBackgroundTask; + bool mIsEphemeralProfile; + nsCOMPtr<nsIFile> mProfD; + + nsresult CreateEphemeralProfileDirectoryImpl(nsIFile* aRootDir, + const nsCString& aProfilePrefix, + nsIFile** aFile); + + nsresult CreateNonEphemeralProfileDirectoryImpl( + nsIFile* aRootDir, const nsCString& aProfilePrefix, nsIFile** aFile); + /* + * Iterates children of `aRoot` and removes unlocked profiles matching + * `aPrefix`. + */ + static nsresult RemoveStaleEphemeralProfileDirectories( + nsIFile* const aRoot, const nsCString& aPrefix); + + virtual ~BackgroundTasks() = default; +}; + +} // namespace mozilla + +#endif // mozilla_BackgroundTasks_h diff --git a/toolkit/components/backgroundtasks/BackgroundTasksManager.sys.mjs b/toolkit/components/backgroundtasks/BackgroundTasksManager.sys.mjs new file mode 100644 index 0000000000..0c2f277a23 --- /dev/null +++ b/toolkit/components/backgroundtasks/BackgroundTasksManager.sys.mjs @@ -0,0 +1,329 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "log", () => { + let { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" + ); + let consoleOptions = { + // tip: set maxLogLevel to "debug" and use log.debug() to create detailed + // messages during development. See LOG_LEVELS in Console.sys.mjs for details. + maxLogLevel: "error", + maxLogLevelPref: "toolkit.backgroundtasks.loglevel", + prefix: "BackgroundTasksManager", + }; + return new ConsoleAPI(consoleOptions); +}); + +XPCOMUtils.defineLazyGetter(lazy, "DevToolsStartup", () => { + return Cc["@mozilla.org/devtools/startup-clh;1"].getService( + Ci.nsICommandLineHandler + ).wrappedJSObject; +}); + +// The default timing settings can be overriden by the preferences +// toolkit.backgroundtasks.defaultTimeoutSec and +// toolkit.backgroundtasks.defaultMinTaskRuntimeMS for all background tasks +// and individually per module by +// export const backgroundTaskTimeoutSec = X; +// export const backgroundTaskMinRuntimeMS = Y; +let timingSettings = { + minTaskRuntimeMS: 500, + maxTaskRuntimeSec: 600, // 10 minutes. +}; + +// Map resource://testing-common/ to the shared test modules directory. This is +// a transliteration of `register_modules_protocol_handler` from +// https://searchfox.org/mozilla-central/rev/f081504642a115cb8236bea4d8250e5cb0f39b02/testing/xpcshell/head.js#358-389. +function registerModulesProtocolHandler() { + let _TESTING_MODULES_URI = Services.env.get( + "XPCSHELL_TESTING_MODULES_URI", + "" + ); + if (!_TESTING_MODULES_URI) { + return false; + } + + let protocolHandler = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + + protocolHandler.setSubstitution( + "testing-common", + Services.io.newURI(_TESTING_MODULES_URI) + ); + // Log loudly so that when testing, we always actually use the + // console logging mechanism and therefore deterministically load that code. + lazy.log.error( + `Substitution set: resource://testing-common aliases ${_TESTING_MODULES_URI}` + ); + + return true; +} + +function locationsForBackgroundTaskNamed(name) { + const subModules = [ + "resource:///modules", // App-specific first. + "resource://gre/modules", // Toolkit/general second. + ]; + + if (registerModulesProtocolHandler()) { + subModules.push("resource://testing-common"); // Test-only third. + } + + let locations = []; + for (const subModule of subModules) { + let URI = `${subModule}/backgroundtasks/BackgroundTask_${name}.sys.mjs`; + locations.push(URI); + } + + return locations; +} + +/** + * Find an ES module named like `backgroundtasks/BackgroundTask_${name}.sys.mjs`, + * import it, and return the whole module. + * + * When testing, allow to load from `XPCSHELL_TESTING_MODULES_URI`, + * which is registered at `resource://testing-common`, the standard + * location for test-only modules. + * + * @return {Object} The imported module. + * @throws NS_ERROR_NOT_AVAILABLE if a background task with the given `name` is + * not found. + */ +function findBackgroundTaskModule(name) { + for (const URI of locationsForBackgroundTaskNamed(name)) { + lazy.log.debug(`Looking for background task at URI: ${URI}`); + + try { + const taskModule = ChromeUtils.importESModule(URI); + lazy.log.info(`Found background task at URI: ${URI}`); + return taskModule; + } catch (ex) { + if (ex.result != Cr.NS_ERROR_FILE_NOT_FOUND) { + throw ex; + } + } + } + + lazy.log.warn(`No backgroundtask named '${name}' registered`); + throw new Components.Exception( + `No backgroundtask named '${name}' registered`, + Cr.NS_ERROR_NOT_AVAILABLE + ); +} + +export class BackgroundTasksManager { + get helpInfo() { + const bts = Cc["@mozilla.org/backgroundtasks;1"].getService( + Ci.nsIBackgroundTasks + ); + + if (bts.isBackgroundTaskMode) { + return lazy.DevToolsStartup.jsdebuggerHelpInfo; + } + + return ""; + } + + handle(commandLine) { + const bts = Cc["@mozilla.org/backgroundtasks;1"].getService( + Ci.nsIBackgroundTasks + ); + + if (!bts.isBackgroundTaskMode) { + lazy.log.info( + `${Services.appinfo.processID}: !isBackgroundTaskMode, exiting` + ); + return; + } + + const name = bts.backgroundTaskName(); + lazy.log.info( + `${Services.appinfo.processID}: Preparing to run background task named '${name}'` + + ` (with ${commandLine.length} arguments)` + ); + + if (!("@mozilla.org/devtools/startup-clh;1" in Cc)) { + return; + } + + // Check this before the devtools startup flow handles and removes it. + const CASE_INSENSITIVE = false; + if ( + commandLine.findFlag("jsdebugger", CASE_INSENSITIVE) < 0 && + commandLine.findFlag("start-debugger-server", CASE_INSENSITIVE) < 0 + ) { + lazy.log.info( + `${Services.appinfo.processID}: No devtools flag found; not preparing devtools thread` + ); + return; + } + + const waitFlag = + commandLine.findFlag("wait-for-jsdebugger", CASE_INSENSITIVE) != -1; + if (waitFlag) { + function onDevtoolsThreadReady(subject, topic, data) { + lazy.log.info( + `${Services.appinfo.processID}: Setting breakpoints for background task named '${name}'` + + ` (with ${commandLine.length} arguments)` + ); + + const threadActor = subject.wrappedJSObject; + threadActor.setBreakpointOnLoad(locationsForBackgroundTaskNamed(name)); + + Services.obs.removeObserver(onDevtoolsThreadReady, topic); + } + + Services.obs.addObserver(onDevtoolsThreadReady, "devtools-thread-ready"); + } + + const DevToolsStartup = Cc[ + "@mozilla.org/devtools/startup-clh;1" + ].getService(Ci.nsICommandLineHandler); + DevToolsStartup.handle(commandLine); + } + + async runBackgroundTaskNamed(name, commandLine) { + function addMarker(markerName) { + return ChromeUtils.addProfilerMarker(markerName, undefined, name); + } + addMarker("BackgroundTasksManager:AfterRunBackgroundTaskNamed"); + + lazy.log.info( + `${Services.appinfo.processID}: Running background task named '${name}'` + + ` (with ${commandLine.length} arguments)` + ); + lazy.log.debug( + `${Services.appinfo.processID}: Background task using profile` + + ` '${Services.dirsvc.get("ProfD", Ci.nsIFile).path}'` + ); + + let exitCode = EXIT_CODE.NOT_FOUND; + try { + let taskModule = findBackgroundTaskModule(name); + addMarker("BackgroundTasksManager:AfterFindRunBackgroundTask"); + + // Get timing configuration. First check for default preferences, + // then for per module overrides. + timingSettings.minTaskRuntimeMS = Services.prefs.getIntPref( + "toolkit.backgroundtasks.defaultMinTaskRuntimeMS", + timingSettings.minTaskRuntimeMS + ); + if (taskModule.backgroundTaskMinRuntimeMS) { + timingSettings.minTaskRuntimeMS = taskModule.backgroundTaskMinRuntimeMS; + } + timingSettings.maxTaskRuntimeSec = Services.prefs.getIntPref( + "toolkit.backgroundtasks.defaultTimeoutSec", + timingSettings.maxTaskRuntimeSec + ); + if (taskModule.backgroundTaskTimeoutSec) { + timingSettings.maxTaskRuntimeSec = taskModule.backgroundTaskTimeoutSec; + } + + try { + let minimumReached = false; + let minRuntime = new Promise(resolve => + lazy.setTimeout(() => { + minimumReached = true; + resolve(true); + }, timingSettings.minTaskRuntimeMS) + ); + exitCode = await Promise.race([ + new Promise(resolve => + lazy.setTimeout(() => { + lazy.log.error(`Background task named '${name}' timed out`); + resolve(EXIT_CODE.TIMEOUT); + }, timingSettings.maxTaskRuntimeSec * 1000) + ), + taskModule.runBackgroundTask(commandLine), + ]); + if (!minimumReached) { + lazy.log.debug( + `Backgroundtask named '${name}' waiting for minimum runtime.` + ); + await minRuntime; + } + lazy.log.info( + `Backgroundtask named '${name}' completed with exit code ${exitCode}` + ); + } catch (e) { + lazy.log.error(`Backgroundtask named '${name}' threw exception`, e); + exitCode = EXIT_CODE.EXCEPTION; + } + } finally { + addMarker("BackgroundTasksManager:AfterAwaitRunBackgroundTask"); + + lazy.log.info(`Invoking Services.startup.quit(..., ${exitCode})`); + Services.startup.quit(Ci.nsIAppStartup.eForceQuit, exitCode); + } + + return exitCode; + } + + classID = Components.ID("{4d48c536-e16f-4699-8f9c-add4f28f92f0}"); + QueryInterface = ChromeUtils.generateQI([ + "nsIBackgroundTasksManager", + "nsICommandLineHandler", + ]); +} + +/** + * Background tasks should standard exit code conventions where 0 denotes + * success and non-zero denotes failure and/or an error. In addition, since + * background tasks have limited channels to communicate with consumers, the + * special values `NOT_FOUND` (integer 2) and `THREW_EXCEPTION` (integer 3) are + * distinguished. + * + * If you extend this to add background task-specific exit codes, use exit codes + * greater than 10 to allow for additional shared exit codes to be added here. + * Exit codes should be between 0 and 127 to be safe across platforms. + */ +export const EXIT_CODE = { + /** + * The task succeeded. + * + * The `runBackgroundTask(...)` promise resolved to 0. + */ + SUCCESS: 0, + + /** + * The task with the specified name could not be found or imported. + * + * The corresponding `runBackgroundTask` method could not be found. + */ + NOT_FOUND: 2, + + /** + * The task failed with an uncaught exception. + * + * The `runBackgroundTask(...)` promise rejected with an exception. + */ + EXCEPTION: 3, + + /** + * The task took too long and timed out. + * + * The default timeout is controlled by the pref: + * "toolkit.backgroundtasks.defaultTimeoutSec", but tasks can override this + * by exporting a non-zero `backgroundTaskTimeoutSec` value. + */ + TIMEOUT: 4, + + /** + * The last exit code reserved by this structure. Use codes larger than this + * code for background task-specific exit codes. + */ + LAST_RESERVED: 10, +}; diff --git a/toolkit/components/backgroundtasks/BackgroundTasksRunner.cpp b/toolkit/components/backgroundtasks/BackgroundTasksRunner.cpp new file mode 100644 index 0000000000..82aaba0380 --- /dev/null +++ b/toolkit/components/backgroundtasks/BackgroundTasksRunner.cpp @@ -0,0 +1,108 @@ +/* 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/. */ + +#include "mozilla/BackgroundTasksRunner.h" + +#include "base/process_util.h" +#include "mozilla/StaticPrefs_datareporting.h" +#include "mozilla/StaticPrefs_telemetry.h" +#include "mozilla/StaticPrefs_toolkit.h" +#include "nsIFile.h" + +#ifdef XP_WIN +# include "mozilla/AssembleCmdLine.h" +#endif + +#include "mozilla/ResultVariant.h" + +namespace mozilla { + +NS_IMPL_ISUPPORTS(BackgroundTasksRunner, nsIBackgroundTasksRunner); + +NS_IMETHODIMP BackgroundTasksRunner::RunInDetachedProcess( + const nsACString& aTaskName, const nsTArray<nsCString>& aArgs) { + nsCOMPtr<nsIFile> lf; + nsresult rv = XRE_GetBinaryPath(getter_AddRefs(lf)); + NS_ENSURE_SUCCESS(rv, rv); + + nsAutoCString exePath; +#if !defined(XP_WIN) + rv = lf->GetNativePath(exePath); +#else + rv = lf->GetNativeTarget(exePath); +#endif + NS_ENSURE_SUCCESS(rv, rv); + + base::LaunchOptions options; +#ifdef XP_WIN + options.start_independent = true; + + nsTArray<const char*> argv = {exePath.Data(), "--backgroundtask", + aTaskName.Data()}; + for (const nsCString& str : aArgs) { + argv.AppendElement(str.get()); + } + argv.AppendElement(nullptr); + + wchar_t* assembledCmdLine = nullptr; + if (assembleCmdLine(argv.Elements(), &assembledCmdLine, CP_UTF8) == -1) { + return NS_ERROR_FAILURE; + } + + if (base::LaunchApp(assembledCmdLine, options, nullptr).isErr()) { + return NS_ERROR_FAILURE; + } +#else + std::vector<std::string> argv = {exePath.Data(), "--backgroundtask", + aTaskName.Data()}; + for (const nsCString& str : aArgs) { + argv.push_back(str.get()); + } + + if (base::LaunchApp(argv, options, nullptr).isErr()) { + return NS_ERROR_FAILURE; + } +#endif + + return NS_OK; +} + +NS_IMETHODIMP BackgroundTasksRunner::RemoveDirectoryInDetachedProcess( + const nsACString& aParentDirPath, const nsACString& aChildDirName, + const nsACString& aSecondsToWait, const nsACString& aOtherFoldersSuffix, + const nsACString& aMetricsId) { + nsTArray<nsCString> argv = {aParentDirPath + ""_ns, aChildDirName + ""_ns, + aSecondsToWait + ""_ns, + aOtherFoldersSuffix + ""_ns}; + + uint32_t testingSleepMs = + StaticPrefs::toolkit_background_tasks_remove_directory_testing_sleep_ms(); + if (testingSleepMs > 0) { + argv.AppendElement("--test-sleep"); + nsAutoCString sleep; + sleep.AppendInt(testingSleepMs); + argv.AppendElement(sleep); + } + + bool telemetryEnabled = + StaticPrefs::datareporting_healthreport_uploadEnabled() && + // Talos set this to not send telemetry but still enable the code path. + // But in this case we just disable it since this telemetry happens + // independently from the main process and thus shouldn't be relevant to + // performance tests. + StaticPrefs::telemetry_fog_test_localhost_port() != -1; + + if (!aMetricsId.IsEmpty() && telemetryEnabled) { + argv.AppendElement("--metrics-id"); + argv.AppendElement(aMetricsId); + } + +#ifdef DEBUG + argv.AppendElement("--attach-console"); +#endif + + return RunInDetachedProcess("removeDirectory"_ns, argv); +} + +} // namespace mozilla diff --git a/toolkit/components/backgroundtasks/BackgroundTasksRunner.h b/toolkit/components/backgroundtasks/BackgroundTasksRunner.h new file mode 100644 index 0000000000..761893a876 --- /dev/null +++ b/toolkit/components/backgroundtasks/BackgroundTasksRunner.h @@ -0,0 +1,24 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#ifndef TOOLKIT_COMPONENTS_BACKGROUNDTASKS_BACKGROUNDTASKSRUNNER_H_ +#define TOOLKIT_COMPONENTS_BACKGROUNDTASKS_BACKGROUNDTASKSRUNNER_H_ + +#include "nsString.h" +#include "nsIBackgroundTasksRunner.h" + +namespace mozilla { + +class BackgroundTasksRunner final : public nsIBackgroundTasksRunner { + public: + NS_DECL_THREADSAFE_ISUPPORTS + NS_DECL_NSIBACKGROUNDTASKSRUNNER + protected: + ~BackgroundTasksRunner() = default; +}; + +} // namespace mozilla + +#endif // TOOLKIT_COMPONENTS_BACKGROUNDTASKS_BACKGROUNDTASKSRUNNER_H_ diff --git a/toolkit/components/backgroundtasks/BackgroundTasksTestUtils.sys.mjs b/toolkit/components/backgroundtasks/BackgroundTasksTestUtils.sys.mjs new file mode 100644 index 0000000000..72a9d3e4f3 --- /dev/null +++ b/toolkit/components/backgroundtasks/BackgroundTasksTestUtils.sys.mjs @@ -0,0 +1,137 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +import { Subprocess } from "resource://gre/modules/Subprocess.sys.mjs"; + +function getFirefoxExecutableFilename() { + if (AppConstants.platform === "win") { + return AppConstants.MOZ_APP_NAME + ".exe"; + } + if (AppConstants.platform == "linux") { + return AppConstants.MOZ_APP_NAME + "-bin"; + } + return AppConstants.MOZ_APP_NAME; +} + +// Returns a nsIFile to the firefox.exe (really, application) executable file. +function getFirefoxExecutableFile() { + let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file = Services.dirsvc.get("GreBinD", Ci.nsIFile); + + file.append(getFirefoxExecutableFilename()); + return file; +} + +export var BackgroundTasksTestUtils = { + init(scope) { + this.testScope = scope; + }, + + async do_backgroundtask( + task, + options = { extraArgs: [], extraEnv: {}, onStdoutLine: null } + ) { + options = Object.assign({}, options); + options.extraArgs = options.extraArgs || []; + options.extraEnv = options.extraEnv || {}; + + let command = getFirefoxExecutableFile().path; + let args = ["--backgroundtask", task]; + args.push(...options.extraArgs); + + // Ensure `resource://testing-common` gets mapped. + let protocolHandler = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + + let uri = protocolHandler.getSubstitution("testing-common"); + const { Assert } = this.testScope; + Assert.ok(!!uri, "resource://testing-common is not substituted"); + + // The equivalent of _TESTING_MODULES_DIR in xpcshell. + options.extraEnv.XPCSHELL_TESTING_MODULES_URI = uri.spec; + + // Now we can actually invoke the process. + console.info(`launching background task`, { + command, + args, + extraEnv: options.extraEnv, + }); + let { proc, readPromise } = await Subprocess.call({ + command, + arguments: args, + environment: options.extraEnv, + environmentAppend: true, + stderr: "stdout", + }).then(p => { + p.stdin.close().catch(() => { + // It's possible that the process exists before we close stdin. + // In that case, we should ignore the errors. + }); + const dumpPipe = async pipe => { + // We must assemble all of the string fragments from stdout. + let leftover = ""; + let data = await pipe.readString(); + while (data) { + data = leftover + data; + // When the string is empty and the separator is not empty, + // split() returns an array containing one empty string, + // rather than an empty array, i.e., we always have + // `lines.length > 0`. + let lines = data.split(/\r\n|\r|\n/); + for (let line of lines.slice(0, -1)) { + dump(`${p.pid}> ${line}\n`); + if (options.onStdoutLine) { + options.onStdoutLine(line, p); + } + } + leftover = lines[lines.length - 1]; + data = await pipe.readString(); + } + + if (leftover.length) { + dump(`${p.pid}> ${leftover}\n`); + if (options.onStdoutLine) { + options.onStdoutLine(leftover, p); + } + } + }; + let readPromise = dumpPipe(p.stdout); + + return { proc: p, readPromise }; + }); + + let { exitCode } = await proc.wait(); + try { + // Read from the output pipe. + await readPromise; + } catch (e) { + if (e.message !== "File closed") { + throw e; + } + } + + return exitCode; + }, + + // Setup that allows to use the profile service in xpcshell tests, + // lifted from `toolkit/profile/xpcshell/head.js`. + setupProfileService() { + let gProfD = this.testScope.do_get_profile(); + let gDataHome = gProfD.clone(); + gDataHome.append("data"); + gDataHome.createUnique(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + let gDataHomeLocal = gProfD.clone(); + gDataHomeLocal.append("local"); + gDataHomeLocal.createUnique(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + + let xreDirProvider = Cc["@mozilla.org/xre/directory-provider;1"].getService( + Ci.nsIXREDirProvider + ); + xreDirProvider.setUserDataDirectory(gDataHome, false); + xreDirProvider.setUserDataDirectory(gDataHomeLocal, true); + }, +}; diff --git a/toolkit/components/backgroundtasks/BackgroundTasksUtils.sys.mjs b/toolkit/components/backgroundtasks/BackgroundTasksUtils.sys.mjs new file mode 100644 index 0000000000..d2a65569f4 --- /dev/null +++ b/toolkit/components/backgroundtasks/BackgroundTasksUtils.sys.mjs @@ -0,0 +1,407 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "log", () => { + let { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" + ); + let consoleOptions = { + // tip: set maxLogLevel to "debug" and use log.debug() to create detailed + // messages during development. See LOG_LEVELS in Console.sys.mjs for details. + maxLogLevel: "error", + maxLogLevelPref: "toolkit.backgroundtasks.loglevel", + prefix: "BackgroundTasksUtils", + }; + return new ConsoleAPI(consoleOptions); +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "ProfileService", + "@mozilla.org/toolkit/profile-service;1", + "nsIToolkitProfileService" +); + +ChromeUtils.defineESModuleGetters(lazy, { + ExperimentManager: "resource://nimbus/lib/ExperimentManager.sys.mjs", + + RemoteSettingsExperimentLoader: + "resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + ASRouter: "resource://activity-stream/lib/ASRouter.jsm", + + ASRouterDefaultConfig: + "resource://activity-stream/lib/ASRouterDefaultConfig.jsm", +}); + +class CannotLockProfileError extends Error { + constructor(message) { + super(message); + this.name = "CannotLockProfileError"; + } +} + +export var BackgroundTasksUtils = { + // Manage our own default profile that can be overridden for testing. It's + // easier to do this here rather than using the profile service itself. + _defaultProfileInitialized: false, + _defaultProfile: null, + + getDefaultProfile() { + if (!this._defaultProfileInitialized) { + this._defaultProfileInitialized = true; + // This is all test-only. + let defaultProfilePath = Services.env.get( + "MOZ_BACKGROUNDTASKS_DEFAULT_PROFILE_PATH" + ); + let noDefaultProfile = Services.env.get( + "MOZ_BACKGROUNDTASKS_NO_DEFAULT_PROFILE" + ); + if (defaultProfilePath) { + lazy.log.info( + `getDefaultProfile: using default profile path ${defaultProfilePath}` + ); + var tmpd = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + tmpd.initWithPath(defaultProfilePath); + // Sadly this writes to `profiles.ini`, but there's little to be done. + this._defaultProfile = lazy.ProfileService.createProfile( + tmpd, + `MOZ_BACKGROUNDTASKS_DEFAULT_PROFILE_PATH-${Date.now()}` + ); + } else if (noDefaultProfile) { + lazy.log.info(`getDefaultProfile: setting default profile to null`); + this._defaultProfile = null; + } else { + try { + lazy.log.info( + `getDefaultProfile: using ProfileService.defaultProfile` + ); + this._defaultProfile = lazy.ProfileService.defaultProfile; + } catch (e) {} + } + } + return this._defaultProfile; + }, + + hasDefaultProfile() { + return this.getDefaultProfile() != null; + }, + + currentProfileIsDefaultProfile() { + let defaultProfile = this.getDefaultProfile(); + let currentProfile = lazy.ProfileService.currentProfile; + // This comparison needs to accommodate null on both sides. + let isDefaultProfile = defaultProfile && currentProfile == defaultProfile; + return isDefaultProfile; + }, + + _throwIfNotLocked(lock) { + if (!(lock instanceof Ci.nsIProfileLock)) { + throw new Error("Passed lock was not an instance of nsIProfileLock"); + } + + try { + // In release builds, `.directory` throws NS_ERROR_NOT_INITIALIZED when + // unlocked. In debug builds, `.directory` when the profile is not locked + // will crash via `NS_ERROR`. + if (lock.directory) { + return; + } + } catch (e) { + if ( + !( + e instanceof Ci.nsIException && + e.result == Cr.NS_ERROR_NOT_INITIALIZED + ) + ) { + throw e; + } + } + throw new Error("Profile is not locked"); + }, + + /** + * Locks the given profile and provides the path to it to the callback. + * The callback should return a promise and once settled the profile is + * unlocked and then the promise returned back to the caller of this function. + * + * @template T + * @param {(lock: nsIProfileLock) => Promise<T>} callback + * @param {nsIToolkitProfile} [profile] defaults to default profile + * @return {Promise<T>} + */ + async withProfileLock(callback, profile = this.getDefaultProfile()) { + if (!profile) { + throw new Error("No default profile exists"); + } + + let lock; + try { + lock = profile.lock({}); + lazy.log.info( + `withProfileLock: locked profile at ${lock.directory.path}` + ); + } catch (e) { + throw new CannotLockProfileError(`Cannot lock profile: ${e}`); + } + + try { + // We must await to ensure any logging is displayed after the callback resolves. + return await callback(lock); + } finally { + try { + lazy.log.info( + `withProfileLock: unlocking profile at ${lock.directory.path}` + ); + lock.unlock(); + lazy.log.info(`withProfileLock: unlocked profile`); + } catch (e) { + lazy.log.warn(`withProfileLock: error unlocking profile`, e); + } + } + }, + + /** + * Reads the preferences from "prefs.js" out of a profile, optionally + * returning only names satisfying a given predicate. + * + * If no `lock` is given, the default profile is locked and the preferences + * read from it. If `lock` is given, read from the given lock's directory. + * + * @param {(name: string) => boolean} [predicate] a predicate to filter + * preferences by; if not given, all preferences are accepted. + * @param {nsIProfileLock} [lock] optional lock to use + * @returns {object} with keys that are string preference names and values + * that are string|number|boolean preference values. + */ + async readPreferences(predicate = null, lock = null) { + if (!lock) { + return this.withProfileLock(profileLock => + this.readPreferences(predicate, profileLock) + ); + } + + this._throwIfNotLocked(lock); + lazy.log.info(`readPreferences: profile is locked`); + + let prefs = {}; + let addPref = (kind, name, value, sticky, locked) => { + if (predicate && !predicate(name)) { + return; + } + prefs[name] = value; + }; + + // We ignore any "user.js" file, since usage is low and doing otherwise + // requires implementing a bit more of `nsIPrefsService` than feels safe. + let prefsFile = lock.directory.clone(); + prefsFile.append("prefs.js"); + lazy.log.info(`readPreferences: will parse prefs ${prefsFile.path}`); + + let data = await IOUtils.read(prefsFile.path); + lazy.log.debug( + `readPreferences: parsing prefs from buffer of length ${data.length}` + ); + + Services.prefs.parsePrefsFromBuffer( + data, + { + onStringPref: addPref, + onIntPref: addPref, + onBoolPref: addPref, + onError(message) { + // Firefox itself manages "prefs.js", so errors should be infrequent. + lazy.log.error(message); + }, + }, + prefsFile.path + ); + + lazy.log.debug(`readPreferences: parsed prefs from buffer`, prefs); + return prefs; + }, + + /** + * Reads the snapshotted Firefox Messaging System targeting out of a profile. + * + * If no `lock` is given, the default profile is locked and the preferences + * read from it. If `lock` is given, read from the given lock's directory. + * + * @param {nsIProfileLock} [lock] optional lock to use + * @returns {object} + */ + async readFirefoxMessagingSystemTargetingSnapshot(lock = null) { + if (!lock) { + return this.withProfileLock(profileLock => + this.readFirefoxMessagingSystemTargetingSnapshot(profileLock) + ); + } + + this._throwIfNotLocked(lock); + + let snapshotFile = lock.directory.clone(); + snapshotFile.append("targeting.snapshot.json"); + + lazy.log.info( + `readFirefoxMessagingSystemTargetingSnapshot: will read Firefox Messaging ` + + `System targeting snapshot from ${snapshotFile.path}` + ); + + return IOUtils.readJSON(snapshotFile.path); + }, + + /** + * Reads the Telemetry Client ID out of a profile. + * + * If no `lock` is given, the default profile is locked and the preferences + * read from it. If `lock` is given, read from the given lock's directory. + * + * @param {nsIProfileLock} [lock] optional lock to use + * @returns {string} + */ + async readTelemetryClientID(lock = null) { + if (!lock) { + return this.withProfileLock(profileLock => + this.readTelemetryClientID(profileLock) + ); + } + + this._throwIfNotLocked(lock); + + let stateFile = lock.directory.clone(); + stateFile.append("datareporting"); + stateFile.append("state.json"); + + lazy.log.info( + `readPreferences: will read Telemetry client ID from ${stateFile.path}` + ); + + let state = await IOUtils.readJSON(stateFile.path); + + return state.clientID; + }, + + /** + * Enable the Nimbus experimentation framework. + * + * @param {nsICommandLine} commandLine if given, accept command line parameters + * like `--url about:studies?...` or + * `--url file:path/to.json` to explicitly + * opt-on to experiment branches. + * @param {object} defaultProfile snapshot of Firefox Messaging System + * targeting from default browsing profile. + */ + async enableNimbus(commandLine, defaultProfile = {}) { + try { + await lazy.ExperimentManager.onStartup({ defaultProfile }); + } catch (err) { + lazy.log.error("Failed to initialize ExperimentManager:", err); + throw err; + } + + try { + await lazy.RemoteSettingsExperimentLoader.init({ forceSync: true }); + } catch (err) { + lazy.log.error( + "Failed to initialize RemoteSettingsExperimentLoader:", + err + ); + throw err; + } + + // Allow manual explicit opt-in to experiment branches to facilitate testing. + // + // Process command line arguments, like + // `--url about:studies?optin_slug=nalexander-ms-test1&optin_branch=treatment-a&optin_collection=nimbus-preview` + // or + // `--url file:///Users/nalexander/Mozilla/gecko/experiment.json?optin_branch=treatment-a`. + let ar; + while ((ar = commandLine?.handleFlagWithParam("url", false))) { + let uri = commandLine.resolveURI(ar); + const params = new URLSearchParams(uri.query); + + if (uri.schemeIs("about") && uri.filePath == "studies") { + // Allow explicit opt-in. In the future, we might take this pref from + // the default browsing profile. + Services.prefs.setBoolPref("nimbus.debug", true); + + const data = { + slug: params.get("optin_slug"), + branch: params.get("optin_branch"), + collection: params.get("optin_collection"), + }; + await lazy.RemoteSettingsExperimentLoader.optInToExperiment(data); + lazy.log.info(`Opted in to experiment: ${JSON.stringify(data)}`); + } + + if (uri.schemeIs("file")) { + let branchSlug = params.get("optin_branch"); + let path = decodeURIComponent(uri.filePath); + let response = await fetch(uri.spec); + let recipe = await response.json(); + if (recipe.permissions) { + // Saved directly from Experimenter, there's a top-level `data`. Hand + // written, that's not the norm. + recipe = recipe.data; + } + let branch = recipe.branches.find(b => b.slug == branchSlug); + + lazy.ExperimentManager.forceEnroll(recipe, branch); + lazy.log.info(`Forced enrollment into: ${path}, branch: ${branchSlug}`); + } + } + }, + + /** + * Enable the Firefox Messaging System and, when successfully initialized, + * trigger a message with trigger id `backgroundTask`. + * + * @param {object} defaultProfile - snapshot of Firefox Messaging System + * targeting from default browsing profile. + */ + async enableFirefoxMessagingSystem(defaultProfile = {}) { + function logArgs(tag, ...args) { + lazy.log.debug(`FxMS invoked ${tag}: ${JSON.stringify(args)}`); + } + + let { messageHandler, router, createStorage } = + lazy.ASRouterDefaultConfig(); + + if (!router.initialized) { + const storage = await createStorage(); + await router.init({ + storage, + // Background tasks never send legacy telemetry. + sendTelemetry: logArgs.bind(null, "sendTelemetry"), + dispatchCFRAction: messageHandler.handleCFRAction.bind(messageHandler), + // There's no child process involved in background tasks, so swallow all + // of these messages. + clearChildMessages: logArgs.bind(null, "clearChildMessages"), + clearChildProviders: logArgs.bind(null, "clearChildProviders"), + updateAdminState: () => {}, + }); + } + + await lazy.ASRouter.waitForInitialized; + + const triggerId = "backgroundTask"; + await lazy.ASRouter.sendTriggerMessage({ + browser: null, + id: triggerId, + context: { + defaultProfile, + }, + }); + lazy.log.info( + "Triggered Firefox Messaging System with trigger id 'backgroundTask'" + ); + }, +}; diff --git a/toolkit/components/backgroundtasks/components.conf b/toolkit/components/backgroundtasks/components.conf new file mode 100644 index 0000000000..05360b1ce6 --- /dev/null +++ b/toolkit/components/backgroundtasks/components.conf @@ -0,0 +1,36 @@ +Classes = [ + { + 'cid': '{cdc33a1f-e8ae-4a4f-85d0-6ec633fe872c}', + 'contract_ids': [ + '@mozilla.org/backgroundtasks;1', + ], + 'type': 'BackgroundTasks', + 'singleton': True, + 'constructor': 'BackgroundTasks::GetSingletonAddRefed', + 'headers': ['mozilla/BackgroundTasks.h'], + 'processes': ProcessSelector.ANY_PROCESS, + }, + { + 'cid': '{4d48c536-e16f-4699-8f9c-add4f28f92f0}', + 'contract_ids': [ + '@mozilla.org/backgroundtasksmanager;1', + ], + 'esModule': 'resource://gre/modules/BackgroundTasksManager.sys.mjs', + 'constructor': 'BackgroundTasksManager', + 'categories': { + 'command-line-handler': { + 'name': 'm-backgroundtasks', + 'backgroundtasks': BackgroundTasksSelector.ALL_TASKS, + }, + }, + }, + { + 'cid': '{8cd92fce-1ec3-470a-ad09-c0de9d98497e}', + 'contract_ids': [ + '@mozilla.org/backgroundtasksrunner;1', + ], + 'type': 'BackgroundTasksRunner', + 'headers': ['mozilla/BackgroundTasksRunner.h'], + 'processes': ProcessSelector.MAIN_PROCESS_ONLY, + }, +] diff --git a/toolkit/components/backgroundtasks/dbg-actors.js b/toolkit/components/backgroundtasks/dbg-actors.js new file mode 100644 index 0000000000..2fb3b45914 --- /dev/null +++ b/toolkit/components/backgroundtasks/dbg-actors.js @@ -0,0 +1,43 @@ +/* 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/. */ + +/* globals require, exports */ + +"use strict"; + +const { DevToolsServer } = require("devtools/server/devtools-server"); +const { RootActor } = require("devtools/server/actors/root"); +const { BrowserTabList } = require("devtools/server/actors/webbrowser"); +const { ProcessActorList } = require("devtools/server/actors/process"); +const { + ActorRegistry, +} = require("devtools/server/actors/utils/actor-registry"); + +/** + * background-task specific actors. + * + */ + +/** + * Construct a root actor appropriate for use in a server running a background task. + */ +function createRootActor(connection) { + let parameters = { + tabList: new BackgroundTaskTabList(connection), + processList: new ProcessActorList(), + globalActorFactories: ActorRegistry.globalActorFactories, + onShutdown() {}, + }; + return new RootActor(connection, parameters); +} +exports.createRootActor = createRootActor; + +/** + * A "stub" TabList implementation that provides no tabs. + */ +class BackgroundTaskTabList extends BrowserTabList { + getList() { + return Promise.resolve([]); + } +} diff --git a/toolkit/components/backgroundtasks/defaults/backgroundtasks.js b/toolkit/components/backgroundtasks/defaults/backgroundtasks.js new file mode 100644 index 0000000000..4aff06f9d7 --- /dev/null +++ b/toolkit/components/backgroundtasks/defaults/backgroundtasks.js @@ -0,0 +1,47 @@ +/* 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 preferences override Gecko preferences in `greprefs.js`. Use +// `backgroundtasks_browser.js` to override browser/-specific preferences in +// `firefox.js`. + +/* global pref */ + +pref("browser.dom.window.dump.enabled", true); +pref("devtools.console.stdout.chrome", true); + +pref("browser.cache.disk.enable", false); +pref("permissions.memory_only", true); + +// For testing only: used to test that backgroundtask-specific prefs are +// processed. This just needs to be an unusual integer in the range 0..127. +pref("test.backgroundtask_specific_pref.exitCode", 79); + +// Enable the browser toolbox by default. The browser toolbox is available only +// when launching the background task with `--jsdebugger` on the command line, +// and an attacker who can launch background task processes with arbitrary +// parameters and execution environment can already access this functionality, +// so there's no need to restrict access via preferences. +pref("devtools.chrome.enabled", true); +pref("devtools.debugger.remote-enabled", true); +pref("devtools.debugger.prompt-connection", false); + +// Background tasks do not persist the cookie database: they should +// not be using cookies for network requests. +pref("network.cookie.noPersistentStorage", true); + +// Background tasks don't need to worry about perceived performance. We disable +// fast shutdown to reduce the risk of open file handles preventing cleanup of +// the ephemeral profile directory. +pref("toolkit.shutdown.fastShutdownStage", 0); + +// Avoid a race between initializing font lists and rapid shutdown, +// particularly on macOS. Compare Bug 1777332. +pref("gfx.font-list-omt.enabled", false); + +// Prevent key#.db and cert#.db from being created in the ephemeral profile. +pref("security.nocertdb", true); + +// Prevent asynchronous preference writes. +pref("preferences.allow.omt-write", false); diff --git a/toolkit/components/backgroundtasks/defaults/backgroundtasks_browser.js b/toolkit/components/backgroundtasks/defaults/backgroundtasks_browser.js new file mode 100644 index 0000000000..fbb8b34827 --- /dev/null +++ b/toolkit/components/backgroundtasks/defaults/backgroundtasks_browser.js @@ -0,0 +1,49 @@ +/* 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 preferences override override browser/-specific preferences in +// `firefox.js`. Use `backgroundtasks.js` to override general Gecko preferences +// in `greprefs.js`. + +/* global pref */ + +// XUL notifications make no sense in background tasks. This is only applies to +// Windows for now. +pref("alerts.useSystemBackend", true); +pref("alerts.useSystemBackend.windows.notificationserver.enabled", true); + +// Configure Messaging Experiments for background tasks, with +// background task-specific feature ID. The regular Firefox Desktop +// Remote Settings collection will be used. +pref( + "browser.newtabpage.activity-stream.asrouter.providers.messaging-experiments", + '{"id":"messaging-experiments","enabled":true,"type":"remote-experiments","featureIds":["backgroundTaskMessage"],"updateCycleInMs":3600000}' +); + +// Disable all other Messaging System providers save for +// `browser.newtabpage.activity-stream.asrouter.providers.message-groups`, which +// is required for the system to function. +pref("browser.newtabpage.activity-stream.asrouter.providers.cfr", "null"); +pref("browser.newtabpage.activity-stream.asrouter.providers.snippets", "null"); +pref( + "browser.newtabpage.activity-stream.asrouter.providers.whats-new-panel", + "null" +); + +// The `browser.newtabpage.activity-stream.asrouter.providers.cfr` provider is +// disabled, but belt and braces: disable extension recommendations and feature +// recommendations. Neither of these make sense in background tasks, and they +// could trigger telemetry. +pref("browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons", false); +pref( + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features", + false +); + +// Don't refresh experiments while a background task is running. +pref("app.normandy.run_interval_seconds", 0); + +// Use a separate Nimbus application ID from regular Firefox Desktop. +// This prevents enrolling in regular desktop experiments. +pref("nimbus.appId", "firefox-desktop-background-task"); diff --git a/toolkit/components/backgroundtasks/docs/index.md b/toolkit/components/backgroundtasks/docs/index.md new file mode 100644 index 0000000000..6b20bd3000 --- /dev/null +++ b/toolkit/components/backgroundtasks/docs/index.md @@ -0,0 +1,137 @@ +# Background Task Mode + +Gecko supports running privileged JavaScript in a special headless "background task" mode. Background task mode is intended to be used for periodic maintenance tasks. The first consumer will be checking for updates, even when Firefox is not running. + +Support for background task mode is gated on the build flag `MOZ_BACKGROUNDTASKS`. + +## Adding a new background task + +Background tasks are invoked with `--backgroundtask TASKNAME`. Tasks must be packaged at build time; the background task runtime looks for regular JSM modules in the following locations (in order): + +1. (App-specific) `resource:///modules/backgroundtasks/BackgroundTask_TASKNAME.sys.mjs` +2. (Toolkit/general) `resource://gre/modules//backgroundtasks/BackgroundTask_TASKNAME.sys.mjs` + +To add a new background task, add to your `moz.build` file a stanza like: + +```python +EXTRA_JS_MODULES.backgroundtasks += [ + "BackgroundTask_TASKNAME.sys.mjs", +] +``` + +## Implementing a background task + +In `BackgroundTask_TASKNAME.sys.mjs`, define a function `runBackgroundTask` that returns a `Promise`. `runBackgroundTask` will be awaited and the integer value it resolves to will be used as the exit code of the `--backgroundtask TASKNAME` invocation. Optionally, `runBackgroundTask` can take an [`nsICommandLine` instance](https://searchfox.org/mozilla-central/source/toolkit/components/commandlines/nsICommandLine.idl) as a parameter. For example: + +```javascript +export async function runBackgroundTask(commandLine) { + return Number.parseInt(commandLine.getArgument(0), 10); +} +``` + +When invoked like `--backgroundtask TASKNAME EXITCODE`, this task will simply complete with the exit code given on the command line. + +Task module can optionally export an integer value called `backgroundTaskTimeoutSec`, which will control how long the task can run before it times out. If no value is specified, the timeout value stored in the pref `toolkit.backgroundtasks.defaultTimeoutSec` will be used. + +## Special exit codes + +The exit codes 2-4 have special meaning: + +* Exit code 2 (`EXIT_CODE.NOT_FOUND`) means the background task with the given `TASKNAME` was not found or could not be loaded. +* Exit code 3 (`EXIT_CODE.EXCEPTION`) means the background task invocation rejected with an exception. +* Exit code 4 (`EXIT_CODE.TIMEOUT`) means that the background task timed out before it could complete. + +See [`EXIT_CODE`](https://searchfox.org/mozilla-central/source/toolkit/components/backgroundtasks/BackgroundTasksManager.sys.mjs) for details. + +## Test-only background tasks + +There is special support for test-only background tasks. Add to your `moz.build` file a stanza like: + +```python +TESTING_JS_MODULES.backgroundtasks += [ + "BackgroundTask_TESTONLYTASKNAME.sys.mjs", +] +``` + +For more details, see [`XPCSHELL_TESTING_MODULES_URI`](https://searchfox.org/mozilla-central/search?q=XPCSHELL_TESTING_MODULES_URI). + +## Debugging background tasks + +Background task mode supports using the JavaScript debugger and the Firefox Devtools and Browser Toolbox. When invoked with the command line parameters `--jsdebugger` (and optionally `--wait-for-jsdebugger`), the background task framework will launch a Browser Toolbox, connect to the background task, and pause execution at the first line of the task implementation. The Browser Toolbox is launched with a temporary profile (sibling to the ephemeral temporary profile the background task itself creates.) The Browser Toolbox profile's preferences are copied from the default browsing profile, allowing to configure devtools preferences. (The `--start-debugger-server` command line option is also recognized; see the output of `firefox --backgroundtask success --attach-console --help` for details.) + +## Invoking background tasks + +Use `BackgroundTasksRunner::RunInDetachedProcess` is a helper to open a new background process within Gecko. It automatically manages the configuration 1) to let the new process outlive the launching process and 2) to escape the arguments properly. The function is safe to be called in a non-main process. + +## Existing background tasks + +* `BackgroundTask_removeDirectory` + + Removes the child directory with the given name and/or child directories with the given postfix, all in the given parent directory. It's recommended to run it via the corresponding helper function `BackgroundTasksRunner::RemoveDirectoryInDetachedProcess`. + + Tests can use `toolkit.background_tasks.remove_directory.testing.sleep_ms` to see whether a longstanding task can finish the work even after the launching process is closed. + +## The background task mode runtime environment + +### Most background tasks run in ephemeral temporary profiles + +Background tasks are intended for periodic maintenance tasks, especially global/per-installation maintenance tasks. To allow background tasks to run at the same time as regular, headed Firefox browsing sessions, by default they run with an ephemeral temporary profile. This ephemeral profile is deleted when the background task main process exits. Every background task applies the preferences in [`backgroundtasks/defaults/backgroundtasks.js`](https://searchfox.org/mozilla-central/source/toolkit/components/backgroundtasks/defaults/backgroundtasks.js), but any additional preference configuration must be handled by the individual task. Over time, we anticipate a small library of background task functionality to grow to make it easier to lock and read specific prefs from the default browsing profile, to declare per-installation prefs, etc. + +It is possible to run background tasks in non-emphemeral, i.e., persistent, profiles. See [Bug 1775132](https://bugzilla.mozilla.org/show_bug.cgi?id=1775132) for details. + +### Background tasks limit the XPCOM instance graph by default + +The technical mechanism that keeps background tasks "lightweight" is very simple. XPCOM declares a number of observer notifications for loosely coupling components via the observer service. Some of those observer notifications are declared as category notifications which allow consumers to register themselves in static components.conf registration files (or in now deprecated chrome.manifest files). In background task mode, category notifications are not registered by default. + +For Firefox in particular, this means that [`BrowserContentHandler.sys.mjs`](https://searchfox.org/mozilla-central/source/browser/components/BrowserContentHandler.sys.mjs) is not registered as a command-line-handler. This means that [`BrowserGlue.sys.mjs`](https://searchfox.org/mozilla-central/source/browser/components/BrowserGlue.sys.mjs) is not loaded, and this short circuits regular headed browsing startup. + +See the [documentation for defining static components](https://firefox-source-docs.mozilla.org/build/buildsystem/defining-xpcom-components.html) for how to change this default behaviour, and [Bug 1675848](https://bugzilla.mozilla.org/show_bug.cgi?id=1675848) for details of the implementation. + +### Most background tasks do not process updates + +To prevent unexpected interactions between background tasks and the Firefox runtime lifecycle, such as those uncovered by [Bug 1736373](https://bugzilla.mozilla.org/show_bug.cgi?id=1736373), most background tasks do not process application updates. The startup process decides whether to process updates in [`::ShouldProcessUpdates`](https://searchfox.org/mozilla-central/source/toolkit/xre/nsAppRunner.cpp) and the predicate that determines whether a particular background task *does* process updates is [`BackgroundTasks::IsUpdatingTaskName`](https://searchfox.org/mozilla-central/source/toolkit/components/backgroundtasks/BackgroundTasks.h). + +Background tasks that are launched at shutdown (and that are not updating) do not prevent Firefox from updating. However, this can result in Firefox updating out from underneath a running background task: see [this summary of the issue](https://bugzilla.mozilla.org/show_bug.cgi?id=1480452#c8). Generally, background tasks should be minimal and short-lived and are unlikely to launch additional child subprocesses after startup, so they should not witness this issue, but it is still possible. See the diagram below visualizing process lifetimes. + +```{mermaid} + gantt + title Background tasks launched at Firefox shutdown + dateFormat YYYY-MM-DD + axisFormat + section Firefox + firefox (version N) :2014-01-03, 3d + updater :2014-01-06, 1d + firefox (version N+1) :2014-01-07, 3d + firefox --backgroundtask ... (version N) :2014-01-05, 3d +``` + +### Most background tasks produce console output + +Background tasks are usually scheduled in situations where their output is not user-visible: by the Windows Task Scheduler, at shutdown, etc. Therefore, it's usually safe to always produce console output. But some tasks, especially shutdown tasks executed during developer builds, can "pollute" the console even after the Firefox main process has exited. To avoid this, background tasks can opt-in to producing no output; the predicate that determines whether a particular background task *does* produce output is [`BackgroundTasks::IsNoOutputTaskName`](https://searchfox.org/mozilla-central/source/toolkit/components/backgroundtasks/BackgroundTasks.h). This predicate can be overridden by providing the `--attach-console` command line flag or by setting the `MOZ_BACKGROUNDTASKS_IGNORE_NO_OUTPUT` environment variable to a non-empty value. + +The `pingsender` background task opts to produce no output: see [Bug 1736623](https://bugzilla.mozilla.org/show_bug.cgi?id=1736623). + +## More details + +### Maximum and minimum runtime + +Background tasks are meant to finish their work in a (very) short time. There are two preferences that control these values globally: + +#### `toolkit.backgroundtasks.defaultTimeoutSec` (default 10 minutes) + +Defines the maximum runtime. Once this timeout is exceeded, the shutdown sequence will be started, regardless of the still running JS payload's state. It is thus recommended, that any potentially long running JS payload adds async shutdown blockers that makes it bail out or checks [isInOrBeyondShutdownPhase](https://searchfox.org/mozilla-central/rev/f60cf6bfa8bd096efd9bb3a445364f5a0f32897a/toolkit/components/startup/public/nsIAppStartup.idl#193) in appropriate places, otherwise shutdown hangs might occur. + +#### `toolkit.backgroundtasks.defaultMinTaskRuntimeMS` (default 500 ms) + +Sets the minimum runtime that even an empty background task will wait before starting its shutdown sequence. The main purpose is to give any asynchronously running startup launch of components sufficient time to complete their startup before we ask them to shutdown. Note that this is wallclock counting from the start of the payload, such that if the background task takes longer for itself, no additional time will be added to the execution. If the background task itself may cause further asynchronous component launches or other processing, it should take care of waiting on them to finish before returning. Note that there is [an additional 100 ms delay before we actually launch the task](https://searchfox.org/mozilla-central/rev/11a4d97a7b5cdfa133f4bda4525649f651703018/toolkit/components/backgroundtasks/BackgroundTasks.cpp#203,227) plus the process startup and shutdown overhead, such that even an empty task will take ~800 ms minimum (on a modern Windows laptop) to finish with `toolkit.backgroundtasks.defaultMinTaskRuntimeMS == 500`. + +#### Overriding the global preference values + +Additionally you can control these values on a per-task granularity by having: + +``` +export const backgroundTaskTimeoutSec = <some seconds>; +export const backgroundTaskMinRuntimeMS = <some millisecs>; +``` + +at [the top of your task definition like here](https://searchfox.org/mozilla-central/rev/f60cf6bfa8bd096efd9bb3a445364f5a0f32897a/toolkit/mozapps/update/BackgroundTask_backgroundupdate.sys.mjs#47-50). diff --git a/toolkit/components/backgroundtasks/metrics.yaml b/toolkit/components/backgroundtasks/metrics.yaml new file mode 100644 index 0000000000..55c997db41 --- /dev/null +++ b/toolkit/components/backgroundtasks/metrics.yaml @@ -0,0 +1,75 @@ +# 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/. + +# Adding a new metric? We have docs for that! +# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html + +--- +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 +$tags: + - 'Toolkit :: Background Tasks' + +# Use the `rmdir` YAML reference when you want to get your own metrics id for +# BackgroundTask_removeDirectory. +# Do not use this base metrics directly. +background_tasks.rmdir.base: &rmdir + metric_base: &metric_base + expires: never + send_in_pings: + - background-tasks + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1788986 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1788986 + notification_emails: + - krosylight@mozilla.com + - vgosu@mozilla.com + # Override below. These exist solely to workaround validation issues. + type: event + description: The base metric template for removeDirectory. + elapsed_ms: + <<: *metric_base + type: quantity + unit: milliseconds + description: > + The amount of time the task took for removing the directories. + was_first: + <<: *metric_base + type: boolean + description: > + Whether this task is the first one removing the directory. + retry_count: + <<: *metric_base + type: quantity + unit: files + description: > + The number of retries before the task started removing the child + directory. This can happen when the target directory doesn't exist. + removal_count: + <<: *metric_base + type: quantity + unit: files + description: > + The number of the removed entries at each call, even if the target + directory itself couldn't be removed. + succeeded: + <<: *metric_base + type: boolean + description: Whether the target directory removal succeeded. + suffix_removal_count: + <<: *metric_base + type: quantity + unit: files + description: > + The number of the removed entries of the suffixed directories. + suffix_ever_failed: + <<: *metric_base + type: boolean + description: Whether removing the suffixed directories ever failed. + +# Metrics identifiers for each use +background_tasks.rmdir.quota: + <<: *rmdir +background_tasks.rmdir.http_cache: + <<: *rmdir diff --git a/toolkit/components/backgroundtasks/moz.build b/toolkit/components/backgroundtasks/moz.build new file mode 100644 index 0000000000..8a532d7e99 --- /dev/null +++ b/toolkit/components/backgroundtasks/moz.build @@ -0,0 +1,102 @@ +# 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/. + +with Files("**"): + BUG_COMPONENT = ("Toolkit", "Background Tasks") + +SPHINX_TREES["/toolkit/components/backgroundtasks"] = "docs" + +with Files("docs/**"): + SCHEDULES.exclusive = ["docs"] + +FINAL_LIBRARY = "xul" + +for var in ("MOZ_APP_VENDOR",): + DEFINES[var] = '"%s"' % CONFIG[var] + +UNIFIED_SOURCES += [ + "BackgroundTasks.cpp", + "BackgroundTasksRunner.cpp", +] + +EXPORTS.mozilla += [ + "BackgroundTasks.h", + "BackgroundTasksRunner.h", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +XPIDL_SOURCES += [ + "nsIBackgroundTasks.idl", + "nsIBackgroundTasksManager.idl", + "nsIBackgroundTasksRunner.idl", +] + +XPIDL_MODULE = "toolkit_backgroundtasks" + +EXTRA_JS_MODULES += [ + "BackgroundTasksManager.sys.mjs", + "BackgroundTasksUtils.sys.mjs", +] + +EXTRA_JS_MODULES.backgroundtasks += [ + "dbg-actors.js", +] + +EXTRA_JS_MODULES.backgroundtasks += [ + "BackgroundTask_exception.sys.mjs", + "BackgroundTask_failure.sys.mjs", + "BackgroundTask_message.sys.mjs", + "BackgroundTask_removeDirectory.sys.mjs", + "BackgroundTask_success.sys.mjs", +] + +LOCAL_INCLUDES += [ + "../../profile", +] + +BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.ini"] +XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.ini"] + +TESTING_JS_MODULES += [ + "BackgroundTasksTestUtils.sys.mjs", +] + +TESTING_JS_MODULES.backgroundtasks += [ + "tests/BackgroundTask_backgroundtask_specific_pref.sys.mjs", + "tests/BackgroundTask_crash.sys.mjs", + "tests/BackgroundTask_file_exists.sys.mjs", + "tests/BackgroundTask_jsdebugger.sys.mjs", + "tests/BackgroundTask_localization.sys.mjs", + "tests/BackgroundTask_minruntime.sys.mjs", + "tests/BackgroundTask_no_output.sys.mjs", + "tests/BackgroundTask_not_ephemeral_profile.sys.mjs", + "tests/BackgroundTask_policies.sys.mjs", + "tests/BackgroundTask_profile_is_slim.sys.mjs", + "tests/BackgroundTask_shouldnotprocessupdates.sys.mjs", + "tests/BackgroundTask_shouldprocessupdates.sys.mjs", + "tests/BackgroundTask_timeout.sys.mjs", + "tests/BackgroundTask_unique_profile.sys.mjs", + "tests/BackgroundTask_update_sync_manager.sys.mjs", + "tests/BackgroundTask_wait.sys.mjs", +] + +if CONFIG["MOZ_BUILD_APP"] == "browser": + # ASRouter is Firefox-only. + TESTING_JS_MODULES.backgroundtasks += [ + "tests/BackgroundTask_targeting.sys.mjs", + ] + + FINAL_TARGET_FILES.browser.defaults.backgroundtasks += [ + "defaults/backgroundtasks_browser.js", + ] + +FINAL_TARGET_FILES.defaults.backgroundtasks += [ + "defaults/backgroundtasks.js", +] + +# For base::LaunchApp +include("/ipc/chromium/chromium-config.mozbuild") diff --git a/toolkit/components/backgroundtasks/nsIBackgroundTasks.idl b/toolkit/components/backgroundtasks/nsIBackgroundTasks.idl new file mode 100644 index 0000000000..ba02f80097 --- /dev/null +++ b/toolkit/components/backgroundtasks/nsIBackgroundTasks.idl @@ -0,0 +1,32 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* 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/. */ + +#include "nsISupports.idl" + +/** + * Determine if this instance is running background task mode and + * what, if any, task is active. + */ +[scriptable, uuid(353dccb8-a863-49e4-941b-007382eac168)] +interface nsIBackgroundTasks : nsISupports +{ + /** + * True if and only if this invocation is running in background task mode. + */ + readonly attribute boolean isBackgroundTaskMode; + + /** + * A non-empty task name if this invocation is running in background + * task mode, or `null` if this invocation is not running in + * background task mode. + */ + AString backgroundTaskName(); + + /** + * Should only be used for testing. + * Set the background task name. + */ + void overrideBackgroundTaskNameForTesting(in AString taskName); +}; diff --git a/toolkit/components/backgroundtasks/nsIBackgroundTasksManager.idl b/toolkit/components/backgroundtasks/nsIBackgroundTasksManager.idl new file mode 100644 index 0000000000..1bb1535e45 --- /dev/null +++ b/toolkit/components/backgroundtasks/nsIBackgroundTasksManager.idl @@ -0,0 +1,28 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* 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/. */ + +#include "nsICommandLineHandler.idl" + +interface nsICommandLine; + +/** + * Import and run named backgroundtask implementations. + */ +[scriptable, uuid(4d48c536-e16f-4699-8f9c-add4f28f92f0)] +interface nsIBackgroundTasksManager : nsICommandLineHandler +{ + /** + * Run the named background task. + * + * @param aTaskName the name of the task to be run. + * @param aCommandLine the command line of this invocation. + * + * This returns a promise which resolves to an integer exit code, 0 when the + * task succeeded, >0 otherwise. The task manager will quit after this + * promise resolves. + */ + void runBackgroundTaskNamed(in AString aTaskName, + in nsICommandLine aCommandLine); +}; diff --git a/toolkit/components/backgroundtasks/nsIBackgroundTasksRunner.idl b/toolkit/components/backgroundtasks/nsIBackgroundTasksRunner.idl new file mode 100644 index 0000000000..ebc13494f1 --- /dev/null +++ b/toolkit/components/backgroundtasks/nsIBackgroundTasksRunner.idl @@ -0,0 +1,40 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* 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/. */ + +#include "nsISupports.idl" + +[scriptable, uuid(8cd92fce-1ec3-470a-ad09-c0de9d98497e)] +interface nsIBackgroundTasksRunner : nsISupports +{ + /** + * Runs a background process in an independent detached process. Any process + * opened by this function can outlive the main process. + * + * This function is thread-safe. + * + * XXX: The use of base::LaunchApp can make a zombie process on Unix. + * See bug 1802559. + * + * @param aTaskName The name of the background task. + * (BackgroundTask_{name}.sys.mjs) + * @param aArgs The arguments that will be passed to the task process. Any + * needed escape will happen automatically. + */ + void runInDetachedProcess(in ACString aTaskName, + in Array<ACString> aCommandLine); + + /** + * Runs removeDirectory background task. + * `toolkit.background_tasks.remove_directory.testing.sleep_ms` can be set to + * make it wait for the given milliseconds for testing purpose. + * + * See BackgroundTask_removeDirectory.sys.mjs for details about the arguments. + */ + void removeDirectoryInDetachedProcess(in ACString aParentDirPath, + in ACString aChildDirName, + in ACString aSecondsToWait, + in ACString aOtherFoldersSuffix, + [optional] in ACString aMetricsId); +}; diff --git a/toolkit/components/backgroundtasks/pings.yaml b/toolkit/components/backgroundtasks/pings.yaml new file mode 100644 index 0000000000..8e12c40cfd --- /dev/null +++ b/toolkit/components/backgroundtasks/pings.yaml @@ -0,0 +1,20 @@ +# 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/. + +--- +$schema: moz://mozilla.org/schemas/glean/pings/2-0-0 + +background-tasks: + description: | + This ping is generic for background tasks. Each background task can + gather its metrics under this ping and submit it when the task finishes. + Note that the ping submission must be done manually. + include_client_id: false + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1788986 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1788986 + notification_emails: + - krosylight@mozilla.com + - vgosu@mozilla.com diff --git a/toolkit/components/backgroundtasks/tests/BackgroundTask_backgroundtask_specific_pref.sys.mjs b/toolkit/components/backgroundtasks/tests/BackgroundTask_backgroundtask_specific_pref.sys.mjs new file mode 100644 index 0000000000..64c347a37f --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/BackgroundTask_backgroundtask_specific_pref.sys.mjs @@ -0,0 +1,31 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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/. */ + +export async function runBackgroundTask(commandLine) { + let pref = commandLine.length + ? commandLine.getArgument(0) + : "test.backgroundtask_specific_pref.exitCode"; + + // 0, 1, 2, 3 are all meaningful exit codes already. + let exitCode = Services.prefs.getIntPref(pref, 4); + + console.error( + `runBackgroundTask: backgroundtask_specific_pref read pref '${pref}' with value ${exitCode}` + ); + + if (commandLine.length > 1) { + let newValue = Number.parseInt(commandLine.getArgument(1), 10); + console.error( + `runBackgroundTask: backgroundtask_specific_pref wrote pref '${pref}' with value ${newValue}` + ); + Services.prefs.setIntPref(pref, newValue); + } + + console.error( + `runBackgroundTask: backgroundtask_specific_pref exiting with exitCode ${exitCode}` + ); + + return exitCode; +} diff --git a/toolkit/components/backgroundtasks/tests/BackgroundTask_crash.sys.mjs b/toolkit/components/backgroundtasks/tests/BackgroundTask_crash.sys.mjs new file mode 100644 index 0000000000..10764bc1f7 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/BackgroundTask_crash.sys.mjs @@ -0,0 +1,51 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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/. */ + +export async function runBackgroundTask(commandLine) { + // This task depends on `CrashTestUtils.jsm` and requires the + // sibling `testcrasher` library to be in the current working + // directory. Fail right away if we can't find the module or the + // native library. + let cwd = Services.dirsvc.get("CurWorkD", Ci.nsIFile); + let protocolHandler = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + var curDirURI = Services.io.newFileURI(cwd); + protocolHandler.setSubstitution("test", curDirURI); + + const { CrashTestUtils } = ChromeUtils.importESModule( + "resource://test/CrashTestUtils.sys.mjs" + ); + + // Get the temp dir. + var tmpd = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + tmpd.initWithPath(Services.env.get("XPCSHELL_TEST_TEMP_DIR")); + + // We need to call this or crash events go in an undefined location. + Services.appinfo.UpdateCrashEventsDir(); + + // Setting the minidump path is not allowed in content processes, + // but background tasks run in the parent. + Services.appinfo.minidumpPath = tmpd; + + // Arguments are [crashType, key1, value1, key2, value2, ...]. + let i = 0; + let crashType = Number.parseInt(commandLine.getArgument(i)); + i += 1; + while (i + 1 < commandLine.length) { + let key = commandLine.getArgument(i); + let value = commandLine.getArgument(i + 1); + i += 2; + Services.appinfo.annotateCrashReport(key, value); + } + + console.log(`Crashing with crash type ${crashType}`); + + // Now actually crash. + CrashTestUtils.crash(crashType); + + // This is moot, since we crashed, but... + return 1; +} diff --git a/toolkit/components/backgroundtasks/tests/BackgroundTask_file_exists.sys.mjs b/toolkit/components/backgroundtasks/tests/BackgroundTask_file_exists.sys.mjs new file mode 100644 index 0000000000..6cfcb75291 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/BackgroundTask_file_exists.sys.mjs @@ -0,0 +1,30 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { EXIT_CODE } from "resource://gre/modules/BackgroundTasksManager.sys.mjs"; + +/** + * Return 0 (success) if the given absolute file path exists, 11 + * (failure) otherwise. + */ +export function runBackgroundTask(commandLine) { + let path = commandLine.getArgument(0); + let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.initWithPath(path); + + let exitCode; + let exists = file.exists(); + if (exists) { + exitCode = EXIT_CODE.SUCCESS; + } else { + exitCode = 11; + } + + console.error( + `runBackgroundTask: '${path}' exists: ${exists}; ` + + `exiting with status ${exitCode}` + ); + return exitCode; +} diff --git a/toolkit/components/backgroundtasks/tests/BackgroundTask_jsdebugger.sys.mjs b/toolkit/components/backgroundtasks/tests/BackgroundTask_jsdebugger.sys.mjs new file mode 100644 index 0000000000..45cf00a449 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/BackgroundTask_jsdebugger.sys.mjs @@ -0,0 +1,23 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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/. */ + +/** + * This task is intended to be interrupted by the JS debugger in tests. + * + * This task exposes its `exitCode` so that in the future the JS + * debugger can change its exit code dynamically from a failing exit + * code to exit code 0. + */ + +export function runBackgroundTask(commandLine) { + // In the future, will be modifed by the JS debugger (to 0, success). + var exposedExitCode = 0; + + console.error( + `runBackgroundTask: will exit with exitCode: ${exposedExitCode}` + ); + + return exposedExitCode; +} diff --git a/toolkit/components/backgroundtasks/tests/BackgroundTask_localization.sys.mjs b/toolkit/components/backgroundtasks/tests/BackgroundTask_localization.sys.mjs new file mode 100644 index 0000000000..48a67cc4de --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/BackgroundTask_localization.sys.mjs @@ -0,0 +1,29 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { EXIT_CODE } from "resource://gre/modules/BackgroundTasksManager.sys.mjs"; + +/** + * Return 0 (success) if in the given resource file, the given string + * identifier has the given string value, 11 (failure) otherwise. + */ +export async function runBackgroundTask(commandLine) { + let resource = commandLine.getArgument(0); + let id = commandLine.getArgument(1); + let expected = commandLine.getArgument(2); + + let l10n = new Localization([resource]); + let value = await l10n.formatValue(id); + + let exitCode = value == expected ? EXIT_CODE.SUCCESS : 11; + + console.error( + `runBackgroundTask: in resource '${resource}': for id '${id}', ` + + `expected is '${expected}' and value is '${value}'; ` + + `exiting with status ${exitCode}` + ); + + return exitCode; +} diff --git a/toolkit/components/backgroundtasks/tests/BackgroundTask_minruntime.sys.mjs b/toolkit/components/backgroundtasks/tests/BackgroundTask_minruntime.sys.mjs new file mode 100644 index 0000000000..2b6a98cc8f --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/BackgroundTask_minruntime.sys.mjs @@ -0,0 +1,13 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { EXIT_CODE } from "resource://gre/modules/BackgroundTasksManager.sys.mjs"; + +// Increase the minimum runtime before shutdown +export const backgroundTaskMinRuntimeMS = 2000; + +export async function runBackgroundTask() { + return EXIT_CODE.SUCCESS; +} diff --git a/toolkit/components/backgroundtasks/tests/BackgroundTask_no_output.sys.mjs b/toolkit/components/backgroundtasks/tests/BackgroundTask_no_output.sys.mjs new file mode 100644 index 0000000000..ce04bd1bdf --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/BackgroundTask_no_output.sys.mjs @@ -0,0 +1,13 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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/. */ + +export async function runBackgroundTask(commandLine) { + // Exact same behaviour as `unique_profile`, but with a task name + // that is recognized as a task that should produce no output. + const taskModule = ChromeUtils.import( + "resource://testing-common/backgroundtasks/BackgroundTask_unique_profile.jsm" + ); + return taskModule.runBackgroundTask(commandLine); +} diff --git a/toolkit/components/backgroundtasks/tests/BackgroundTask_not_ephemeral_profile.sys.mjs b/toolkit/components/backgroundtasks/tests/BackgroundTask_not_ephemeral_profile.sys.mjs new file mode 100644 index 0000000000..e8a6d882af --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/BackgroundTask_not_ephemeral_profile.sys.mjs @@ -0,0 +1,14 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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/. */ + +export async function runBackgroundTask(commandLine) { + // Exact same behaviour as `backgroundtask_specific_pref`, but with + // a task name that is recognized as a task that should not use an + // ephemeral profile. + const taskModule = ChromeUtils.import( + "resource://testing-common/backgroundtasks/BackgroundTask_backgroundtask_specific_pref.jsm" + ); + return taskModule.runBackgroundTask(commandLine); +} diff --git a/toolkit/components/backgroundtasks/tests/BackgroundTask_policies.sys.mjs b/toolkit/components/backgroundtasks/tests/BackgroundTask_policies.sys.mjs new file mode 100644 index 0000000000..90bb71db8e --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/BackgroundTask_policies.sys.mjs @@ -0,0 +1,26 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { EnterprisePolicyTesting } from "resource://testing-common/EnterprisePolicyTesting.sys.mjs"; + +export async function runBackgroundTask(commandLine) { + let filePath = commandLine.getArgument(0); + await EnterprisePolicyTesting.setupPolicyEngineWithJson(filePath); + + let checker = Cc["@mozilla.org/updates/update-checker;1"].getService( + Ci.nsIUpdateChecker + ); + let actual = await checker.getUpdateURL(checker.BACKGROUND_CHECK); + let expected = commandLine.getArgument(1); + + // 0, 1, 2, 3 are all meaningful exit codes already. + let exitCode = expected == actual ? 0 : 4; + console.error( + `runBackgroundTask: policies read AppUpdateURL '${actual}', + expected '${expected}', exiting with exitCode ${exitCode}` + ); + + return exitCode; +} diff --git a/toolkit/components/backgroundtasks/tests/BackgroundTask_profile_is_slim.sys.mjs b/toolkit/components/backgroundtasks/tests/BackgroundTask_profile_is_slim.sys.mjs new file mode 100644 index 0000000000..e95a6f07e2 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/BackgroundTask_profile_is_slim.sys.mjs @@ -0,0 +1,25 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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/. */ + +// A task that exercises various functionality to witness contents of +// the temporary profile created during background tasks. This will +// be a dumping ground for functionality that writes to the profile. + +import { EXIT_CODE } from "resource://gre/modules/BackgroundTasksManager.sys.mjs"; + +export async function runBackgroundTask(commandLine) { + console.error("runBackgroundTask: is_profile_slim"); + + // For now, just verify contents of profile after a network request. + if (commandLine.length != 1) { + console.error("Single URL argument required"); + return 1; + } + + let response = await fetch(commandLine.getArgument(0)); + console.error(`response status code: ${response.status}`); + + return response.ok ? EXIT_CODE.SUCCESS : 11; +} diff --git a/toolkit/components/backgroundtasks/tests/BackgroundTask_shouldnotprocessupdates.sys.mjs b/toolkit/components/backgroundtasks/tests/BackgroundTask_shouldnotprocessupdates.sys.mjs new file mode 100644 index 0000000000..e869cd0d9d --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/BackgroundTask_shouldnotprocessupdates.sys.mjs @@ -0,0 +1,13 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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/. */ + +export async function runBackgroundTask(commandLine) { + // Exact same behaviour as `shouldprocessupdates`, but with a task name that + // is not recognized as a task that should process updates. + const taskModule = ChromeUtils.importESModule( + "resource://testing-common/backgroundtasks/BackgroundTask_shouldprocessupdates.sys.mjs" + ); + return taskModule.runBackgroundTask(commandLine); +} diff --git a/toolkit/components/backgroundtasks/tests/BackgroundTask_shouldprocessupdates.sys.mjs b/toolkit/components/backgroundtasks/tests/BackgroundTask_shouldprocessupdates.sys.mjs new file mode 100644 index 0000000000..4030f42e5d --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/BackgroundTask_shouldprocessupdates.sys.mjs @@ -0,0 +1,27 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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/. */ + +export async function runBackgroundTask(commandLine) { + const get = Services.env.get("MOZ_TEST_PROCESS_UPDATES"); + let exitCode = 81; + if (get == "ShouldNotProcessUpdates(): OtherInstanceRunning") { + exitCode = 80; + } + if (get == "ShouldNotProcessUpdates(): DevToolsLaunching") { + exitCode = 79; + } + if (get == "ShouldNotProcessUpdates(): NotAnUpdatingTask") { + exitCode = 78; + } + console.debug(`runBackgroundTask: shouldprocessupdates`, { + exists: Services.env.exists("MOZ_TEST_PROCESS_UPDATES"), + get, + }); + console.error( + `runBackgroundTask: shouldprocessupdates exiting with exitCode ${exitCode}` + ); + + return exitCode; +} diff --git a/toolkit/components/backgroundtasks/tests/BackgroundTask_targeting.sys.mjs b/toolkit/components/backgroundtasks/tests/BackgroundTask_targeting.sys.mjs new file mode 100644 index 0000000000..a879265b44 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/BackgroundTask_targeting.sys.mjs @@ -0,0 +1,51 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { EXIT_CODE } from "resource://gre/modules/BackgroundTasksManager.sys.mjs"; + +const { ASRouterTargeting } = ChromeUtils.import( + "resource://activity-stream/lib/ASRouterTargeting.jsm" +); + +// Background tasks are "live" with a temporary profile that doesn't map common +// network preferences to https://mochi.test in the way that regular testing +// profiles do. Therefore, certain targeting getters will fail due to non-local +// network connections. Exclude these. +const EXCLUDED_NAMES = [ + "region", // Queries Mozilla Location Services. + "needsUpdate", // Queries Balrog update server. +]; + +/** + * Return 0 (success) if all targeting getters succeed, 11 (failure) + * otherwise. + */ +export async function runBackgroundTask(commandLine) { + let exitCode = EXIT_CODE.SUCCESS; + + // Can't use `ASRouterTargeting.getEnvironmentSnapshot`, since that + // ignores errors, and this is testing that every getter succeeds. + let target = ASRouterTargeting.Environment; + let environment = {}; + for (let name of Object.keys(target)) { + if (EXCLUDED_NAMES.includes(name)) { + continue; + } + + try { + console.debug(`Fetching property ${name}`); + environment[name] = await target[name]; + } catch (e) { + exitCode = 11; + console.error(`Caught exception for property ${name}:`, e); + } + } + + console.log(`ASRouterTargeting.Environment:`, environment); + + console.error(`runBackgroundTask: exiting with status ${exitCode}`); + + return exitCode; +} diff --git a/toolkit/components/backgroundtasks/tests/BackgroundTask_timeout.sys.mjs b/toolkit/components/backgroundtasks/tests/BackgroundTask_timeout.sys.mjs new file mode 100644 index 0000000000..357019486b --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/BackgroundTask_timeout.sys.mjs @@ -0,0 +1,19 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { EXIT_CODE } from "resource://gre/modules/BackgroundTasksManager.sys.mjs"; +import { setTimeout } from "resource://gre/modules/Timer.sys.mjs"; + +// Time out in just a single second. The task is set up to run for 5 minutes, +// so it should always time out. +export const backgroundTaskTimeoutSec = 1; + +export async function runBackgroundTask() { + await new Promise(resolve => { + const fiveMinutesInMs = 5 * 60 * 1000; + setTimeout(resolve, fiveMinutesInMs); + }); + return EXIT_CODE.SUCCESS; +} diff --git a/toolkit/components/backgroundtasks/tests/BackgroundTask_unique_profile.sys.mjs b/toolkit/components/backgroundtasks/tests/BackgroundTask_unique_profile.sys.mjs new file mode 100644 index 0000000000..741046292f --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/BackgroundTask_unique_profile.sys.mjs @@ -0,0 +1,65 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Subprocess } from "resource://gre/modules/Subprocess.sys.mjs"; + +export async function runBackgroundTask(commandLine) { + let sentinel = commandLine.getArgument(0); + let count = + commandLine.length > 1 + ? Number.parseInt(commandLine.getArgument(1), 10) + : 1; + + let main = await ChromeUtils.requestProcInfo(); + let info = [main.pid, Services.dirsvc.get("ProfD", Ci.nsIFile).path]; + + // `dump` prints to the console without formatting. + dump(`${count}: ${sentinel}${JSON.stringify(info)}${sentinel}\n`); + + // Maybe launch a child. + if (count <= 1) { + return 0; + } + + let command = Services.dirsvc.get("XREExeF", Ci.nsIFile).path; + let args = [ + "--backgroundtask", + "unique_profile", + sentinel, + (count - 1).toString(), + ]; + + // We must assemble all of the string fragments from stdout. + let stdoutChunks = []; + let proc = await Subprocess.call({ + command, + arguments: args, + stderr: "stdout", + // Don't inherit this task's profile path. + environmentAppend: true, + environment: { XRE_PROFILE_PATH: null }, + }).then(p => { + p.stdin.close(); + const dumpPipe = async pipe => { + let data = await pipe.readString(); + while (data) { + data = await pipe.readString(); + stdoutChunks.push(data); + } + }; + dumpPipe(p.stdout); + + return p; + }); + + let { exitCode } = await proc.wait(); + + let stdout = stdoutChunks.join(""); + for (let line of stdout.split(/\r\n|\r|\n/).slice(0, -1)) { + dump(`${count}> ${line}\n`); + } + + return exitCode; +} diff --git a/toolkit/components/backgroundtasks/tests/BackgroundTask_update_sync_manager.sys.mjs b/toolkit/components/backgroundtasks/tests/BackgroundTask_update_sync_manager.sys.mjs new file mode 100644 index 0000000000..dfdbd817e9 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/BackgroundTask_update_sync_manager.sys.mjs @@ -0,0 +1,24 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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/. */ + +export async function runBackgroundTask(commandLine) { + let syncManager = Cc["@mozilla.org/updates/update-sync-manager;1"].getService( + Ci.nsIUpdateSyncManager + ); + + if (commandLine.length) { + let appPath = commandLine.getArgument(0); + let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.initWithPath(appPath); + syncManager.resetLock(file); + } + + let exitCode = syncManager.isOtherInstanceRunning() ? 80 : 81; + console.error( + `runBackgroundTask: update_sync_manager exiting with exitCode ${exitCode}` + ); + + return exitCode; +} diff --git a/toolkit/components/backgroundtasks/tests/BackgroundTask_wait.sys.mjs b/toolkit/components/backgroundtasks/tests/BackgroundTask_wait.sys.mjs new file mode 100644 index 0000000000..ae940faaa2 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/BackgroundTask_wait.sys.mjs @@ -0,0 +1,17 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { setTimeout } from "resource://gre/modules/Timer.sys.mjs"; + +export function runBackgroundTask(commandLine) { + let delay = 10; + if (commandLine.length) { + delay = Number.parseInt(commandLine.getArgument(0)); + } + + console.error(`runBackgroundTask: wait ${delay} seconds`); + + return new Promise(resolve => setTimeout(resolve, delay * 1000)); +} diff --git a/toolkit/components/backgroundtasks/tests/browser/browser.ini b/toolkit/components/backgroundtasks/tests/browser/browser.ini new file mode 100644 index 0000000000..1524d431b4 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/browser/browser.ini @@ -0,0 +1,11 @@ +# 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/. + +[DEFAULT] +skip-if = toolkit == 'android' +head = head.js + +[browser_backgroundtask_specific_pref.js] +[browser_xpcom_graph_wait.js] +skip-if = tsan # TSan times out on pretty much all profiler-consuming tests. diff --git a/toolkit/components/backgroundtasks/tests/browser/browser_backgroundtask_specific_pref.js b/toolkit/components/backgroundtasks/tests/browser/browser_backgroundtask_specific_pref.js new file mode 100644 index 0000000000..b80ee2f593 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/browser/browser_backgroundtask_specific_pref.js @@ -0,0 +1,23 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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"; + +add_task(async function test_backgroundtask_specific_pref() { + // First, verify this pref isn't set in Gecko itself. + Assert.equal( + -1, + Services.prefs.getIntPref("test.backgroundtask_specific_pref.exitCode", -1) + ); + + // Second, verify that this pref is set in background tasks. + // mochitest-chrome tests locally test both unpackaged and packaged + // builds (with `--appname=dist`). + let exitCode = await do_backgroundtask("backgroundtask_specific_pref", { + extraArgs: ["test.backgroundtask_specific_pref.exitCode"], + }); + Assert.equal(79, exitCode); +}); diff --git a/toolkit/components/backgroundtasks/tests/browser/browser_xpcom_graph_wait.js b/toolkit/components/backgroundtasks/tests/browser/browser_xpcom_graph_wait.js new file mode 100644 index 0000000000..c5ec373e08 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/browser/browser_xpcom_graph_wait.js @@ -0,0 +1,407 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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/. */ + +/* This test records code loaded during a dummy background task. + * + * To run this test similar to try server, you need to run: + * ./mach package + * ./mach test --appname=dist <path to test> + * + * If you made changes that cause this test to fail, it's likely + * because you are changing the application startup process. In + * general, you should prefer to defer loading code as long as you + * can, especially if it's not going to be used in background tasks. + */ + +"use strict"; + +const Cm = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); + +// Shortcuts for conditions. +const LINUX = AppConstants.platform == "linux"; +const WIN = AppConstants.platform == "win"; +const MAC = AppConstants.platform == "macosx"; + +const backgroundtaskPhases = { + AfterRunBackgroundTaskNamed: { + allowlist: { + modules: [ + "resource://gre/modules/AppConstants.sys.mjs", + "resource://gre/modules/AsyncShutdown.sys.mjs", + "resource://gre/modules/BackgroundTasksManager.sys.mjs", + "resource://gre/modules/Console.sys.mjs", + "resource://gre/modules/EnterprisePolicies.sys.mjs", + "resource://gre/modules/EnterprisePoliciesParent.sys.mjs", + "resource://gre/modules/PromiseUtils.sys.mjs", + "resource://gre/modules/XPCOMUtils.sys.mjs", + "resource://gre/modules/nsAsyncShutdown.sys.mjs", + ], + // Human-readable contract IDs are many-to-one mapped to CIDs, so this + // list is a little misleading. For example, all of + // "@mozilla.org/xre/app-info;1", "@mozilla.org/xre/runtime;1", and + // "@mozilla.org/toolkit/crash-reporter;1", map to the CID + // {95d89e3e-a169-41a3-8e56-719978e15b12}, but only one is listed here. + // We could be more precise by listing CIDs, but that's a good deal harder + // to read and modify. + services: [ + "@mozilla.org/async-shutdown-service;1", + "@mozilla.org/backgroundtasks;1", + "@mozilla.org/backgroundtasksmanager;1", + "@mozilla.org/base/telemetry;1", + "@mozilla.org/categorymanager;1", + "@mozilla.org/chrome/chrome-registry;1", + "@mozilla.org/cookieService;1", + "@mozilla.org/docloaderservice;1", + "@mozilla.org/embedcomp/window-watcher;1", + "@mozilla.org/enterprisepolicies;1", + "@mozilla.org/file/directory_service;1", + "@mozilla.org/intl/stringbundle;1", + "@mozilla.org/layout/content-policy;1", + "@mozilla.org/memory-reporter-manager;1", + "@mozilla.org/network/captive-portal-service;1", + "@mozilla.org/network/effective-tld-service;1", + "@mozilla.org/network/idn-service;1", + "@mozilla.org/network/io-service;1", + "@mozilla.org/network/network-link-service;1", + "@mozilla.org/network/protocol;1?name=file", + "@mozilla.org/network/protocol;1?name=jar", + "@mozilla.org/network/protocol;1?name=resource", + "@mozilla.org/network/socket-transport-service;1", + "@mozilla.org/network/stream-transport-service;1", + "@mozilla.org/network/url-parser;1?auth=maybe", + "@mozilla.org/network/url-parser;1?auth=no", + "@mozilla.org/network/url-parser;1?auth=yes", + "@mozilla.org/observer-service;1", + "@mozilla.org/power/powermanagerservice;1", + "@mozilla.org/preferences-service;1", + "@mozilla.org/process/environment;1", + "@mozilla.org/storage/service;1", + "@mozilla.org/thirdpartyutil;1", + "@mozilla.org/toolkit/app-startup;1", + { + name: "@mozilla.org/widget/appshell/mac;1", + condition: MAC, + }, + { + name: "@mozilla.org/widget/appshell/gtk;1", + condition: LINUX, + }, + { + name: "@mozilla.org/widget/appshell/win;1", + condition: WIN, + }, + "@mozilla.org/xpcom/debug;1", + "@mozilla.org/xre/app-info;1", + "@mozilla.org/mime;1", + ], + }, + }, + AfterFindRunBackgroundTask: { + allowlist: { + modules: [ + // We have a profile marker for this, even though it failed to load! + "resource:///modules/backgroundtasks/BackgroundTask_wait.sys.mjs", + + "resource://gre/modules/ConsoleAPIStorage.sys.mjs", + "resource://gre/modules/Timer.sys.mjs", + + // We have a profile marker for this, even though it failed to load! + "resource://gre/modules/backgroundtasks/BackgroundTask_wait.sys.mjs", + + "resource://testing-common/backgroundtasks/BackgroundTask_wait.sys.mjs", + ], + services: ["@mozilla.org/consoleAPI-storage;1"], + }, + }, + AfterAwaitRunBackgroundTask: { + allowlist: { + modules: [], + services: [], + }, + }, +}; + +function getStackFromProfile(profile, stack, libs) { + const stackPrefixCol = profile.stackTable.schema.prefix; + const stackFrameCol = profile.stackTable.schema.frame; + const frameLocationCol = profile.frameTable.schema.location; + + let index = 1; + let result = []; + while (stack) { + let sp = profile.stackTable.data[stack]; + let frame = profile.frameTable.data[sp[stackFrameCol]]; + stack = sp[stackPrefixCol]; + frame = profile.stringTable[frame[frameLocationCol]]; + + if (frame.startsWith("0x")) { + try { + let addr = frame.slice("0x".length); + addr = Number.parseInt(addr, 16); + for (let lib of libs) { + if (lib.start <= addr && addr <= lib.end) { + // Only handle two digits for now. + let indexString = index.toString(10); + if (indexString.length == 1) { + indexString = "0" + indexString; + } + let offset = addr - lib.start; + frame = `#${indexString}: ???[${lib.debugPath} ${ + "+0x" + offset.toString(16) + }]`; + break; + } + } + } catch (e) { + // Fall through. + } + } + + if (frame != "js::RunScript" && !frame.startsWith("next (self-hosted:")) { + result.push(frame); + index += 1; + } + } + return result; +} + +add_task(async function test_xpcom_graph_wait() { + TestUtils.assertPackagedBuild(); + + let profilePath = Services.env.get("MOZ_UPLOAD_DIR"); + profilePath = + profilePath || + (await IOUtils.createUniqueDirectory( + PathUtils.profileDir, + "testBackgroundTask", + 0o700 + )); + + profilePath = PathUtils.join(profilePath, "profile_backgroundtask_wait.json"); + await IOUtils.remove(profilePath, { ignoreAbsent: true }); + + let extraEnv = { + MOZ_PROFILER_STARTUP: "1", + MOZ_PROFILER_SHUTDOWN: profilePath, + }; + + let exitCode = await do_backgroundtask("wait", { extraEnv }); + Assert.equal(0, exitCode); + + let rootProfile = await IOUtils.readJSON(profilePath); + let profile = rootProfile.threads[0]; + + const nameCol = profile.markers.schema.name; + const dataCol = profile.markers.schema.data; + + function newMarkers() { + return { + // The equivalent of `Cu.loadedJSModules` + `Cu.loadedESModules`. + modules: [], + services: [], + }; + } + + let phases = {}; + let markersForCurrentPhase = newMarkers(); + + // If a subsequent phase loads an already loaded resource, that's + // fine. Track all loaded resources to ignore such repeated loads. + let markersForAllPhases = newMarkers(); + + for (let m of profile.markers.data) { + let markerName = profile.stringTable[m[nameCol]]; + if (markerName.startsWith("BackgroundTasksManager:")) { + phases[markerName.split("BackgroundTasksManager:")[1]] = + markersForCurrentPhase; + markersForCurrentPhase = newMarkers(); + continue; + } + + if ( + ![ + "ChromeUtils.import", // JSMs. + "ChromeUtils.importESModule", // System ESMs. + "ChromeUtils.importESModule static import", + "GetService", // XPCOM services. + ].includes(markerName) + ) { + continue; + } + + let markerData = m[dataCol]; + if ( + markerName == "ChromeUtils.import" || + markerName == "ChromeUtils.importESModule" || + markerName == "ChromeUtils.importESModule static import" + ) { + let module = markerData.name; + if (!markersForAllPhases.modules.includes(module)) { + markersForAllPhases.modules.push(module); + markersForCurrentPhase.modules.push(module); + } + } + + if (markerName == "GetService") { + // We get a CID from the marker itself, but not a human-readable contract + // ID. Now, most of the time, the stack will contain a label like + // `GetServiceByContractID @...;1`, and we could extract the contract ID + // from that. But there are multiple ways to instantiate services, and + // not all of them are annotated in this manner. Therefore we "go the + // other way" and use the component manager's mapping from contract IDs to + // CIDs. This opens up the possibility for that mapping to be different + // between `--backgroundtask` and `xpcshell`, but that's not an issue + // right at this moment. It's worth noting that one CID can (and + // sometimes does) correspond to more than one contract ID. + let cid = markerData.name; + + if (!markersForAllPhases.services.includes(cid)) { + markersForAllPhases.services.push(cid); + markersForCurrentPhase.services.push(cid); + } + } + } + + // Turn `["1", {name: "2", condition: false}, {name: "3", condition: true}]` + // into `new Set(["1", "3"])`. + function filterConditions(l) { + let set = new Set([]); + for (let entry of l) { + if (typeof entry == "object") { + if ("condition" in entry && !entry.condition) { + continue; + } + entry = entry.name; + } + set.add(entry); + } + return set; + } + + for (let phaseName in backgroundtaskPhases) { + for (let listName in backgroundtaskPhases[phaseName]) { + for (let scriptType in backgroundtaskPhases[phaseName][listName]) { + backgroundtaskPhases[phaseName][listName][scriptType] = + filterConditions( + backgroundtaskPhases[phaseName][listName][scriptType] + ); + } + + // Turn human-readable contract IDs into CIDs. It's worth noting that one + // CID can (and sometimes does) correspond to more than one contract ID. + let services = Array.from( + backgroundtaskPhases[phaseName][listName].services || new Set([]) + ); + services = services + .map(contractID => { + try { + return Cm.contractIDToCID(contractID).toString(); + } catch (e) { + return null; + } + }) + .filter(cid => cid); + services.sort(); + backgroundtaskPhases[phaseName][listName].services = new Set(services); + info( + `backgroundtaskPhases[${phaseName}][${listName}].services = ${JSON.stringify( + services.map(c => c.toString()) + )}` + ); + } + } + + // Turn `{CID}` into `{CID} (@contractID)` or `{CID} (one of + // @contractID1, ..., @contractIDn)` as appropriate. + function renderResource(resource) { + const UUID_PATTERN = + /^\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}$/i; + if (UUID_PATTERN.test(resource)) { + let foundContractIDs = []; + for (let contractID of Cm.getContractIDs()) { + try { + if (resource == Cm.contractIDToCID(contractID).toString()) { + foundContractIDs.push(contractID); + } + } catch (e) { + // This can throw for contract IDs that are filtered. The common + // reason is that they're limited to a particular process. + } + } + if (!foundContractIDs.length) { + return `${resource} (CID with no human-readable contract IDs)`; + } else if (foundContractIDs.length == 1) { + return `${resource} (CID with human-readable contract ID ${foundContractIDs[0]})`; + } + foundContractIDs.sort(); + return `${resource} (CID with human-readable contract IDs ${JSON.stringify( + foundContractIDs + )})`; + } + + return resource; + } + + for (let phase in backgroundtaskPhases) { + let loadedList = phases[phase]; + let allowlist = backgroundtaskPhases[phase].allowlist || null; + if (allowlist) { + for (let scriptType in allowlist) { + loadedList[scriptType] = loadedList[scriptType].filter(c => { + if (!allowlist[scriptType].has(c)) { + return true; + } + allowlist[scriptType].delete(c); + return false; + }); + Assert.deepEqual( + loadedList[scriptType], + [], + `${phase}: should have no unexpected ${scriptType} loaded` + ); + + // Present errors in deterministic order. + let unexpected = Array.from(loadedList[scriptType]); + unexpected.sort(); + for (let script of unexpected) { + // It would be nice to show stacks here, but that can be follow-up. + ok( + false, + `${phase}: unexpected ${scriptType}: ${renderResource(script)}` + ); + } + Assert.deepEqual( + allowlist[scriptType].size, + 0, + `${phase}: all ${scriptType} allowlist entries should have been used` + ); + let unused = Array.from(allowlist[scriptType]); + unused.sort(); + for (let script of unused) { + ok( + false, + `${phase}: unused ${scriptType} allowlist entry: ${renderResource( + script + )}` + ); + } + } + } + let denylist = backgroundtaskPhases[phase].denylist || null; + if (denylist) { + for (let scriptType in denylist) { + let resources = denylist[scriptType]; + resources.sort(); + for (let resource of resources) { + let loaded = loadedList[scriptType].includes(resource); + let message = `${phase}: ${renderResource(resource)} is not allowed`; + // It would be nice to show stacks here, but that can be follow-up. + ok(!loaded, message); + } + } + } + } +}); diff --git a/toolkit/components/backgroundtasks/tests/browser/head.js b/toolkit/components/backgroundtasks/tests/browser/head.js new file mode 100644 index 0000000000..703a3d64c9 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/browser/head.js @@ -0,0 +1,15 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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"; + +const { BackgroundTasksTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/BackgroundTasksTestUtils.sys.mjs" +); +BackgroundTasksTestUtils.init(this); +const do_backgroundtask = BackgroundTasksTestUtils.do_backgroundtask.bind( + BackgroundTasksTestUtils +); diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/CatBackgroundTaskRegistrationComponents.manifest b/toolkit/components/backgroundtasks/tests/xpcshell/CatBackgroundTaskRegistrationComponents.manifest new file mode 100644 index 0000000000..6a675fc234 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/CatBackgroundTaskRegistrationComponents.manifest @@ -0,0 +1,4 @@ +category test-cat CatRegisteredComponent @unit.test.com/cat-registered-component;1 +category test-cat CatBackgroundTaskRegisteredComponent @unit.test.com/cat-backgroundtask-registered-component;1 backgroundtask +category test-cat CatBackgroundTaskAlwaysRegisteredComponent @unit.test.com/cat-backgroundtask-alwaysregistered-component;1 backgroundtask=1 +category test-cat CatBackgroundTaskNotRegisteredComponent @unit.test.com/cat-backgroundtask-notregistered-component;1 backgroundtask=0 diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/experiment.json b/toolkit/components/backgroundtasks/tests/xpcshell/experiment.json new file mode 100644 index 0000000000..606cff3de9 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/experiment.json @@ -0,0 +1,102 @@ +{ + "permissions": {}, + "data": { + "slug": "test-experiment", + "appId": "firefox-desktop", + "appName": "firefox_desktop", + "channel": "", + "endDate": null, + "branches": [ + { + "slug": "treatment-a", + "ratio": 1, + "feature": { + "value": {}, + "enabled": false, + "featureId": "this-is-included-for-desktop-pre-95-support" + }, + "features": [ + { + "value": { + "id": "test-experiment:treatment-a", + "groups": ["backgroundTaskMessage"], + "content": { + "body": "Body A", + "title": "Treatment A", + "tag": "should_be_overridden_a" + }, + "trigger": { + "id": "backgroundTask" + }, + "priority": 1, + "template": "toast_notification", + "frequency": { + "lifetime": 2 + }, + "targeting": "true" + }, + "enabled": true, + "featureId": "backgroundTaskMessage" + } + ] + }, + { + "slug": "treatment-b", + "ratio": 1, + "feature": { + "value": {}, + "enabled": false, + "featureId": "this-is-included-for-desktop-pre-95-support" + }, + "features": [ + { + "value": { + "id": "test-experiment:treatment-b", + "groups": ["backgroundTaskMessage"], + "content": { + "body": "Body B", + "title": "Treatment B" + }, + "trigger": { + "id": "backgroundTask" + }, + "priority": 1, + "template": "toast_notification", + "frequency": { + "lifetime": 2 + }, + "targeting": "true" + }, + "enabled": true, + "featureId": "backgroundTaskMessage" + } + ] + } + ], + "outcomes": [], + "arguments": {}, + "isRollout": false, + "probeSets": [], + "startDate": null, + "targeting": "('app.shield.optoutstudies.enabled'|preferenceValue) && (version|versionCompare('102.!') >= 0)", + "featureIds": ["backgroundTaskMessage"], + "application": "firefox-desktop", + "bucketConfig": { + "count": 10000, + "start": 0, + "total": 10000, + "namespace": "firefox-desktop-backgroundTaskMessage-1", + "randomizationUnit": "normandy_id" + }, + "schemaVersion": "1.8.0", + "userFacingName": "test-experiment", + "referenceBranch": "treatment-a", + "proposedDuration": 28, + "enrollmentEndDate": null, + "isEnrollmentPaused": false, + "proposedEnrollment": 7, + "userFacingDescription": "Test experiment to test supporting the Messaging System in Firefox background tasks.", + "id": "test-experiment", + "last_modified": 1657578927064 + } +} diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/head.js b/toolkit/components/backgroundtasks/tests/xpcshell/head.js new file mode 100644 index 0000000000..929d53d208 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/head.js @@ -0,0 +1,22 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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"; + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +const { BackgroundTasksTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/BackgroundTasksTestUtils.sys.mjs" +); +BackgroundTasksTestUtils.init(this); +const do_backgroundtask = BackgroundTasksTestUtils.do_backgroundtask.bind( + BackgroundTasksTestUtils +); +const setupProfileService = BackgroundTasksTestUtils.setupProfileService.bind( + BackgroundTasksTestUtils +); diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_deletes_profile.js b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_deletes_profile.js new file mode 100644 index 0000000000..7c9be2e780 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_deletes_profile.js @@ -0,0 +1,128 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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/. */ + +add_task(async function test_backgroundtask_deletes_profile() { + let sentinel = Services.uuid.generateUUID().toString(); + sentinel = sentinel.substring(1, sentinel.length - 1); + + let stdoutLines = []; + let exitCode = await do_backgroundtask("unique_profile", { + extraArgs: [sentinel], + onStdoutLine: line => stdoutLines.push(line), + }); + Assert.equal(0, exitCode); + + let profile; + for (let line of stdoutLines) { + if (line.includes(sentinel)) { + let info = JSON.parse(line.split(sentinel)[1]); + profile = info[1]; + } + } + Assert.ok(!!profile, `Found profile: ${profile}`); + + let dir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + dir.initWithPath(profile); + Assert.ok( + !dir.exists(), + `Temporary profile directory does not exist: ${profile}` + ); +}); + +let c = { + // See note about macOS and temporary directories below. + skip_if: () => AppConstants.platform == "macosx", +}; + +add_task(c, async function test_backgroundtask_cleans_up_stale_profiles() { + // Background tasks shutdown and removal raced with creating files in the + // temporary profile directory, leaving stale profile remnants on disk. We + // try to incrementally clean up such remnants. This test verifies that clean + // up succeeds as expected. In the future we might test that locked profiles + // are ignored and that a failure during a removal does not stop other + // removals from being processed, etc. + + // Background task temporary profiles are created in the OS temporary + // directory. On Windows, we can put the temporary directory in a + // testing-specific location by setting TMP, per + // https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-gettemppathw. + // On Linux, this works as well: see + // [GetSpecialSystemDirectory](https://searchfox.org/mozilla-central/source/xpcom/io/SpecialSystemDirectory.cpp). + // On macOS, we can't set the temporary directory in this manner so we skip + // this test. + let tmp = do_get_profile(); + tmp.append("Temp"); + tmp.createUnique(Ci.nsIFile.DIRECTORY_TYPE, 0o700); + + // Get the "profile prefix" that the clean up process generates by fishing the + // profile directory from the testing task that provides the profile path. + let sentinel = Services.uuid.generateUUID().toString(); + sentinel = sentinel.substring(1, sentinel.length - 1); + + let stdoutLines = []; + let exitCode = await do_backgroundtask("unique_profile", { + extraArgs: [sentinel], + extraEnv: { TMP: tmp.path }, + onStdoutLine: line => stdoutLines.push(line), + }); + Assert.equal(0, exitCode); + + let profile; + for (let line of stdoutLines) { + if (line.includes(sentinel)) { + let info = JSON.parse(line.split(sentinel)[1]); + profile = info[1]; + } + } + Assert.ok(!!profile, `Found profile: ${profile}`); + + Assert.ok( + profile.startsWith(tmp.path), + "Profile was created in test-specific temporary path" + ); + + let dir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + dir.initWithPath(profile); + + // Create a few "stale" profile directories. + let ps = []; + for (let i = 0; i < 3; i++) { + let p = dir.parent.clone(); + p.append(`${dir.leafName}_${i}`); + p.create(Ci.nsIFile.DIRECTORY_TYPE, 0o700); + ps.push(p); + + let f = p.clone(); + f.append(`file_${i}`); + await IOUtils.writeJSON(f.path, {}); + } + + // Display logging for ease of debugging. + let moz_log = "BackgroundTasks:5"; + if (Services.env.exists("MOZ_LOG")) { + moz_log += `,${Services.env.get("MOZ_LOG")}`; + } + + // Invoke the task. + exitCode = await do_backgroundtask("unique_profile", { + extraArgs: [sentinel], + extraEnv: { + TMP: tmp.path, + MOZ_LOG: moz_log, + MOZ_BACKGROUNDTASKS_PURGE_STALE_PROFILES: "always", + }, + }); + Assert.equal(0, exitCode); + + // Verify none of the stale profile directories persist. + let es = []; // Expecteds. + let as = []; // Actuals. + for (let p of ps) { + as.push(!p.exists()); + es.push(true); + } + Assert.deepEqual(es, as, "All stale profile directories were cleaned up."); +}); diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_exitcodes.js b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_exitcodes.js new file mode 100644 index 0000000000..f2202ff60f --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_exitcodes.js @@ -0,0 +1,49 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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 test exercises functionality and also ensures the exit codes, +// which are a public API, do not change over time. +const { EXIT_CODE } = ChromeUtils.importESModule( + "resource://gre/modules/BackgroundTasksManager.sys.mjs" +); + +add_task(async function test_success() { + let exitCode = await do_backgroundtask("success"); + Assert.equal(0, exitCode); + Assert.equal(EXIT_CODE.SUCCESS, exitCode); +}); + +add_task(async function test_failure() { + let exitCode = await do_backgroundtask("failure"); + Assert.equal(1, exitCode); + // There's no single exit code for failure. + Assert.notEqual(EXIT_CODE.SUCCESS, exitCode); +}); + +add_task(async function test_exception() { + let exitCode = await do_backgroundtask("exception"); + Assert.equal(3, exitCode); + Assert.equal(EXIT_CODE.EXCEPTION, exitCode); +}); + +add_task(async function test_not_found() { + let exitCode = await do_backgroundtask("not_found"); + Assert.equal(2, exitCode); + Assert.equal(EXIT_CODE.NOT_FOUND, exitCode); +}); + +add_task(async function test_timeout() { + const startTime = new Date(); + let exitCode = await do_backgroundtask("timeout"); + const endTime = new Date(); + const elapsedMs = endTime - startTime; + const fiveMinutesInMs = 5 * 60 * 1000; + Assert.less(elapsedMs, fiveMinutesInMs); + Assert.equal(4, exitCode); + Assert.equal(EXIT_CODE.TIMEOUT, exitCode); +}); diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_experiments.js b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_experiments.js new file mode 100644 index 0000000000..087c557acf --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_experiments.js @@ -0,0 +1,416 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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/. */ + +// This file tests several things. +// +// 1. We verify that we can forcefully opt-in to (particular branches of) +// experiments, that the resulting Firefox Messaging Experiment applies, and +// that the Firefox Messaging System respects lifetime frequency caps. +// 2. We verify that Nimbus randomization works with specific Normandy +// randomization IDs. +// 3. We verify that relevant opt-out prefs disable the Nimbus and Firefox +// Messaging System experience. + +const { ASRouterTargeting } = ChromeUtils.import( + "resource://activity-stream/lib/ASRouterTargeting.jsm" +); + +// These randomization IDs were extracted by hand from Firefox instances. +// Randomization is sufficiently stable to hard-code these IDs rather than +// generating new ones at test time. +const BRANCH_MAP = { + "treatment-a": { + randomizationId: "d0e95fc3-fb15-4bc4-8151-a89582a56e29", + title: "Treatment A", + text: "Body A", + }, + "treatment-b": { + randomizationId: "90a60347-66cc-4716-9fef-cf49dd992d51", + title: "Treatment B", + text: "Body B", + }, +}; + +setupProfileService(); + +let taskProfile; + +// Arrange a dummy Remote Settings server so that no non-local network +// connections are opened. +// And arrange dummy task profile. +add_setup(() => { + info("Setting up profile service"); + let profileService = Cc["@mozilla.org/toolkit/profile-service;1"].getService( + Ci.nsIToolkitProfileService + ); + + let taskProfD = do_get_profile(); + taskProfD.append("test_backgroundtask_experiments_task"); + taskProfile = profileService.createUniqueProfile( + taskProfD, + "test_backgroundtask_experiments_task" + ); + + registerCleanupFunction(() => { + taskProfile.remove(true); + }); +}); + +function resetProfile(profile) { + profile.rootDir.remove(true); + profile.rootDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o700); + info(`Reset profile '${profile.rootDir.path}'`); +} + +// Run the "message" background task with some default configuration. Return +// "info" items output from the task as an array and as a map. +async function doMessage({ extraArgs = [], extraEnv = {} } = {}) { + let sentinel = Services.uuid.generateUUID().toString(); + sentinel = sentinel.substring(1, sentinel.length - 1); + + let infoArray = []; + let exitCode = await do_backgroundtask("message", { + extraArgs: [ + "--sentinel", + sentinel, + // Use a test-specific non-ephemeral profile, not the system-wide shared + // task-specific profile. + "--profile", + taskProfile.rootDir.path, + // Don't actually show a toast notification. + "--disable-alerts-service", + // Don't contact Remote Settings server. Can be overridden by subsequent + // `--experiments ...`. + "--no-experiments", + ...extraArgs, + ], + extraEnv: { + MOZ_LOG: "Dump:5,BackgroundTasks:5", + ...extraEnv, + }, + onStdoutLine: line => { + if (line.includes(sentinel)) { + let info = JSON.parse(line.split(sentinel)[1]); + infoArray.push(info); + } + }, + }); + + Assert.equal( + 0, + exitCode, + "The message background task exited with exit code 0" + ); + + // Turn [{x:...}, {y:...}] into {x:..., y:...}. + let infoMap = Object.assign({}, ...infoArray); + + return { infoArray, infoMap }; +} + +// Opt-in to an experiment. Verify that the experiment state is non-ephemeral, +// i.e., persisted. Verify that messages are shown until we hit the lifetime +// frequency caps. +// +// It's awkward to inspect the `ASRouter.jsm` internal state directly in this +// manner, but this is the pattern for testing such things at the time of +// writing. +add_task(async function test_backgroundtask_caps() { + let experimentFile = do_get_file("experiment.json"); + let experimentFileURI = Services.io.newFileURI(experimentFile); + + let { infoMap } = await doMessage({ + extraArgs: [ + // Opt-in to an experiment from a file. + "--url", + `${experimentFileURI.spec}?optin_branch=treatment-a`, + ], + }); + + // Verify that the correct experiment and branch generated an impression. + let impressions = infoMap.ASRouterState.messageImpressions; + Assert.deepEqual(Object.keys(impressions), ["test-experiment:treatment-a"]); + Assert.equal(impressions["test-experiment:treatment-a"].length, 1); + + // Verify that the correct toast notification was shown. + let alert = infoMap.showAlert.args[0]; + Assert.equal(alert.title, "Treatment A"); + Assert.equal(alert.text, "Body A"); + Assert.equal(alert.name, "optin-test-experiment:treatment-a"); + + // Now, do it again. No need to opt-in to the experiment this time. + ({ infoMap } = await doMessage({})); + + // Verify that only the correct experiment and branch generated an impression. + impressions = infoMap.ASRouterState.messageImpressions; + Assert.deepEqual(Object.keys(impressions), ["test-experiment:treatment-a"]); + Assert.equal(impressions["test-experiment:treatment-a"].length, 2); + + // Verify that the correct toast notification was shown. + alert = infoMap.showAlert.args[0]; + Assert.equal(alert.title, "Treatment A"); + Assert.equal(alert.text, "Body A"); + Assert.equal(alert.name, "optin-test-experiment:treatment-a"); + + // A third time. We'll hit the lifetime frequency cap (which is 2). + ({ infoMap } = await doMessage({})); + + // Verify that the correct experiment and branch impressions are untouched. + impressions = infoMap.ASRouterState.messageImpressions; + Assert.deepEqual(Object.keys(impressions), ["test-experiment:treatment-a"]); + Assert.equal(impressions["test-experiment:treatment-a"].length, 2); + + // Verify that no toast notication was shown. + Assert.ok(!("showAlert" in infoMap), "No alert shown"); +}); + +// Test that background tasks are enrolled into branches based on the Normandy +// randomization ID as expected. Run the message task with a hard-coded list of +// a single experiment and known randomization IDs, and verify that the enrolled +// branches are as expected. +add_task(async function test_backgroundtask_randomization() { + let experimentFile = do_get_file("experiment.json"); + + for (let [branchSlug, branchDetails] of Object.entries(BRANCH_MAP)) { + // Start fresh each time. + resetProfile(taskProfile); + + // Invoke twice; verify the branch is consistent each time. + for (let count = 1; count <= 2; count++) { + let { infoMap } = await doMessage({ + extraArgs: [ + // Read experiments from a file. + "--experiments", + experimentFile.path, + // Fixed randomization ID yields a deterministic enrollment branch assignment. + "--randomizationId", + branchDetails.randomizationId, + ], + }); + + // Verify that only the correct experiment and branch generated an impression. + let impressions = infoMap.ASRouterState.messageImpressions; + Assert.deepEqual(Object.keys(impressions), [ + `test-experiment:${branchSlug}`, + ]); + Assert.equal(impressions[`test-experiment:${branchSlug}`].length, count); + + // Verify that the correct toast notification was shown. + let alert = infoMap.showAlert.args[0]; + Assert.equal(alert.title, branchDetails.title, "Title is correct"); + Assert.equal(alert.text, branchDetails.text, "Text is correct"); + Assert.equal( + alert.name, + `test-experiment:${branchSlug}`, + "Name (tag) is correct" + ); + } + } +}); + +// Test that background tasks respect the datareporting and studies opt-out +// preferences. +add_task(async function test_backgroundtask_optout_preferences() { + let experimentFile = do_get_file("experiment.json"); + + let OPTION_MAP = { + "--no-datareporting": { + "datareporting.healthreport.uploadEnabled": false, + "app.shield.optoutstudies.enabled": true, + }, + "--no-optoutstudies": { + "datareporting.healthreport.uploadEnabled": true, + "app.shield.optoutstudies.enabled": false, + }, + }; + + for (let [option, expectedPrefs] of Object.entries(OPTION_MAP)) { + // Start fresh each time. + resetProfile(taskProfile); + + let { infoMap } = await doMessage({ + extraArgs: [ + option, + // Read experiments from a file. Opting in to an experiment with + // `--url` does not consult relevant preferences. + "--experiments", + experimentFile.path, + ], + }); + + Assert.deepEqual(infoMap.taskProfilePrefs, expectedPrefs); + + // Verify that no experiment generated an impression. + let impressions = infoMap.ASRouterState.messageImpressions; + Assert.deepEqual( + impressions, + [], + `No impressions generated with ${option}` + ); + + // Verify that no toast notication was shown. + Assert.ok(!("showAlert" in infoMap), `No alert shown with ${option}`); + } +}); + +const TARGETING_LIST = [ + // Target based on background task details. + ["isBackgroundTaskMode", 1], + ["backgroundTaskName == 'message'", 1], + ["backgroundTaskName == 'unrecognized'", 0], + // Filter based on `defaultProfile` targeting snapshot. + ["(currentDate|date - defaultProfile.currentDate|date) > 0", 1], + ["(currentDate|date - defaultProfile.currentDate|date) > 999999", 0], +]; + +// Test that background tasks targeting works for Nimbus experiments. +add_task(async function test_backgroundtask_Nimbus_targeting() { + let experimentFile = do_get_file("experiment.json"); + let experimentData = await IOUtils.readJSON(experimentFile.path); + + // We can't take a full environment snapshot under `xpcshell`. Select a few + // items that do work. + let target = { + currentDate: ASRouterTargeting.Environment.currentDate, + firefoxVersion: ASRouterTargeting.Environment.firefoxVersion, + }; + let targetSnapshot = await ASRouterTargeting.getEnvironmentSnapshot(target); + + for (let [targeting, expectedLength] of TARGETING_LIST) { + // Start fresh each time. + resetProfile(taskProfile); + + let snapshotFile = taskProfile.rootDir.clone(); + snapshotFile.append("targeting.snapshot.json"); + await IOUtils.writeJSON(snapshotFile.path, targetSnapshot); + + // Write updated experiment data. + experimentData.data.targeting = targeting; + let targetingExperimentFile = taskProfile.rootDir.clone(); + targetingExperimentFile.append("targeting.experiment.json"); + await IOUtils.writeJSON(targetingExperimentFile.path, experimentData); + + let { infoMap } = await doMessage({ + extraArgs: [ + "--experiments", + targetingExperimentFile.path, + "--targeting-snapshot", + snapshotFile.path, + ], + }); + + // Verify that the given targeting generated the expected number of impressions. + let impressions = infoMap.ASRouterState.messageImpressions; + Assert.equal( + Object.keys(impressions).length, + expectedLength, + `${expectedLength} impressions generated with targeting '${targeting}'` + ); + } +}); + +// Test that background tasks targeting works for Firefox Messaging System branches. +add_task(async function test_backgroundtask_Messaging_targeting() { + // Don't target the Nimbus experiment at all. Use a consistent + // randomization ID to always enroll in the first branch. Target + // the first branch of the Firefox Messaging Experiment to the given + // targeting. Therefore, we either get the first branch if the + // targeting matches, or nothing at all. + + let treatmentARandomizationId = BRANCH_MAP["treatment-a"].randomizationId; + + let experimentFile = do_get_file("experiment.json"); + let experimentData = await IOUtils.readJSON(experimentFile.path); + + // We can't take a full environment snapshot under `xpcshell`. Select a few + // items that do work. + let target = { + currentDate: ASRouterTargeting.Environment.currentDate, + firefoxVersion: ASRouterTargeting.Environment.firefoxVersion, + }; + let targetSnapshot = await ASRouterTargeting.getEnvironmentSnapshot(target); + + for (let [targeting, expectedLength] of TARGETING_LIST) { + // Start fresh each time. + resetProfile(taskProfile); + + let snapshotFile = taskProfile.rootDir.clone(); + snapshotFile.append("targeting.snapshot.json"); + await IOUtils.writeJSON(snapshotFile.path, targetSnapshot); + + // Write updated experiment data. + experimentData.data.targeting = "true"; + experimentData.data.branches[0].features[0].value.targeting = targeting; + + let targetingExperimentFile = taskProfile.rootDir.clone(); + targetingExperimentFile.append("targeting.experiment.json"); + await IOUtils.writeJSON(targetingExperimentFile.path, experimentData); + + let { infoMap } = await doMessage({ + extraArgs: [ + "--experiments", + targetingExperimentFile.path, + "--targeting-snapshot", + snapshotFile.path, + "--randomizationId", + treatmentARandomizationId, + ], + }); + + // Verify that the given targeting generated the expected number of impressions. + let impressions = infoMap.ASRouterState.messageImpressions; + Assert.equal( + Object.keys(impressions).length, + expectedLength, + `${expectedLength} impressions generated with targeting '${targeting}'` + ); + + if (expectedLength > 0) { + // Verify that the correct toast notification was shown. + let alert = infoMap.showAlert.args[0]; + Assert.equal( + alert.title, + BRANCH_MAP["treatment-a"].title, + "Title is correct" + ); + Assert.equal( + alert.text, + BRANCH_MAP["treatment-a"].text, + "Text is correct" + ); + Assert.equal( + alert.name, + `test-experiment:treatment-a`, + "Name (tag) is correct" + ); + } + } +}); + +// Verify that `RemoteSettingsClient.sync` is invoked before any +// `RemoteSettingsClient.get` invocations. This ensures the Remote Settings +// recipe collection is not allowed to go stale. +add_task( + async function test_backgroundtask_RemoteSettingsClient_invokes_sync() { + let { infoArray, infoMap } = await doMessage({}); + + Assert.ok( + "RemoteSettingsClient.get" in infoMap, + "RemoteSettingsClient.get was invoked" + ); + + for (let info of infoArray) { + if ("RemoteSettingsClient.get" in info) { + const { options: calledOptions } = info["RemoteSettingsClient.get"]; + Assert.ok( + calledOptions.forceSync, + "RemoteSettingsClient.get was first called with `forceSync`" + ); + return; + } + } + } +); diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_help.js b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_help.js new file mode 100644 index 0000000000..3dea69cb6a --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_help.js @@ -0,0 +1,20 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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"; + +add_task(async function test_backgroundtask_help_includes_jsdebugger_options() { + let lines = []; + let exitCode = await do_backgroundtask("success", { + extraArgs: ["--help"], + onStdoutLine: line => lines.push(line), + }); + Assert.equal(0, exitCode); + + Assert.ok(lines.some(line => line.includes("--jsdebugger"))); + Assert.ok(lines.some(line => line.includes("--wait-for-jsdebugger"))); + Assert.ok(lines.some(line => line.includes("--start-debugger-server"))); +}); diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_localization.js b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_localization.js new file mode 100644 index 0000000000..e61ea50f09 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_localization.js @@ -0,0 +1,40 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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/. */ + +// Disable `xpc::IsInAutomation()` so that we don't generate fatal +// Localization errors. +Services.prefs.setBoolPref( + "security.turn_off_all_security_so_that_viruses_can_take_over_this_computer", + false +); + +async function doOne(resource, id) { + let l10n = new Localization([resource], true); + let value = await l10n.formatValue(id); + Assert.ok(value, `${id} from ${resource} is not null: ${value}`); + + let exitCode = await do_backgroundtask("localization", { + extraArgs: [resource, id, value], + }); + Assert.equal(0, exitCode); +} + +add_task(async function test_localization() { + // Verify that the `l10n-registry` category is processed and that localization + // works as expected in background tasks. We can use any FTL resource and + // string identifier here as long as the value is short and can be passed as a + // command line argument safely (i.e., is ASCII). + + // One from toolkit/. + await doOne("toolkit/global/commonDialog.ftl", "common-dialog-title-system"); + if (AppConstants.MOZ_APP_NAME == "thunderbird") { + // And one from messenger/. + await doOne("messenger/messenger.ftl", "no-reply-title"); + } else { + // And one from browser/. + await doOne("browser/pageInfo.ftl", "not-set-date"); + } +}); diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_locked_profile.js b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_locked_profile.js new file mode 100644 index 0000000000..ef70bcf51e --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_locked_profile.js @@ -0,0 +1,28 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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/. */ + +setupProfileService(); + +add_task(async function test_backgroundtask_locked_profile() { + let profileService = Cc["@mozilla.org/toolkit/profile-service;1"].getService( + Ci.nsIToolkitProfileService + ); + + let profile = profileService.createUniqueProfile( + do_get_profile(), + "test_locked_profile" + ); + let lock = profile.lock({}); + + try { + let exitCode = await do_backgroundtask("success", { + extraEnv: { XRE_PROFILE_PATH: lock.directory.path }, + }); + Assert.equal(1, exitCode); + } finally { + lock.unlock(); + } +}); diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_minruntime.js b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_minruntime.js new file mode 100644 index 0000000000..faacf1b0e4 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_minruntime.js @@ -0,0 +1,21 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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"; + +add_task(async function test_backgroundtask_minruntime() { + let startTime = new Date().getTime(); + let exitCode = await do_backgroundtask("minruntime"); + Assert.equal(0, exitCode); + let finishTime = new Date().getTime(); + + // minruntime sets backgroundTaskMinRuntimeMS = 2000; + // Have some tolerance for flaky timers. + Assert.ok( + finishTime - startTime > 1800, + "Runtime was at least 2 seconds (approximately)." + ); +}); diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_no_output.js b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_no_output.js new file mode 100644 index 0000000000..e4553c6a7b --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_no_output.js @@ -0,0 +1,57 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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/. */ + +// Run a background task which itself waits for a launched background task, +// which itself waits for a launched background task, etc. Verify no output is +// produced. +add_task(async function test_backgroundtask_no_output() { + let sentinel = Services.uuid.generateUUID().toString(); + sentinel = sentinel.substring(1, sentinel.length - 1); + + let count = 2; + let outputLines = []; + let exitCode = await do_backgroundtask("no_output", { + extraArgs: [sentinel, count.toString()], + // This is a misnomer: stdout is redirected to stderr, so this is _any_ + // output line. + onStdoutLine: line => outputLines.push(line), + }); + Assert.equal(0, exitCode); + + if (AppConstants.platform !== "win") { + // Check specific logs because there can still be some logs in certain conditions, + // e.g. in code coverage (see bug 1831778 and bug 1804833) + ok( + outputLines.every(l => !l.includes("*** You are running in")), + "Should not have logs by default" + ); + } +}); + +// Run a background task which itself waits for a launched background task, +// which itself waits for a launched background task, etc. Since we ignore the +// no output restriction, verify that output is produced. +add_task(async function test_backgroundtask_ignore_no_output() { + let sentinel = Services.uuid.generateUUID().toString(); + sentinel = sentinel.substring(1, sentinel.length - 1); + + let count = 2; + let outputLines = []; + let exitCode = await do_backgroundtask("no_output", { + extraArgs: [sentinel, count.toString()], + extraEnv: { MOZ_BACKGROUNDTASKS_IGNORE_NO_OUTPUT: "1" }, + // This is a misnomer: stdout is redirected to stderr, so this is _any_ + // output line. + onStdoutLine: line => { + if (line.includes(sentinel)) { + outputLines.push(line); + } + }, + }); + Assert.equal(0, exitCode); + + Assert.equal(count, outputLines.length); +}); diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_not_ephemeral_profile.js b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_not_ephemeral_profile.js new file mode 100644 index 0000000000..29ef0d542d --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_not_ephemeral_profile.js @@ -0,0 +1,24 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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/. */ + +add_task(async function test_not_ephemeral_profile() { + // Get the pref, and then update its value. We ignore the initial return, + // since the persistent profile may already exist. + let exitCode = await do_backgroundtask("not_ephemeral_profile", { + extraArgs: ["test.backgroundtask_specific_pref.exitCode", "80"], + }); + + // Do it again, confirming that the profile is persistent. + exitCode = await do_backgroundtask("not_ephemeral_profile", { + extraArgs: ["test.backgroundtask_specific_pref.exitCode", "81"], + }); + Assert.equal(80, exitCode); + + exitCode = await do_backgroundtask("not_ephemeral_profile", { + extraArgs: ["test.backgroundtask_specific_pref.exitCode", "82"], + }); + Assert.equal(81, exitCode); +}); diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_policies.js b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_policies.js new file mode 100644 index 0000000000..45f64e5f01 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_policies.js @@ -0,0 +1,45 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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/. */ + +// In order to use the policy engine inside the xpcshell harness, we need to set +// up a dummy app info. In the backgroundtask itself, the application under +// test will configure real app info. This opens a possibility for some +// incompatibility, but there doesn't appear to be such an issue at this time. +const { updateAppInfo } = ChromeUtils.importESModule( + "resource://testing-common/AppInfo.sys.mjs" +); +updateAppInfo({ + name: "XPCShell", + ID: "xpcshell@tests.mozilla.org", + version: "48", + platformVersion: "48", +}); + +const { EnterprisePolicyTesting } = ChromeUtils.importESModule( + "resource://testing-common/EnterprisePolicyTesting.sys.mjs" +); + +// This initializes the policy engine for xpcshell tests +let policies = Cc["@mozilla.org/enterprisepolicies;1"].getService( + Ci.nsIObserver +); +policies.observe(null, "policies-startup", null); + +add_task(async function test_backgroundtask_policies() { + let url = "https://www.example.com/"; + await EnterprisePolicyTesting.setupPolicyEngineWithJson({ + policies: { + AppUpdateURL: url, + }, + }); + + let filePath = Services.prefs.getStringPref("browser.policies.alternatePath"); + + let exitCode = await do_backgroundtask("policies", { + extraArgs: [filePath, url], + }); + Assert.equal(0, exitCode); +}); diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_profile_is_slim.js b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_profile_is_slim.js new file mode 100644 index 0000000000..06f28616b4 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_profile_is_slim.js @@ -0,0 +1,136 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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/. */ + +// Invoke `profile_is_slim` background task, have it *not* remove its +// temporary profile, and verify that the temporary profile doesn't +// contain unexpected files and/or directories. +// +// N.b.: the background task invocation is a production Firefox +// running in automation; that means it can only connect to localhost +// (and it is not configured to connect to mochi.test, etc). +// Therefore we run our own server for it to connect to. + +const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js"); +let server; + +setupProfileService(); + +let successCount = 0; +function success(metadata, response) { + response.setStatusLine(metadata.httpVersion, 200, "OK"); + let str = "success"; + response.write(str, str.length); + successCount += 1; +} + +add_setup(() => { + server = new HttpServer(); + server.registerPathHandler("/success", success); + server.start(-1); + + registerCleanupFunction(async () => { + await server.stop(); + }); +}); + +add_task(async function test_backgroundtask_profile_is_slim() { + let profileService = Cc["@mozilla.org/toolkit/profile-service;1"].getService( + Ci.nsIToolkitProfileService + ); + + let profD = do_get_profile(); + profD.append("test_profile_is_slim"); + let profile = profileService.createUniqueProfile( + profD, + "test_profile_is_slim" + ); + + registerCleanupFunction(() => { + profile.remove(true); + }); + + let exitCode = await do_backgroundtask("profile_is_slim", { + extraArgs: [`http://localhost:${server.identity.primaryPort}/success`], + extraEnv: { + XRE_PROFILE_PATH: profile.rootDir.path, + MOZ_BACKGROUNDTASKS_NO_REMOVE_PROFILE: "1", + }, + }); + + Assert.equal( + 0, + exitCode, + "The fetch background task exited with exit code 0" + ); + Assert.equal( + 1, + successCount, + "The fetch background task reached the test server 1 time" + ); + + assertProfileIsSlim(profile.rootDir); +}); + +const expected = [ + { relPath: "lock", condition: AppConstants.platform == "linux" }, + { + relPath: ".parentlock", + condition: + AppConstants.platform == "linux" || AppConstants.platform == "macosx", + }, + { + relPath: "parent.lock", + condition: + AppConstants.platform == "win" || AppConstants.platform == "macosx", + }, + // TODO: verify that directory is empty. + { relPath: "cache2", isDirectory: true }, + { relPath: "compatibility.ini" }, + { relPath: "crashes", isDirectory: true }, + { relPath: "data", isDirectory: true }, + { relPath: "local", isDirectory: true }, + { relPath: "minidumps", isDirectory: true }, + // `mozinfo.json` is an artifact of the testing infrastructure. + { relPath: "mozinfo.json" }, + { relPath: "pkcs11.txt" }, + { relPath: "prefs.js" }, + { relPath: "security_state", isDirectory: true }, + // TODO: verify that directory is empty. + { relPath: "startupCache", isDirectory: true }, + { relPath: "times.json" }, +]; + +async function assertProfileIsSlim(profile) { + Assert.ok(profile.exists(), `Profile directory does exist: ${profile.path}`); + + // We collect unexpected results, log them all, and then assert + // there are none to provide the most feedback per iteration. + let unexpected = []; + + let typeLabel = { true: "directory", false: "file" }; + + for (let entry of profile.directoryEntries) { + // `getRelativePath` always uses '/' as path separator. + let relPath = entry.getRelativePath(profile); + + info(`Witnessed directory entry '${relPath}'.`); + + let expectation = expected.find( + it => it.relPath == relPath && (!("condition" in it) || it.condition) + ); + if (!expectation) { + info(`UNEXPECTED: Directory entry '${relPath}' is NOT expected!`); + unexpected.push(relPath); + } else if ( + typeLabel[!!expectation.isDirectory] != typeLabel[entry.isDirectory()] + ) { + info(`UNEXPECTED: Directory entry '${relPath}' is NOT expected type!`); + unexpected.push(relPath); + } + } + + Assert.deepEqual([], unexpected, "Expected nothing to report"); +} diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_profile_service_configuration.js b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_profile_service_configuration.js new file mode 100644 index 0000000000..7d8e161046 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_profile_service_configuration.js @@ -0,0 +1,64 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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/. */ + +setupProfileService(); + +async function doOne(extraArgs = [], extraEnv = {}) { + let sentinel = Services.uuid.generateUUID().toString(); + sentinel = sentinel.substring(1, sentinel.length - 1); + + let stdoutLines = []; + let exitCode = await do_backgroundtask("unique_profile", { + extraArgs: [sentinel, ...extraArgs], + extraEnv, + onStdoutLine: line => stdoutLines.push(line), + }); + Assert.equal(0, exitCode); + + let infos = []; + let profiles = []; + for (let line of stdoutLines) { + if (line.includes(sentinel)) { + let info = JSON.parse(line.split(sentinel)[1]); + infos.push(info); + profiles.push(info[1]); + } + } + + Assert.equal( + 1, + profiles.length, + `Found 1 profile: ${JSON.stringify(profiles)}` + ); + + return profiles[0]; +} + +// Run a background task which displays its profile directory, and verify that +// the "normal profile configuration" mechanisms override the background +// task-specific default. +add_task(async function test_backgroundtask_profile_service_configuration() { + let profileService = Cc["@mozilla.org/toolkit/profile-service;1"].getService( + Ci.nsIToolkitProfileService + ); + + let profile = profileService.createUniqueProfile( + do_get_profile(), + "test_locked_profile" + ); + let path = profile.rootDir.path; + + Assert.ok( + profile.rootDir.exists(), + `Temporary profile directory does exists: ${profile.rootDir}` + ); + + let profileDir = await doOne(["--profile", path]); + Assert.equal(profileDir, path); + + profileDir = await doOne([], { XRE_PROFILE_PATH: path }); + Assert.equal(profileDir, path); +}); diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_removeDirectory.js b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_removeDirectory.js new file mode 100644 index 0000000000..6fb1ee6f27 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_removeDirectory.js @@ -0,0 +1,245 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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 test exercises functionality and also ensures the exit codes, +// which are a public API, do not change over time. +const { EXIT_CODE } = ChromeUtils.importESModule( + "resource://gre/modules/BackgroundTasksManager.sys.mjs" +); + +const LEAF_NAME = "newCacheFolder"; + +add_task(async function test_simple() { + // This test creates a simple folder in the profile and passes it to + // the background task to delete + + let dir = do_get_profile(); + dir.append(LEAF_NAME); + dir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o744); + equal(dir.exists(), true); + + let extraDir = do_get_profile(); + extraDir.append("test.abc"); + extraDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o744); + equal(extraDir.exists(), true); + + let outputLines = []; + let exitCode = await do_backgroundtask("removeDirectory", { + extraArgs: [do_get_profile().path, LEAF_NAME, "10", ".abc"], + onStdoutLine: line => outputLines.push(line), + }); + equal(exitCode, EXIT_CODE.SUCCESS); + equal(dir.exists(), false); + equal(extraDir.exists(), false); + + if (AppConstants.platform !== "win") { + // Check specific logs because there can still be some logs in certain conditions, + // e.g. in code coverage (see bug 1831778 and bug 1804833) + ok( + outputLines.every(l => !l.includes("*** You are running in")), + "Should not have logs by default" + ); + } +}); + +add_task(async function test_no_extension() { + // Test that not passing the extension is fine + + let dir = do_get_profile(); + dir.append(LEAF_NAME); + dir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o744); + equal(dir.exists(), true); + + let exitCode = await do_backgroundtask("removeDirectory", { + extraArgs: [do_get_profile().path, LEAF_NAME, "10"], + }); + equal(exitCode, EXIT_CODE.SUCCESS); + equal(dir.exists(), false); +}); + +add_task(async function test_createAfter() { + // First we create the task then we create the directory. + // The task will only wait for 10 seconds. + let dir = do_get_profile(); + dir.append(LEAF_NAME); + + let task = do_backgroundtask("removeDirectory", { + extraArgs: [do_get_profile().path, LEAF_NAME, "10", ""], + }); + + dir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o744); + + let exitCode = await task; + equal(exitCode, EXIT_CODE.SUCCESS); + equal(dir.exists(), false); +}); + +add_task(async function test_folderNameWithSpaces() { + // We pass in a leaf name with spaces just to make sure the command line + // argument parsing works properly. + let dir = do_get_profile(); + let leafNameWithSpaces = `${LEAF_NAME} space`; + dir.append(leafNameWithSpaces); + + let task = do_backgroundtask("removeDirectory", { + extraArgs: [do_get_profile().path, leafNameWithSpaces, "10", ""], + }); + + dir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o744); + + let exitCode = await task; + equal(exitCode, EXIT_CODE.SUCCESS); + equal(dir.exists(), false); +}); + +add_task(async function test_missing_folder() { + // We never create the directory. + // We only wait for 1 second + + let dir = do_get_profile(); + dir.append(LEAF_NAME); + let exitCode = await do_backgroundtask("removeDirectory", { + extraArgs: [do_get_profile().path, LEAF_NAME, "1", ""], + }); + + equal(exitCode, EXIT_CODE.SUCCESS); + equal(dir.exists(), false); +}); + +add_task( + { skip_if: () => mozinfo.os != "win" }, + async function test_ro_file_in_folder() { + let dir = do_get_profile(); + dir.append(LEAF_NAME); + dir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o744); + equal(dir.exists(), true); + + let file = dir.clone(); + file.append("ro_file"); + file.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o644); + file.QueryInterface(Ci.nsILocalFileWin).readOnly = true; + + // Make sure that we can move with a readonly file in the directory + dir.moveTo(null, "newName"); + + // This will fail because of the readonly file. + let exitCode = await do_backgroundtask("removeDirectory", { + extraArgs: [do_get_profile().path, "newName", "1", ""], + }); + + equal(exitCode, EXIT_CODE.EXCEPTION); + + dir = do_get_profile(); + dir.append("newName"); + file = dir.clone(); + file.append("ro_file"); + equal(file.exists(), true); + + // Remove readonly attribute and try again. + // As a followup we might want to force an old cache dir to be deleted, even if it has readonly files in it. + file.QueryInterface(Ci.nsILocalFileWin).readOnly = false; + dir.remove(true); + equal(file.exists(), false); + equal(dir.exists(), false); + } +); + +add_task(async function test_purgeFile() { + // Test that only directories are purged by the background task + let file = do_get_profile(); + file.append(LEAF_NAME); + file.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o644); + equal(file.exists(), true); + + let exitCode = await do_backgroundtask("removeDirectory", { + extraArgs: [do_get_profile().path, LEAF_NAME, "2", ""], + }); + equal(exitCode, EXIT_CODE.EXCEPTION); + equal(file.exists(), true); + + file.remove(true); + let dir = file.clone(); + dir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o744); + equal(dir.exists(), true); + file = do_get_profile(); + file.append("test.abc"); + file.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o644); + equal(file.exists(), true); + let dir2 = do_get_profile(); + dir2.append("dir.abc"); + dir2.create(Ci.nsIFile.DIRECTORY_TYPE, 0o744); + equal(dir2.exists(), true); + + exitCode = await do_backgroundtask("removeDirectory", { + extraArgs: [do_get_profile().path, LEAF_NAME, "2", ".abc"], + }); + + equal(exitCode, EXIT_CODE.SUCCESS); + equal(dir.exists(), false); + equal(dir2.exists(), false); + equal(file.exists(), true); + file.remove(true); +}); + +add_task(async function test_two_tasks() { + let dir1 = do_get_profile(); + dir1.append("leaf1.abc"); + + let dir2 = do_get_profile(); + dir2.append("leaf2.abc"); + + let tasks = []; + tasks.push( + do_backgroundtask("removeDirectory", { + extraArgs: [do_get_profile().path, dir1.leafName, "5", ".abc"], + }) + ); + dir1.create(Ci.nsIFile.DIRECTORY_TYPE, 0o744); + tasks.push( + do_backgroundtask("removeDirectory", { + extraArgs: [do_get_profile().path, dir2.leafName, "5", ".abc"], + }) + ); + dir2.create(Ci.nsIFile.DIRECTORY_TYPE, 0o744); + + let [r1, r2] = await Promise.all(tasks); + + equal(r1, EXIT_CODE.SUCCESS); + equal(r2, EXIT_CODE.SUCCESS); + equal(dir1.exists(), false); + equal(dir2.exists(), false); +}); + +// This test creates a large number of tasks and directories. Spawning a huge +// number of tasks concurrently (say, 50 or 100) appears to cause problems; +// since doing so is rather artificial, we haven't tracked this down. +const TASK_COUNT = 20; +add_task(async function test_aLotOfTasks() { + let dirs = []; + let tasks = []; + + for (let i = 0; i < TASK_COUNT; i++) { + let dir = do_get_profile(); + dir.append(`leaf${i}.abc`); + + tasks.push( + do_backgroundtask("removeDirectory", { + extraArgs: [do_get_profile().path, dir.leafName, "5", ".abc"], + extraEnv: { MOZ_LOG: "BackgroundTasks:5" }, + }) + ); + + dir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o744); + dirs.push(dir); + } + + let results = await Promise.all(tasks); + for (let i in results) { + equal(results[i], EXIT_CODE.SUCCESS, `Task ${i} should succeed`); + equal(dirs[i].exists(), false, `leaf${i}.abc should not exist`); + } +}); diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_shouldprocessupdates.js b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_shouldprocessupdates.js new file mode 100644 index 0000000000..27dce8b99f --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_shouldprocessupdates.js @@ -0,0 +1,45 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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/. */ + +add_task(async function test_backgroundtask_shouldprocessupdates() { + // The task returns 81 if we should process updates, i.e., + // !ShouldNotProcessUpdates(), <= 80 otherwise. xpcshell itself counts as an + // instance, so the background task will see it and think another instance is + // running. N.b.: this isn't as robust as it could be: running Firefox + // instances and parallel tests interact here (mostly harmlessly). + // + // Since the behaviour under test (ShouldNotProcessUpdates()) happens at startup, + // we can't easily change the lock location of the background task. + + // `shouldprocessupdates` is an updating task, but there is another instance + // running, so we should not process updates. + let exitCode = await do_backgroundtask("shouldprocessupdates", { + extraArgs: ["--test-process-updates"], + }); + Assert.equal(80, exitCode); + + // If we change our lock location, the background task won't see our instance + // running. + let file = do_get_profile(); + file.append("customExePath"); + let syncManager = Cc["@mozilla.org/updates/update-sync-manager;1"].getService( + Ci.nsIUpdateSyncManager + ); + syncManager.resetLock(file); + + // Since we've changed the xpcshell executable name, the background task won't + // see us and think another instance is running. This time, there is no + // reason to not process updates. + exitCode = await do_backgroundtask("shouldprocessupdates"); + Assert.equal(81, exitCode); + + // `shouldnotprocessupdates` is not a recognized updating task, so we should + // not process updates. + exitCode = await do_backgroundtask("shouldnotprocessupdates", { + extraArgs: ["--test-process-updates"], + }); + Assert.equal(78, exitCode); +}); diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_simultaneous_instances.js b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_simultaneous_instances.js new file mode 100644 index 0000000000..acae920849 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_simultaneous_instances.js @@ -0,0 +1,100 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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/. */ + +// Display logging for ease of debugging. +let moz_log = "BackgroundTasks:5"; + +// Background task temporary profiles are created in the OS temporary directory. +// On Windows, we can put the temporary directory in a testing-specific location +// by setting TMP, per +// https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-gettemppathw. +// On Linux, this works as well: see +// [GetSpecialSystemDirectory](https://searchfox.org/mozilla-central/source/xpcom/io/SpecialSystemDirectory.cpp). +// On macOS, we can't set the temporary directory in this manner, so we will +// leak some temporary directories. It's important to test this functionality +// on macOS so we accept this. +let tmp = do_get_profile(); +tmp.append("Temp"); + +add_setup(() => { + if (Services.env.exists("MOZ_LOG")) { + moz_log += `,${Services.env.get("MOZ_LOG")}`; + } + + tmp.createUnique(Ci.nsIFile.DIRECTORY_TYPE, 0o700); +}); + +async function launch_one(index) { + let sentinel = Services.uuid.generateUUID().toString(); + sentinel = sentinel.substring(1, sentinel.length - 1); + + let stdoutLines = []; + let exitCode = await do_backgroundtask("unique_profile", { + extraArgs: [sentinel], + extraEnv: { + MOZ_BACKGROUNDTASKS_NO_REMOVE_PROFILE: "1", + MOZ_LOG: moz_log, + TMP: tmp.path, // Ignored on macOS. + }, + onStdoutLine: line => stdoutLines.push(line), + }); + + let profile; + for (let line of stdoutLines) { + if (line.includes(sentinel)) { + let info = JSON.parse(line.split(sentinel)[1]); + profile = info[1]; + } + } + + return { index, sentinel, exitCode, profile }; +} + +add_task(async function test_backgroundtask_simultaneous_instances() { + let expectedCount = 10; + let promises = []; + + for (let i = 0; i < expectedCount; i++) { + promises.push(launch_one(i)); + } + + let results = await Promise.all(promises); + + registerCleanupFunction(() => { + for (let result of results) { + if (!result.profile) { + return; + } + + let dir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + dir.initWithPath(result.profile); + try { + dir.remove(); + } catch (e) { + console.warn("Could not clean up profile directory", e); + } + } + }); + + for (let result of results) { + Assert.equal( + 0, + result.exitCode, + `Invocation ${result.index} with sentinel ${result.sentinel} exited with code 0` + ); + Assert.ok( + result.profile, + `Invocation ${result.index} with sentinel ${result.sentinel} yielded a temporary profile directory` + ); + } + + let profiles = new Set(results.map(it => it.profile)); + Assert.equal( + expectedCount, + profiles.size, + `Invocations used ${expectedCount} different temporary profile directories` + ); +}); diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_specific_pref.js b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_specific_pref.js new file mode 100644 index 0000000000..50f85b9bec --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_specific_pref.js @@ -0,0 +1,20 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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/. */ + +add_task(async function test_backgroundtask_specific_pref() { + // First, verify this pref isn't set in Gecko itself. + Assert.equal( + -1, + Services.prefs.getIntPref("test.backgroundtask_specific_pref.exitCode", -1) + ); + + // Second, verify that this pref is set in background tasks. + // xpcshell tests locally test unpackaged builds. + let exitCode = await do_backgroundtask("backgroundtask_specific_pref", { + extraArgs: ["test.backgroundtask_specific_pref.exitCode"], + }); + Assert.equal(79, exitCode); +}); diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_targeting.js b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_targeting.js new file mode 100644 index 0000000000..6b848ff3a7 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_targeting.js @@ -0,0 +1,17 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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"; + +const { EXIT_CODE } = ChromeUtils.importESModule( + "resource://gre/modules/BackgroundTasksManager.sys.mjs" +); + +add_task(async function test_targeting() { + // The task itself fails if any targeting getter fails. + let exitCode = await do_backgroundtask("targeting"); + Assert.equal(EXIT_CODE.SUCCESS, exitCode); +}); diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_unique_profile.js b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_unique_profile.js new file mode 100644 index 0000000000..6c17241932 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_unique_profile.js @@ -0,0 +1,39 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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/. */ + +// Run a background task which itself waits for a launched background task, which itself waits for a +// launched background task, etc. This is an easy way to ensure that tasks are running concurrently +// without requiring concurrency primitives. +add_task(async function test_backgroundtask_unique_profile() { + let sentinel = Services.uuid.generateUUID().toString(); + sentinel = sentinel.substring(1, sentinel.length - 1); + + let count = 3; + let stdoutLines = []; + let exitCode = await do_backgroundtask("unique_profile", { + extraArgs: [sentinel, count.toString()], + onStdoutLine: line => stdoutLines.push(line), + }); + Assert.equal(0, exitCode); + + let infos = []; + let profiles = new Set(); + for (let line of stdoutLines) { + if (line.includes(sentinel)) { + let info = JSON.parse(line.split(sentinel)[1]); + infos.push(info); + profiles.add(info[1]); + } + } + + Assert.equal( + count, + profiles.size, + `Found ${count} distinct profiles: ${JSON.stringify( + Array.from(profiles) + )} in: ${JSON.stringify(infos)}` + ); +}); diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_update_sync_manager.js b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_update_sync_manager.js new file mode 100644 index 0000000000..30009613d7 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtask_update_sync_manager.js @@ -0,0 +1,48 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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"; + +add_task(async function test_backgroundtask_update_sync_manager() { + // The task returns 80 if another instance is running, 81 otherwise. xpcshell + // itself counts as an instance, so the background task will see it and think + // another instance is running. + // + // This can also be achieved by overriding directory providers, but + // that's not particularly robust in the face of parallel testing. + // Doing it this way exercises `resetLock` with a path. + let exitCode = await do_backgroundtask("update_sync_manager", { + extraArgs: [Services.dirsvc.get("XREExeF", Ci.nsIFile).path], + }); + Assert.equal(80, exitCode, "Another instance is running"); + + // If we tell the backgroundtask to use a unique appPath, the + // background task won't see any other instances running. + let file = do_get_profile(); + file.append("customExePath"); + file.append("customExe"); + + exitCode = await do_backgroundtask("update_sync_manager", { + extraArgs: [file.path], + }); + Assert.equal(81, exitCode, "No other instance is running"); + + let upperCaseFile = Cc["@mozilla.org/file/local;1"].createInstance( + Ci.nsIFile + ); + upperCaseFile.initWithPath( + Services.dirsvc.get("XREExeF", Ci.nsIFile).path.toUpperCase() + ); + if (upperCaseFile.exists()) { + // The uppercased path can still be used to access the exe, indicating a + // case-insensitive filesystem (as is usual on Windows and macOS), so path + // normalization can be tested. + exitCode = await do_backgroundtask("update_sync_manager", { + extraArgs: [upperCaseFile.path], + }); + Assert.equal(80, exitCode, "Another instance is running"); + } +}); diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtasksutils.js b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtasksutils.js new file mode 100644 index 0000000000..f16766c23c --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/test_backgroundtasksutils.js @@ -0,0 +1,198 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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/. */ + +const { BackgroundTasksUtils } = ChromeUtils.importESModule( + "resource://gre/modules/BackgroundTasksUtils.sys.mjs" +); +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; +XPCOMUtils.defineLazyModuleGetters(lazy, { + ASRouterTargeting: "resource://activity-stream/lib/ASRouterTargeting.jsm", +}); + +setupProfileService(); + +add_task(async function test_withProfileLock() { + let profileService = Cc["@mozilla.org/toolkit/profile-service;1"].getService( + Ci.nsIToolkitProfileService + ); + + let profilePath = do_get_profile(); + profilePath.append(`test_withProfileLock`); + let profile = profileService.createUniqueProfile( + profilePath, + "test_withProfileLock" + ); + + await BackgroundTasksUtils.withProfileLock(async lock => { + BackgroundTasksUtils._throwIfNotLocked(lock); + }, profile); + + // In debug builds, an unlocked lock crashes via `NS_ERROR` when + // `.directory` is invoked. There's no way to check that the lock + // is unlocked, so we can't realistically test this scenario in + // debug builds. + if (!AppConstants.DEBUG) { + await Assert.rejects( + BackgroundTasksUtils.withProfileLock(async lock => { + lock.unlock(); + BackgroundTasksUtils._throwIfNotLocked(lock); + }, profile), + /Profile is not locked/ + ); + } +}); + +add_task(async function test_readPreferences() { + let profileService = Cc["@mozilla.org/toolkit/profile-service;1"].getService( + Ci.nsIToolkitProfileService + ); + + let profilePath = do_get_profile(); + profilePath.append(`test_readPreferences`); + let profile = profileService.createUniqueProfile( + profilePath, + "test_readPreferences" + ); + + // Before we write any preferences, we fail to read. + await Assert.rejects( + BackgroundTasksUtils.withProfileLock( + lock => BackgroundTasksUtils.readPreferences(null, lock), + profile + ), + /NotFoundError/ + ); + + let s = `user_pref("testPref.bool1", true); + user_pref("testPref.bool2", false); + user_pref("testPref.int1", 23); + user_pref("testPref.int2", -1236); + `; + + let prefsFile = profile.rootDir.clone(); + prefsFile.append("prefs.js"); + await IOUtils.writeUTF8(prefsFile.path, s); + + // Now we can read all the prefs. + let prefs = await BackgroundTasksUtils.withProfileLock( + lock => BackgroundTasksUtils.readPreferences(null, lock), + profile + ); + let expected = { + "testPref.bool1": true, + "testPref.bool2": false, + "testPref.int1": 23, + "testPref.int2": -1236, + }; + Assert.deepEqual(prefs, expected, "Prefs read are correct"); + + // And we can filter the read as well. + prefs = await BackgroundTasksUtils.withProfileLock( + lock => + BackgroundTasksUtils.readPreferences(name => name.endsWith("1"), lock), + profile + ); + expected = { + "testPref.bool1": true, + "testPref.int1": 23, + }; + Assert.deepEqual(prefs, expected, "Filtered prefs read are correct"); +}); + +add_task(async function test_readTelemetryClientID() { + let profileService = Cc["@mozilla.org/toolkit/profile-service;1"].getService( + Ci.nsIToolkitProfileService + ); + + let profilePath = do_get_profile(); + profilePath.append(`test_readTelemetryClientID`); + let profile = profileService.createUniqueProfile( + profilePath, + "test_readTelemetryClientID" + ); + + // Before we write any state, we fail to read. + await Assert.rejects( + BackgroundTasksUtils.withProfileLock( + lock => BackgroundTasksUtils.readTelemetryClientID(lock), + profile + ), + /NotFoundError/ + ); + + let expected = { + clientID: "9cc75672-6830-4cb6-a7fd-089d6c7ce34c", + ecosystemClientID: "752f9d53-73fc-4006-a93c-d3e2e427f238", + }; + let prefsFile = profile.rootDir.clone(); + prefsFile.append("datareporting"); + prefsFile.append("state.json"); + await IOUtils.writeUTF8(prefsFile.path, JSON.stringify(expected)); + + // Now we can read the state. + let state = await BackgroundTasksUtils.withProfileLock( + lock => BackgroundTasksUtils.readTelemetryClientID(lock), + profile + ); + Assert.deepEqual(state, expected.clientID, "State read is correct"); +}); + +add_task( + { + skip_if: () => AppConstants.MOZ_BUILD_APP !== "browser", // ASRouter is Firefox-only. + }, + async function test_readFirefoxMessagingSystemTargetingSnapshot() { + let profileService = Cc[ + "@mozilla.org/toolkit/profile-service;1" + ].getService(Ci.nsIToolkitProfileService); + + let profilePath = do_get_profile(); + profilePath.append(`test_readFirefoxMessagingSystemTargetingSnapshot`); + let profile = profileService.createUniqueProfile( + profilePath, + "test_readFirefoxMessagingSystemTargetingSnapshot" + ); + + // Before we write any state, we fail to read. + await Assert.rejects( + BackgroundTasksUtils.withProfileLock( + lock => + BackgroundTasksUtils.readFirefoxMessagingSystemTargetingSnapshot( + lock + ), + profile + ), + /NotFoundError/ + ); + + // We can't take a full environment snapshot under `xpcshell`. Select a few + // items that do work. + let target = { + // `Date` instances and strings do not `deepEqual` each other. + currentDate: JSON.stringify( + await lazy.ASRouterTargeting.Environment.currentDate + ), + firefoxVersion: lazy.ASRouterTargeting.Environment.firefoxVersion, + }; + let expected = await lazy.ASRouterTargeting.getEnvironmentSnapshot(target); + + let snapshotFile = profile.rootDir.clone(); + snapshotFile.append("targeting.snapshot.json"); + await IOUtils.writeUTF8(snapshotFile.path, JSON.stringify(expected)); + + // Now we can read the state. + let state = await BackgroundTasksUtils.withProfileLock( + lock => + BackgroundTasksUtils.readFirefoxMessagingSystemTargetingSnapshot(lock), + profile + ); + Assert.deepEqual(state, expected, "State read is correct"); + } +); diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/test_manifest_with_backgroundtask.js b/toolkit/components/backgroundtasks/tests/xpcshell/test_manifest_with_backgroundtask.js new file mode 100644 index 0000000000..f713fe5e78 --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/test_manifest_with_backgroundtask.js @@ -0,0 +1,50 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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/. */ + +add_task(async function test_manifest_with_backgroundtask() { + let bts = Cc["@mozilla.org/backgroundtasks;1"].getService( + Ci.nsIBackgroundTasks + ); + + Assert.equal(false, bts.isBackgroundTaskMode); + Assert.equal(null, bts.backgroundTaskName()); + + bts.overrideBackgroundTaskNameForTesting("test-task"); + + Assert.equal(true, bts.isBackgroundTaskMode); + Assert.equal("test-task", bts.backgroundTaskName()); + + // Load test components. + do_load_manifest("CatBackgroundTaskRegistrationComponents.manifest"); + + const expectedEntries = new Map([ + [ + "CatBackgroundTaskRegisteredComponent", + "@unit.test.com/cat-backgroundtask-registered-component;1", + ], + [ + "CatBackgroundTaskAlwaysRegisteredComponent", + "@unit.test.com/cat-backgroundtask-alwaysregistered-component;1", + ], + ]); + + // Verify the correct entries are registered in the "test-cat" category. + for (let { entry, value } of Services.catMan.enumerateCategory("test-cat")) { + ok(expectedEntries.has(entry), `${entry} is expected`); + Assert.equal( + value, + expectedEntries.get(entry), + "${entry} has correct value." + ); + expectedEntries.delete(entry); + } + print("Check that all of the expected entries have been deleted."); + Assert.deepEqual( + Array.from(expectedEntries.keys()), + [], + "All expected entries have been deleted." + ); +}); diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/test_manifest_without_backgroundtask.js b/toolkit/components/backgroundtasks/tests/xpcshell/test_manifest_without_backgroundtask.js new file mode 100644 index 0000000000..422d9a49ff --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/test_manifest_without_backgroundtask.js @@ -0,0 +1,38 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=4 ts=4 sts=4 et + * 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/. */ + +add_task(async function test_without_backgroundtask() { + let bts = Cc["@mozilla.org/backgroundtasks;1"].getService( + Ci.nsIBackgroundTasks + ); + bts.overrideBackgroundTaskNameForTesting(null); + + Assert.equal(false, bts.isBackgroundTaskMode); + Assert.equal(null, bts.backgroundTaskName()); + + // Load test components. + do_load_manifest("CatBackgroundTaskRegistrationComponents.manifest"); + + const EXPECTED_ENTRIES = new Map([ + ["CatRegisteredComponent", "@unit.test.com/cat-registered-component;1"], + [ + "CatBackgroundTaskNotRegisteredComponent", + "@unit.test.com/cat-backgroundtask-notregistered-component;1", + ], + ]); + + // Verify the correct entries are registered in the "test-cat" category. + for (let { entry, value } of Services.catMan.enumerateCategory("test-cat")) { + ok(EXPECTED_ENTRIES.has(entry), `${entry} is expected`); + Assert.equal(EXPECTED_ENTRIES.get(entry), value); + EXPECTED_ENTRIES.delete(entry); + } + Assert.deepEqual( + Array.from(EXPECTED_ENTRIES.keys()), + [], + "All expected entries have been deleted." + ); +}); diff --git a/toolkit/components/backgroundtasks/tests/xpcshell/xpcshell.ini b/toolkit/components/backgroundtasks/tests/xpcshell/xpcshell.ini new file mode 100644 index 0000000000..25878ce0fd --- /dev/null +++ b/toolkit/components/backgroundtasks/tests/xpcshell/xpcshell.ini @@ -0,0 +1,43 @@ +# 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/. + +[DEFAULT] +firefox-appdir = browser +skip-if = toolkit == 'android' +head = head.js +support-files = + CatBackgroundTaskRegistrationComponents.manifest + experiment.json + +[test_backgroundtask_deletes_profile.js] +[test_backgroundtask_exitcodes.js] +[test_backgroundtask_experiments.js] +tags = remote-settings +run-if = buildapp == "browser" +reason = "ASRouter is Firefox-only." +[test_backgroundtask_help.js] +[test_backgroundtask_localization.js] +[test_backgroundtask_locked_profile.js] +[test_backgroundtask_minruntime.js] +[test_backgroundtask_no_output.js] +skip-if = ccov +reason = Bug 1804825: code coverage harness prints [CodeCoverage] output in early startup. +[test_backgroundtask_not_ephemeral_profile.js] +run-sequentially = Uses global profile directory `DefProfRt` +[test_backgroundtask_policies.js] +[test_backgroundtask_profile_is_slim.js] +[test_backgroundtask_profile_service_configuration.js] +[test_backgroundtask_removeDirectory.js] +skip-if = os == "android" # MultiInstanceLock doesn't work on Android +[test_backgroundtask_shouldprocessupdates.js] +[test_backgroundtask_simultaneous_instances.js] +[test_backgroundtask_specific_pref.js] +[test_backgroundtask_targeting.js] +run-if = buildapp == "browser" +reason = "ASRouter is Firefox-only." +[test_backgroundtask_unique_profile.js] +[test_backgroundtask_update_sync_manager.js] +[test_backgroundtasksutils.js] +[test_manifest_with_backgroundtask.js] +[test_manifest_without_backgroundtask.js] |