1
0
Fork 0
firefox/toolkit/components/translations/content/translations-engine.sys.mjs
Daniel Baumann 5e9a113729
Adding upstream version 140.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
2025-06-25 09:37:52 +02:00

768 lines
25 KiB
JavaScript

/* 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 lives in the translation engine's process and is in charge of managing the
* lifecycle of the translations engines. This process is a singleton Web Content
* process that can be created and destroyed as needed.
*
* The goal of the code in this file is to be as unprivileged as possible, which should
* unlock Bug 1813789, which will make this file fully unprivileged.
*
* Each translation needs an engine for that specific language pair. This engine is
* kept around as long as the CACHE_TIMEOUT_MS, after this if some keepAlive event does
* not happen, the engine is destroyed. An engine may be destroyed even when a page is
* still open and may need translations in the future. This is handled gracefully by
* creating new engines and MessagePorts on the fly.
*
* The engine communicates directly with the content page via a MessagePort. Each end
* of the port is transferred from the parent process to the content process, and this
* engine process. This port is transitory, and may be closed at any time. Only when a
* translation has been requested once (which is initiated by the parent process) can
* the content process re-request translation ports. This ensures a rogue content process
* only has the capabilities to perform tasks that the parent process has given it.
*
* The messaging flow can get a little convoluted to handle all of the correctness cases,
* but ideally communication passes through the message port as much as possible. There
* are many scenarios such as:
*
* - Translation pages becoming idle
* - Tab changing causing "pageshow" and "pagehide" visibility changes
* - Translation actor destruction (this can happen long after the page has been
* navigated away from, but is still alive in the
* page history)
* - Error states
* - Engine Process being graceful shut down (no engines left)
* - Engine Process being killed by the OS.
*
* The following is a diagram that attempts to illustrate the structure of the processes
* and the communication channels that exist between them.
*
* ┌─────────────────────────────────────────────────────────────┐
* │ PARENT PROCESS │
* │ │
* │ [TranslationsParent] ←────→ [TranslationsEngineParent] │
* │ ↑ ↑ │
* └──────────────────│────────────────────────────────────│─────┘
* │ JSWindowActor IPC calls │ JSProcessActor IPC calls
* │ │
* ┌──────────────────│────────┐ ┌─────│─────────────────────────────┐
* │ CONTENT PROCESS │ │ │ │ ENGINE PROCESS │
* │ │ │ │ ↓ │
* │ [french.html] │ │ │ [TranslationsEngineChild] │
* │ ↕ ↓ │ │ ↕ │
* │ [TranslationsChild] │ │ [translations-engine.sys.mjs] │
* │ └──TranslationsDocument │ │ ├── "fr to en" engine │
* │ └──port1 « ═══════════ MessageChannel ════ » │ └── port2 │
* │ │ │ └── "de to en" engine (idle) │
* └───────────────────────────┘ └───────────────────────────────────┘
*/
// FIXME: Currently, `translations-engine.sys.mjs` is loaded with the system
// principal within the sys.mjs context.
//
// There is some existing code which exported these methods in a global scope
// from when this file was being loaded within a chrome .html document within
// the content process, however this code no longer exists.
//
// This block re-exports various methods from the singleton TranslationsEngine
// actor into this scope so they can be called as they were called before the
// change to use a ProcessActor.
//
// In the future, this code could perhaps be modified to run within an
// unprivileged Cu.Sandbox, with these specific methods re-exported into the
// sandbox scope.
const engineActor = ChromeUtils.domProcessChild.getActor("TranslationsEngine");
const TE_addProfilerMarker = engineActor.TE_addProfilerMarker.bind(engineActor);
const TE_getLogLevel = engineActor.TE_getLogLevel.bind(engineActor);
const TE_log = engineActor.TE_log.bind(engineActor);
const TE_logError = engineActor.TE_logError.bind(engineActor);
const TE_requestEnginePayload =
engineActor.TE_requestEnginePayload.bind(engineActor);
const TE_reportEnginePerformance =
engineActor.TE_reportEnginePerformance.bind(engineActor);
const TE_reportEngineStatus =
engineActor.TE_reportEngineStatus.bind(engineActor);
const TE_resolveForceShutdown =
engineActor.TE_resolveForceShutdown.bind(engineActor);
const TE_destroyEngineProcess =
engineActor.TE_destroyEngineProcess.bind(engineActor);
// How long the cache remains alive between uses, in milliseconds. In automation the
// engine is manually created and destroyed to avoid timing issues.
const CACHE_TIMEOUT_MS = 15_000;
/**
* @typedef {import("./translations-document.sys.mjs").TranslationsDocument} TranslationsDocument
* @typedef {import("../translations.js").TranslationsEnginePayload} TranslationsEnginePayload
* @typedef {import("../translations.js").LanguagePair} LanguagePair
*/
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
clearTimeout: "resource://gre/modules/Timer.sys.mjs",
setTimeout: "resource://gre/modules/Timer.sys.mjs",
TranslationsUtils:
"chrome://global/content/translations/TranslationsUtils.mjs",
});
/**
* The TranslationsEngine encapsulates the logic for translating messages. It can
* only be set up for a single language pair. In order to change languages
* a new engine should be constructed.
*
* The actual work for the translations happens in a worker. This class manages
* instantiating and messaging the worker.
*
* Keep unused engines around in the TranslationsEngine.#cachedEngine cache in case
* page navigation happens and we can re-use previous engines. The engines are very
* heavy-weight, so get rid of them after a timeout. Once all are destroyed the
* TranslationsEngineParent is notified that it can be destroyed.
*/
export class TranslationsEngine {
/**
* Maps a language pair key to a cached engine. Engines are kept around for a timeout
* before they are removed so that they can be re-used during navigation.
*
* @type {Map<string, Promise<TranslationsEngine>>}
*/
static #cachedEngines = new Map();
/**
* A DOMParser instance used for parsing HTML strings into DOM objects.
*
* @type {DOMParser}
*/
static #domParser = new DOMParser();
/**
* The ID of a timer that keeps the engine alive in the cache.
*
* @see {#cachedEngines}
*
* @type {TimeoutID | null}
*/
#keepAliveTimeout = null;
/**
* The Web Worker instance used to handle translation requests.
*
* @type {Worker}
*/
#worker;
/**
* Multiple messages can be sent before a response is received. This ID is used to keep
* track of the messages. It is incremented on every use.
*
* @type {number}
*/
#messageId = 0;
/**
* The total count of completed translation requests.
*
* @type {number}
*/
#totalCompletedRequests = 0;
/**
* The total count of words translated across all requests.
*
* @type {number}
*/
#totalTranslatedWords = 0;
/**
* The total milliseconds spent in active translation inference.
*
* @type {number}
*/
#totalInferenceMilliseconds = 0;
/**
* A word segmenter instance corresponding to the language of the source text.
*
* @type {Intl.Segmenter | null}
*/
#wordSegmenter = null;
/**
* Returns a getter function that will create a translations engine on the first
* call, and then return the cached one. After a timeout when the engine hasn't
* been used, it is destroyed.
*
* @param {LanguagePair} languagePair
* @param {number} innerWindowId
* @returns {Promise<TranslationsEngine>}
*/
static getOrCreate(languagePair, innerWindowId) {
const languagePairKey =
lazy.TranslationsUtils.serializeLanguagePair(languagePair);
let enginePromise = TranslationsEngine.#cachedEngines.get(languagePairKey);
if (enginePromise) {
return enginePromise;
}
TE_log(`Creating a new engine for "${languagePairKey}".`);
// A new engine needs to be created.
enginePromise = TranslationsEngine.create(languagePair, innerWindowId);
TranslationsEngine.#cachedEngines.set(languagePairKey, enginePromise);
enginePromise.catch(error => {
TE_logError(
`The engine failed to load for translating "${languagePairKey}". Removing it from the cache.`,
error
);
// Remove the engine if it fails to initialize.
TranslationsEngine.#removeEngineFromCache(languagePairKey);
});
return enginePromise;
}
/**
* Removes the engine, and if it's the last, call the process to destroy itself.
*
* @param {string} languagePairKey
* @param {boolean} force - On forced shutdowns, it's not necessary to notify the
* parent process.
*/
static #removeEngineFromCache(languagePairKey, force) {
TranslationsEngine.#cachedEngines.delete(languagePairKey);
if (TranslationsEngine.#cachedEngines.size === 0 && !force) {
TE_log("The last engine was removed, destroying this process.");
TE_destroyEngineProcess();
}
}
/**
* Create a TranslationsEngine and bypass the cache.
*
* @param {LanguagePair} languagePair
* @param {number} innerWindowId
* @returns {Promise<TranslationsEngine>}
*/
static async create(languagePair, innerWindowId) {
const startTime = Cu.now();
if (!languagePair.sourceLanguage || !languagePair.targetLanguage) {
throw new Error(
"Attempt to create Translator with missing language tags."
);
}
const engine = new TranslationsEngine(
languagePair,
await TE_requestEnginePayload(languagePair)
);
await engine.isReady;
TE_addProfilerMarker({
startTime,
message: `Translations engine loaded for "${lazy.TranslationsUtils.serializeLanguagePair(languagePair)}"`,
innerWindowId,
});
return engine;
}
/**
* Signal to the engines that they are being forced to shutdown.
*/
static forceShutdown() {
return Promise.allSettled(
[...TranslationsEngine.#cachedEngines].map(
async ([langPair, enginePromise]) => {
TE_log(`Force shutdown of the engine "${langPair}"`);
const engine = await enginePromise;
engine.terminate(true /* force */);
}
)
);
}
/**
* Terminates the engine and its worker after a timeout.
*
* @param {boolean} force
*/
terminate = (force = false) => {
const message = `Terminating translations engine "${this.languagePairKey}".`;
this.#maybeReportEnginePerformance();
TE_addProfilerMarker({ message });
TE_log(message);
this.#worker.terminate();
this.#worker = null;
if (this.#keepAliveTimeout) {
lazy.clearTimeout(this.#keepAliveTimeout);
}
for (const [innerWindowId, data] of ports) {
const { sourceLanguage, targetLanguage, port } = data;
if (
sourceLanguage === this.sourceLanguage &&
targetLanguage === this.targetLanguage
) {
// This port is still active but being closed.
ports.delete(innerWindowId);
port.postMessage({ type: "TranslationsPort:EngineTerminated" });
port.close();
}
}
TranslationsEngine.#removeEngineFromCache(this.languagePairKey, force);
};
/**
* The worker needs to be shutdown after some amount of time of not being used.
*/
keepAlive() {
if (this.#keepAliveTimeout) {
// Clear any previous timeout.
lazy.clearTimeout(this.#keepAliveTimeout);
}
// In automated tests, the engine is manually destroyed.
if (!Cu.isInAutomation) {
this.#keepAliveTimeout = lazy.setTimeout(
this.terminate,
CACHE_TIMEOUT_MS
);
}
}
/**
* Reports this engine's performance metrics to telemetry if it
* has completed at least one successful translation request.
*/
#maybeReportEnginePerformance() {
if (!this.#totalCompletedRequests) {
// This engine did not translate any requests to completion.
// There is nothing to report.
return;
}
const { sourceLanguage, targetLanguage } = this.languagePair;
TE_reportEnginePerformance({
sourceLanguage,
targetLanguage,
totalInferenceSeconds: this.#totalInferenceMilliseconds / 1000,
totalTranslatedWords: this.#totalTranslatedWords,
totalCompletedRequests: this.#totalCompletedRequests,
});
}
/**
* Construct and initialize the worker.
*
* @param {LanguagePair} languagePair
* @param {TranslationsEnginePayload} enginePayload - If there is no engine payload
* then the engine will be mocked. This allows this class to be used in tests.
*/
constructor(languagePair, enginePayload) {
/** @type {LanguagePair} */
this.languagePair = languagePair;
this.languagePairKey =
lazy.TranslationsUtils.serializeLanguagePair(languagePair);
this.#worker = new Worker(
"chrome://global/content/translations/translations-engine.worker.js"
);
/** @type {Promise<void>} */
this.isReady = new Promise((resolve, reject) => {
const onMessage = ({ data }) => {
TE_log("Received initialization message", data);
if (data.type === "initialization-success") {
resolve();
} else if (data.type === "initialization-error") {
reject(data.error);
}
this.#worker.removeEventListener("message", onMessage);
};
this.#worker.addEventListener("message", onMessage);
try {
this.#wordSegmenter = new Intl.Segmenter(this.sourceLanguage, {
granularity: "word",
});
} catch (error) {
reject(error);
}
// Schedule the first timeout for keeping the engine alive.
this.keepAlive();
});
// Make sure the ArrayBuffers are transferred, not cloned.
// https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects
const transferables = [];
if (enginePayload) {
transferables.push(enginePayload.bergamotWasmArrayBuffer);
for (const translationModelPayload of enginePayload.translationModelPayloads) {
const { languageModelFiles } = translationModelPayload;
for (const { buffer } of Object.values(languageModelFiles)) {
transferables.push(buffer);
}
}
}
const { sourceLanguage, targetLanguage } = languagePair;
this.#worker.postMessage(
{
type: "initialize",
sourceLanguage,
targetLanguage,
enginePayload,
messageId: this.#messageId++,
logLevel: TE_getLogLevel(),
},
transferables
);
}
/**
* Counts the number of words in the given source text.
*
* @param {string} sourceText - The text to be counted.
* @param {boolean} isHTML - Whether to parse the text as HTML.
* @returns {number} - The total count of word-like segments in the text.
*/
#countWords(sourceText, isHTML) {
if (isHTML) {
sourceText = TranslationsEngine.#domParser.parseFromString(
sourceText,
"text/html"
).documentElement.textContent;
}
let wordCount = 0;
for (const { isWordLike } of this.#wordSegmenter.segment(sourceText)) {
if (isWordLike) {
wordCount += 1;
}
}
return wordCount;
}
/**
* The implementation for translation. Use translateText or translateHTML for the
* public API.
*
* @param {string} sourceText
* @param {boolean} isHTML
* @param {number} innerWindowId
* @param {number} translationId
* @returns {Promise<string>}
* A promise that resolves with the translated text.
*/
translate(sourceText, isHTML, innerWindowId, translationId) {
this.keepAlive();
const messageId = this.#messageId++;
return new Promise((resolve, reject) => {
const onMessage = ({ data }) => {
if (
data.type === "translations-discarded" &&
data.innerWindowId === innerWindowId
) {
// The page was unloaded, and we no longer need to listen for a response.
this.#worker.removeEventListener("message", onMessage);
return;
}
if (data.messageId !== messageId) {
// Multiple translation requests can be sent before a response is received.
// Ensure that the response received here is the correct one.
return;
}
if (data.type === "translation-response") {
// Also keep the translation alive after getting a result, as many translations
// can queue up at once, and then it can take minutes to resolve them all.
this.keepAlive();
const { targetText, inferenceMilliseconds } = data;
resolve(targetText);
const sourceTextWordCount = this.#countWords(sourceText, isHTML);
this.#totalInferenceMilliseconds += inferenceMilliseconds;
this.#totalTranslatedWords += sourceTextWordCount;
this.#totalCompletedRequests += 1;
}
if (data.type === "translation-error") {
reject(data.error);
}
this.#worker.removeEventListener("message", onMessage);
};
this.#worker.addEventListener("message", onMessage);
this.#worker.postMessage({
type: "translation-request",
isHTML,
sourceText,
messageId,
translationId,
innerWindowId,
});
});
}
/**
* Applies a function only if a cached engine exists.
*
* @param {LanguagePair} languagePair
* @param {(engine: TranslationsEngine) => void} fn
*/
static withCachedEngine(languagePair, fn) {
const engine = TranslationsEngine.#cachedEngines.get(
lazy.TranslationsUtils.serializeLanguagePair(languagePair)
);
if (engine) {
engine.then(fn).catch(() => {});
}
}
/**
* Stop processing the translation queue. All in-progress messages will be discarded.
*
* @param {number} innerWindowId
*/
discardTranslationQueue(innerWindowId) {
this.#worker.postMessage({
type: "discard-translation-queue",
innerWindowId,
});
}
/**
* Cancel a single translation.
*
* @param {number} innerWindowId
* @param {id} translationId
*/
cancelSingleTranslation(innerWindowId, translationId) {
this.#worker.postMessage({
type: "cancel-single-translation",
innerWindowId,
translationId,
});
}
}
/**
* Maps the innerWindowId to the port.
*
* @type {Map<number, {
* languagePair: LanguagePair,
* port: MessagePort
* }>}
*/
const ports = new Map();
/**
* Listen to the port to the content process for incoming messages, and pass
* them to the TranslationsEngine manager. The other end of the port is held
* in the content process by the TranslationsDocument.
*
* @param {LanguagePair} languagePair
* @param {number} innerWindowId
* @param {MessagePort} port
*/
function listenForPortMessages(languagePair, innerWindowId, port) {
async function handleMessage({ data }) {
switch (data.type) {
case "TranslationsPort:GetEngineStatusRequest": {
// This message gets sent first before the translation queue is processed.
// The engine is most likely to fail on the initial invocation. Any failure
// past the first one is not reported to the UI.
TranslationsEngine.getOrCreate(languagePair, innerWindowId).then(
() => {
TE_log("The engine is ready for translations.", {
innerWindowId,
});
TE_reportEngineStatus(innerWindowId, "ready");
port.postMessage({
type: "TranslationsPort:GetEngineStatusResponse",
status: "ready",
});
},
error => {
console.error(error);
TE_reportEngineStatus(innerWindowId, "error");
port.postMessage({
type: "TranslationsPort:GetEngineStatusResponse",
status: "error",
error: String(error),
});
// After an error no more translation requests will be sent. Go ahead
// and close the port.
port.close();
ports.delete(innerWindowId);
}
);
break;
}
case "TranslationsPort:Passthrough": {
const { translationId } = data;
port.postMessage({
type: "TranslationsPort:TranslationResponse",
translationId,
targetText: null,
});
TE_addProfilerMarker({
innerWindowId,
type: "Passthrough",
message: `Handled passthrough translation`,
});
break;
}
case "TranslationsPort:CachedTranslation": {
const { cachedTranslation, translationId } = data;
port.postMessage({
type: "TranslationsPort:TranslationResponse",
translationId,
targetText: cachedTranslation,
});
TE_addProfilerMarker({
innerWindowId,
type: "Cached",
message: `Handled cached translation of ${cachedTranslation.length} code units`,
});
break;
}
case "TranslationsPort:TranslationRequest": {
const { sourceText, isHTML, translationId } = data;
const engine = await TranslationsEngine.getOrCreate(
languagePair,
innerWindowId
);
TE_addProfilerMarker({
innerWindowId,
type: "Request",
message: `Handled translation request of ${sourceText.length} code units`,
});
const targetText = await engine.translate(
sourceText,
isHTML,
innerWindowId,
translationId
);
port.postMessage({
type: "TranslationsPort:TranslationResponse",
translationId,
targetText,
});
break;
}
case "TranslationsPort:CancelSingleTranslation": {
const { translationId } = data;
TranslationsEngine.withCachedEngine(languagePair, engine => {
engine.cancelSingleTranslation(innerWindowId, translationId);
});
TE_addProfilerMarker({
innerWindowId,
type: "Cancel",
message: `Cancelled request for translationId ${translationId}`,
});
break;
}
case "TranslationsPort:DiscardTranslations": {
discardTranslations(innerWindowId);
TE_addProfilerMarker({
innerWindowId,
type: "Discard",
message: `Discarded all active translation requests`,
});
break;
}
default:
TE_logError("Unknown translations port message: " + data.type);
break;
}
}
if (port.onmessage) {
TE_logError(
new Error("The MessagePort onmessage handler was already present.")
);
}
port.onmessage = event => {
handleMessage(event).catch(error => TE_logError(error));
};
}
/**
* Discards the queue and removes the port.
*
* @param {number} innerWindowId
*/
function discardTranslations(innerWindowId) {
TE_log("Discarding translations, innerWindowId:", innerWindowId);
const portData = ports.get(innerWindowId);
if (portData) {
const { port, languagePair } = portData;
port.close();
ports.delete(innerWindowId);
TranslationsEngine.withCachedEngine(languagePair, engine => {
engine.discardTranslationQueue(innerWindowId);
});
}
}
/**
* Listen for events coming from the TranslationsEngine actor.
*/
export function handleActorMessage(data) {
switch (data.type) {
case "StartTranslation": {
const { languagePair, innerWindowId, port } = data;
TE_log(
"Starting translation",
lazy.TranslationsUtils.serializeLanguagePair(languagePair),
innerWindowId
);
listenForPortMessages(languagePair, innerWindowId, port);
ports.set(innerWindowId, { port, languagePair });
break;
}
case "DiscardTranslations": {
const { innerWindowId } = data;
discardTranslations(innerWindowId);
break;
}
case "ForceShutdown": {
TranslationsEngine.forceShutdown().then(() => {
TE_resolveForceShutdown();
});
break;
}
default:
throw new Error("Unknown TranslationsEngineChromeToContent event.");
}
}