commit ced62a04f4ed340ba1a7970ab2efac69fbd12cf6
parent b86e7a7cafacd7f5aae51876bc7d317ac68cade0
Author: Rob Wu <rob@robwu.nl>
Date: Fri, 2 Jan 2026 15:58:11 +0000
Bug 2006489 - Keep staged langpack despite other installation r=zombie
Differential Revision: https://phabricator.services.mozilla.com/D276856
Diffstat:
2 files changed, 278 insertions(+), 2 deletions(-)
diff --git a/toolkit/mozapps/extensions/internal/XPIInstall.sys.mjs b/toolkit/mozapps/extensions/internal/XPIInstall.sys.mjs
@@ -1468,9 +1468,18 @@ class AddonInstall {
);
this.removeTemporaryFile();
+ const stagedInstall = AppUpdate._stagedLangpacks.get(this.addon.id);
+ if (stagedInstall && stagedInstall !== this) {
+ // File path is owned by another AddonInstall (langpack). To avoid
+ // removing the wrong file or database entry, skip unstage.
+ logger.debug(`Skipping unstageInstall for obsolete AddonInstall`);
+ return;
+ }
+
let stagingDir = this.location.installer.getStagingDir();
let stagedAddon = stagingDir.clone();
+ // Note: unstageInstall is async!
this.unstageInstall(stagedAddon);
break;
}
@@ -2114,6 +2123,73 @@ class AddonInstall {
}
/**
+ * Rename the staged XPI associated with this install, to prevent another
+ * installation from overwriting it (see bug 2006489).
+ *
+ * @see restoreStagedInstall
+ */
+ backupStagedInstall() {
+ if (this._backupStagedAddon) {
+ logger.warn(
+ `Unexpected double attempt to back up staged langpack ${this.addon.id}`
+ );
+ return;
+ }
+ const stagedAddon = this.location.installer.getStagingDir();
+ stagedAddon.append(`${this.addon.id}.xpi`);
+ try {
+ // Rename file. This will be restored when the install completes. If for
+ // some reason the application crashes or quits before we get there, this
+ // file will be left behind.
+ //
+ // "~" as separator because it is never a part of an addon ID.
+ stagedAddon.moveTo(null, `${this.addon.id}~bak.xpi`);
+ this._backupStagedAddon = stagedAddon;
+ } catch (e) {
+ logger.warn(`Failed to rename staged langpack ${this.addon.id}`, e);
+ }
+ }
+
+ /**
+ * Undo the rename of backupStagedInstall(). This should be called when there
+ * are no other uses of the original file name.
+ *
+ * @see backupStagedInstall
+ * @see stageInstall
+ */
+ restoreStagedInstall() {
+ if (this._backupStagedAddon) {
+ try {
+ // Restore file and metadata, matching the logic from stageInstall().
+ this._backupStagedAddon.moveTo(null, `${this.addon.id}.xpi`);
+ this._backupStagedAddon = null;
+ this.location.stageAddon(this.addon.id, this.addon.toJSON());
+ } catch (e) {
+ logger.warn(`Failed to restore staged langpack ${this.addon.id}`, e);
+ this._backupStagedAddon = null;
+ }
+ }
+ }
+
+ /**
+ * Remove the staged file without restoring it. This should be called when
+ * the file has become obsolete, e.g. due to a newer version of the XPI.
+ *
+ * @see backupStagedInstall
+ * @see restoreStagedInstall
+ */
+ deleteBackupStagedInstall() {
+ if (this._backupStagedAddon) {
+ try {
+ this._backupStagedAddon.remove(false);
+ } catch (e) {
+ logger.warn(`Failed to delete staged langpack ${this.addon.id}`, e);
+ }
+ this._backupStagedAddon = null;
+ }
+ }
+
+ /**
* Postone a pending update, until restart or until the add-on resumes.
*
* @param {function} resumeFn
@@ -4073,10 +4149,59 @@ var AppUpdate = {
});
},
- stageInstall(installer) {
+ // Map from addon ID to AddonInstall of langpacks staged for next startup.
+ _stagedLangpacks: new Map(),
+ _conflictingInstalls: null,
+ _ensurePersistentStagedLangpack() {
+ // stageLangpacksForAppUpdate may stage a langpack for the next application
+ // update, because langpacks are compatible with specific major versions
+ // only. But if an update check finds another langpack compatible with the
+ // current version, the previously staged langpack would be overwritten,
+ // because AddonInstall instances with the same ID share the same file path.
+ // To avoid the loss of langpacks, temporarily rename the file, then restore
+ // it upon completion of the installation (bug 2006489).
+ if (this._conflictingInstalls) {
+ return;
+ }
+ this._conflictingInstalls = new Set();
+ const restoreStagedIfNeeded = install => {
+ if (this._conflictingInstalls.delete(install)) {
+ const stagedInstall = this._stagedLangpacks.get(install.addon.id);
+ stagedInstall.restoreStagedInstall();
+ }
+ };
+ const globalInstallListener = {
+ onInstallStarted: install => {
+ const stagedInstall = this._stagedLangpacks.get(install.addon.id);
+ if (stagedInstall && stagedInstall !== install) {
+ this._conflictingInstalls.add(install);
+ stagedInstall.backupStagedInstall();
+ }
+ },
+ onInstallCancelled: install => {
+ restoreStagedIfNeeded(install);
+ },
+ onInstallFailed: install => {
+ restoreStagedIfNeeded(install);
+ },
+ onInstallEnded: install => {
+ restoreStagedIfNeeded(install);
+ },
+ };
+ AddonManager.addInstallListener(globalInstallListener);
+ },
+
+ stageLangpackInstall(installer) {
return new Promise((resolve, reject) => {
let listener = {
onDownloadEnded: install => {
+ const stagedInstall = this._stagedLangpacks.get(install.addon.id);
+ if (stagedInstall) {
+ // Found a new version of a previously staged langpack. We don't
+ // care about the previous one any more.
+ stagedInstall.deleteBackupStagedInstall();
+ this._stagedLangpacks.delete(install.addon.id);
+ }
install.postpone();
},
onInstallFailed: install => {
@@ -4092,6 +4217,10 @@ var AppUpdate = {
onInstallPostponed: install => {
// At this point the addon is staged for restart.
install.removeListener(listener);
+
+ this._stagedLangpacks.set(install.addon.id, installFor(install));
+ this._ensurePersistentStagedLangpack();
+
resolve();
},
};
@@ -4112,7 +4241,7 @@ var AppUpdate = {
nextVersion,
nextPlatformVersion
)
- .then(update => update && this.stageInstall(update))
+ .then(update => update && this.stageLangpackInstall(update))
.catch(e => {
logger.debug(`addon.findUpdate error: ${e}`);
})
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_webextension_langpack.js b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_langpack.js
@@ -61,6 +61,17 @@ AddonTestUtils.registerJSON(server, "/test_update_langpack.json", {
},
},
},
+ {
+ version: "145.0.20251124.145406",
+ update_link:
+ "http://example.com/addons/latermajor/langpack-und@test.mozilla.org.xpi",
+ applications: {
+ gecko: {
+ strict_min_version: "145.0",
+ strict_max_version: "145.*",
+ },
+ },
+ },
],
},
},
@@ -191,6 +202,15 @@ langpack_update_dotrelease2["manifest.json"].browser_specific_settings.gecko = {
update_url: "http://example.com/test_update_langpack2.json",
};
+const langpack_update_later_major = structuredClone(ADDONS.langpack_1);
+langpack_update_later_major["manifest.json"].version = "145.0.20251124.145406";
+langpack_update_later_major["manifest.json"].browser_specific_settings.gecko = {
+ id: ID,
+ strict_min_version: "145.0",
+ strict_max_version: "145.*",
+ // no update_url, the test using it doesn't care about updating after that.
+};
+
let xpi = AddonTestUtils.createTempXPIFile(langpack_update);
server.registerFile(`/addons/${ID}.xpi`, xpi);
@@ -204,6 +224,11 @@ let xpiDotRelease2 = AddonTestUtils.createTempXPIFile(
);
server.registerFile(`/addons/dotrelease2/${ID}.xpi`, xpiDotRelease2);
+let xpiLaterMajor = AddonTestUtils.createTempXPIFile(
+ langpack_update_later_major
+);
+server.registerFile(`/addons/latermajor/${ID}.xpi`, xpiLaterMajor);
+
function promiseLangpackStartup() {
return new Promise(resolve => {
const EVENT = "webextension-langpack-startup";
@@ -598,6 +623,128 @@ add_task(async function test_staged_langpack_for_app_update_fail() {
});
/**
+ * This test verifies that the staged langpack update is available after
+ * updating the application, even if another langpack update happened in
+ * the meantime.
+ * Regression test for https://bugzilla.mozilla.org/show_bug.cgi?id=2006489
+ */
+add_task(async function test_staged_langpack_preserved_during_addon_update() {
+ let originalLocales = Services.locale.requestedLocales;
+
+ await promiseStartupManager("60");
+ let [, { addon }] = await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(langpack_update),
+ ]);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.version, "60.0.20230207.112555");
+ await promiseLocaleChanged(["und"]);
+
+ await AddonManager.stageLangpacksForAppUpdate("145");
+
+ {
+ // Between stage of langpack for 60 and application update to 145,
+ // we trigger an add-on update check, where we discover an updated
+ // langpack for the current application version (60). This should not
+ // affect the langpack staged for 145.
+ let update = await promiseFindAddonUpdates(addon);
+ Assert.ok(update.updateAvailable, "update is available");
+ await promiseCompleteInstall(update.updateAvailable);
+ addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "60.1.20230309.91233");
+ Assert.ok(addon.isActive);
+ }
+
+ await promiseRestartManager("145");
+
+ addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "145.0.20251124.145406");
+ Assert.ok(addon.isActive);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+ Services.locale.requestedLocales = originalLocales;
+});
+
+/**
+ * This test verifies that an attempt to stage another langpack, after having
+ * already staged one, will cause the last attempt to take precedence.
+ */
+add_task(async function test_staged_langpack_twice_without_restart() {
+ let originalLocales = Services.locale.requestedLocales;
+
+ await promiseStartupManager("58");
+ let [, { addon }] = await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.version, "58.0.20230105.121014");
+ await promiseLocaleChanged(["und"]);
+
+ info("Staging langpack for next version (60), without application restart");
+ const [[installFor60]] = await Promise.all([
+ AddonTestUtils.promiseInstallEvent("onInstallPostponed"),
+ AddonManager.stageLangpacksForAppUpdate("60"),
+ ]);
+ Assert.equal(installFor60.version, "60.1.20230309.91233");
+
+ info("Staging langpack for different version (145), will restart after.");
+ await AddonManager.stageLangpacksForAppUpdate("145");
+
+ info("cancel() on obsolete install (60) should not break install (145)");
+ Assert.equal(installFor60.state, AddonManager.STATE_POSTPONED);
+ // Sanity check: we are operating on an active install:
+ Assert.ok((await AddonManager.getAllInstalls()).includes(installFor60));
+ installFor60.cancel();
+
+ await promiseRestartManager("145");
+
+ addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "145.0.20251124.145406");
+ Assert.ok(addon.isActive);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+ Services.locale.requestedLocales = originalLocales;
+});
+
+/**
+ * Tests that canceling a staged langpack will prevent its installation at
+ * the next application startup.
+ */
+add_task(async function test_staged_langpack_cancel() {
+ let originalLocales = Services.locale.requestedLocales;
+
+ await promiseStartupManager("58");
+ let [, { addon }] = await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.version, "58.0.20230105.121014");
+ await promiseLocaleChanged(["und"]);
+
+ info("Staging langpack for next version (60), without application restart");
+ const [[installFor60]] = await Promise.all([
+ AddonTestUtils.promiseInstallEvent("onInstallPostponed"),
+ AddonManager.stageLangpacksForAppUpdate("60"),
+ ]);
+ Assert.equal(installFor60.version, "60.1.20230309.91233");
+ installFor60.cancel();
+
+ await promiseRestartManager("60");
+
+ addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "58.0.20230105.121014");
+ Assert.ok(!addon.isActive);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+ Services.locale.requestedLocales = originalLocales;
+});
+
+/**
* This test verifies that an update restart works when the langpack
* cannot be updated.
*/