ckgroundtasks/BackgroundTask_removeDirectory.sys.mjs/* 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 (
      otherFoldersSuffix !== "*" &&
      !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
//                      As a special command, "*" will remove all subdirectories.
// 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;
}
PK