tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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:
Mtoolkit/mozapps/extensions/internal/XPIInstall.sys.mjs | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtoolkit/mozapps/extensions/test/xpcshell/test_webextension_langpack.js | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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. */