Files
tubestation/browser/components/urlbar/tests/quicksuggest/RemoteSettingsServer.sys.mjs
Drew Willcoxon 1817ce0847 Bug 1952588 - Vendor application-services to 138 for Suggest geo expansion. r=bdk,daisuke
This vendors the `desktop-138` branch [1] of application-services. The `main`
branch currently does not have some PRs that desktop needs because they break
mobile, and we need this on desktop ASAP.

This patch is larger than usual because the vendor includes some major changes
to the application-services `suggest` component, including new and changed API.
As a consequence this patch includes the following important changes:

## New `suggest` API & uniffi

`SuggestStoreBuilder::remote_settings_service` and `RemoteSettingsService::new`
are exposed to JS as synchronous functions. There's no need for them to be
off-main-thread.

## Telemetry

The labels of `suggest.ingest_time`, `ingest_download_time` and `query_time` had
to be updated due to changes in the Rust component. These are minor updates that
don't need a data review.

## Urlbar

I had to make the following changes to urlbar. I tried to keep them to a minimum
for now. There are opportunities for improvements in follow-ups.

* Appropriate minimal integration changes to `SuggestBackendRust` for creating
  the `SuggestStore` and setting up the RS config
* The Rust component uses new RS collections, which breaks tests. I tried to fix
  them without touching too many lines. There are definitely opportunities to
  improve these tests and test helpers that I'd like to come back to.
* A fix to `RemoteSettingsServer` that's required due to the new RS client used
  by the Rust component

## Late writes due to the new RS client & `AsyncShutdown`

This is a urlbar change but it's worth calling out separately. I pushed all
these changes to tryserver, and there was a failure at the end of the browser
Suggest tests due to `LateWriteObserver` [2].

The late write happens when the app exits: `SuggestStore` is dropped, which
causes the new app-services RS client to drop its Sqlite connection, which
causes Sqlite to sync the RS client's DB to disk. This hasn't been a problem
before because `suggest` currently uses the old RS client, which doesn't keep a
DB. (`suggest` does have its own separate Sqlite DB, and I didn't investigate
why this isn't a problem for it, mainly because it makes sense that the new RS
client would sync its DB when it's dropped and that might be considered a "late
write" when it happens on app shutdown.)

According to the stack in the log, `SuggestStore` is dropped by
`nsCycleCollector`. I can't see how `SuggestBackendRust.#store` is involved in a
cycle, and I don't know if something in this patch is causing a cycle where
there wasn't one before. Maybe there always was. And I don't know if the cycle
is what's causing the all this to happen too late on shutdown. Maybe it's
unrelated. (I'll paste the stack in a Phabricator comment.)

The `SuggestStore` is definitely kept alive until
`AsyncShutdown.profileBeforeChange` since we have a barrier for that phase that
calls `interrupt()` on it. Maybe that's simply the problem and we're using a
phase that's too late in shutdown. But again I don't know why it wouldn't also
be a problem for Suggest's own DB.

The only fix I found is to replace `AsyncShutdown.profileBeforeChange` with
either `quitApplicationGranted` or `profileChangeTeardown`, and then null out
`#store` in the callback (after we call `interrupt()` on it). I assume that
fixes it because `profileBeforeChange` runs later than those other two phases.

So I replaced `profileBeforeChange` with `profileChangeTeardown`. I don't know
which of `quitApplicationGranted` or `profileChangeTeardown` is better. I think
it probably doesn't matter. I chose `profileChangeTeardown` because it's closer
to `profileBeforeChange`. (The order is: `quitApplicationGranted`,
`profileChangeTeardown`, `profileBeforeChange`.)

[1] https://github.com/mozilla/application-services/tree/desktop-138
[2] https://treeherder.mozilla.org/jobs?repo=try&revision=1639f87aa46f1afaf50901d80c8282861700019b

Differential Revision: https://phabricator.services.mozilla.com/D240919
2025-03-12 20:20:09 +00:00

643 lines
19 KiB
JavaScript

/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
/* eslint-disable jsdoc/require-param-description */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
HttpError: "resource://testing-common/httpd.sys.mjs",
HttpServer: "resource://testing-common/httpd.sys.mjs",
HTTP_404: "resource://testing-common/httpd.sys.mjs",
});
const SERVER_PREF = "services.settings.server";
/**
* A remote settings server. Tested with the desktop and Rust remote settings
* clients.
*/
export class RemoteSettingsServer {
/**
* The server must be started by calling `start()`.
*
* @param {object} options
* @param {number} options.maxLogLevel
* A log level value as defined by ConsoleInstance. `Info` logs server start
* and stop. `Debug` logs requests, responses, and added and removed
* records.
*/
constructor({ maxLogLevel = "Info" } = {}) {
this.#log = console.createInstance({
prefix: "RemoteSettingsServer",
maxLogLevel,
});
}
/**
* @returns {URL}
* The server's URL. Null when the server is stopped.
*/
get url() {
return this.#url;
}
/**
* Starts the server and sets the `services.settings.server` pref to its
* URL. The server's `url` property will be non-null on return.
*/
async start() {
this.#log.info("Starting");
if (this.#url) {
this.#log.info("Already started at " + this.#url);
return;
}
if (!this.#server) {
this.#server = new lazy.HttpServer();
this.#server.registerPrefixHandler("/", this);
}
this.#server.start(-1);
this.#url = new URL("http://localhost/v1");
this.#url.port = this.#server.identity.primaryPort;
this.#originalServerPrefValue = Services.prefs.getCharPref(
SERVER_PREF,
null
);
Services.prefs.setCharPref(SERVER_PREF, this.#url.toString());
this.#log.info("Server is now started at " + this.#url);
}
/**
* Stops the server and clears the `services.settings.server` pref. The
* server's `url` property will be null on return.
*/
async stop() {
this.#log.info("Stopping");
if (!this.#url) {
this.#log.info("Already stopped");
return;
}
await this.#server.stop();
this.#url = null;
if (this.#originalServerPrefValue === null) {
Services.prefs.clearUserPref(SERVER_PREF);
} else {
Services.prefs.setCharPref(SERVER_PREF, this.#originalServerPrefValue);
}
this.#log.info("Server is now stopped");
}
/**
* Adds remote settings records to the server. Records may have attachments;
* see the param doc below.
*
* @param {object} options
* @param {string} options.bucket
* @param {string} options.collection
* @param {Array} options.records
* Each object in this array should be a realistic remote settings record
* with the following exceptions:
*
* - `record.id` will be generated if it's undefined.
* - `record.last_modified` will be set to the `#lastModified` property of
* the server if it's undefined.
* - `record.attachment`, if defined, should be the attachment itself and
* not its metadata. The server will automatically create some dummy
* metadata. Currently the only supported attachment type is plain
* JSON'able objects that the server will convert to JSON in responses.
*/
async addRecords({ bucket = "main", collection = "test", records }) {
this.#log.debug("Adding records:", { bucket, collection, records });
this.#lastModified++;
let key = this.#recordsKey(bucket, collection);
let allRecords = this.#records.get(key);
if (!allRecords) {
allRecords = [];
this.#records.set(key, allRecords);
}
for (let record of records) {
let copy = { ...record };
if (!copy.hasOwnProperty("id")) {
copy.id = String(this.#nextRecordId++);
}
if (!copy.hasOwnProperty("last_modified")) {
copy.last_modified = this.#lastModified;
}
if (copy.attachment) {
await this.#addAttachment({ bucket, collection, record: copy });
}
allRecords.push(copy);
}
this.#log.debug("Done adding records. All records are now:", [
...this.#records.entries(),
]);
}
/**
* Marks records as deleted. Deleted records will still be returned in
* responses, but they'll have a `deleted = true` property. Their attachments
* will be deleted immediately, however.
*
* @param {object} filter
* If null, all records will be marked as deleted. Otherwise only records
* that match the filter will be marked as deleted. For a given record, each
* value in the filter object will be compared to the value with the same
* key in the record. If all values are the same, the record will be
* removed. Examples:
*
* To remove remove records whose `type` key has the value "data":
* `{ type: "data" }
*
* To remove remove records whose `type` key has the value "data" and whose
* `last_modified` key has the value 1234:
* `{ type: "data", last_modified: 1234 }
*/
removeRecords(filter = null) {
this.#log.debug("Removing records", { filter });
this.#lastModified++;
for (let records of this.#records.values()) {
for (let record of records) {
if (
!filter ||
Object.entries(filter).every(
([filterKey, filterValue]) =>
record.hasOwnProperty(filterKey) &&
record[filterKey] == filterValue
)
) {
record.deleted = true;
record.last_modified = this.#lastModified;
// If the record has an attachment, leave it. Sometimes the following
// sequence can happen: A test requests records, we send them,
// something else deletes the records, and then the test requests
// their attachments. The JS RS client throws an error in that case
// since the attachment hashes don't match the hashes in the records.
}
}
}
this.#log.debug("Done removing records. All records are now:", [
...this.#records.entries(),
]);
}
/**
* Removes all existing records and adds the given records to the server.
*
* @param {object} options
* @param {string} options.bucket
* @param {string} options.collection
* @param {Array} options.records
* See `addRecords()`.
*/
async setRecords({ bucket = "main", collection = "test", records }) {
this.#log.debug("Setting records");
this.removeRecords();
await this.addRecords({ bucket, collection, records });
this.#log.debug("Done setting records");
}
/**
* `nsIHttpRequestHandler` callback from the backing server. Handles a
* request.
*
* @param {nsIHttpRequest} request
* @param {nsIHttpResponse} response
*/
handle(request, response) {
this.#logRequest(request);
// Get the route that matches the request path.
let { match, route } = this.#getRoute(request.path) || {};
if (!route) {
this.#prepareError({ request, response, error: lazy.HTTP_404 });
return;
}
let respInfo = route.response(match, request, response);
if (respInfo instanceof lazy.HttpError) {
this.#prepareError({ request, response, error: respInfo });
} else {
this.#prepareResponse({ ...respInfo, request, response });
}
}
/**
* @returns {Array}
* The routes handled by the server. Each item in this array is an object
* with the following properties that describes one or more paths and the
* response that should be sent when a request is made on those paths:
*
* {string} spec
* A path spec. This is required unless `specs` is defined. To determine
* which route should be used for a given request, the server will check
* each route's spec(s) until it finds the first that matches the
* request's path. A spec is just a path whose components can be variables
* that start with "$". When a spec with variables matches a request path,
* the `match` object passed to the route's `response` function will map
* from variable names to the corresponding components in the path.
* {Array} specs
* An array of path spec strings. Use this instead of `spec` if the route
* handles more than one.
* {function} response
* A function that will be called when the route matches a request. It is
* called as: `response(match, request, response)`
*
* {object} match
* An object mapping variable names in the spec to their matched
* components in the path. See `#match()` for details.
* {nsIHttpRequest} request
* {nsIHttpResponse} response
*
* The function must return one of the following:
*
* {object}
* An object that describes the response with the following properties:
* {object} body
* A plain JSON'able object. The server will convert this to JSON and
* set it to the response body.
* {HttpError}
* An `HttpError` instance defined in `httpd.sys.mjs`.
*/
get #routes() {
return [
{
spec: "/v1",
response: () => ({
body: {
capabilities: {
attachments: {
base_url: this.#url.toString(),
},
},
},
}),
},
{
spec: "/v1/buckets/monitor/collections/changes/changeset",
response: () => ({
body: {
timestamp: this.#lastModified,
changes: [
{
last_modified: this.#lastModified,
},
],
},
}),
},
{
spec: "/v1/buckets/$bucket/collections/$collection/changeset",
response: ({ bucket, collection }, request) => {
let records = this.#getRecords(bucket, collection, request);
return !records
? lazy.HTTP_404
: {
body: {
metadata: {
bucket,
signature: {
signature: "",
x5u: "",
},
},
timestamp: this.#lastModified,
changes: records,
},
};
},
},
{
spec: "/v1/buckets/$bucket/collections/$collection/records",
response: ({ bucket, collection }, request) => {
let records = this.#getRecords(bucket, collection, request);
return !records
? lazy.HTTP_404
: {
body: {
data: records,
},
};
},
},
{
specs: [
// The Rust remote settings client doesn't include "v1" in attachment
// URLs, but the JS client does.
"/attachments/$bucket/$collection/$filename",
"/v1/attachments/$bucket/$collection/$filename",
],
response: ({ bucket, collection, filename }) => {
return {
body: this.#getAttachment(bucket, collection, filename),
};
},
},
];
}
/**
* @returns {object}
* Default response headers.
*/
get #responseHeaders() {
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Expose-Headers":
"Retry-After, Content-Length, Alert, Backoff",
Server: "waitress",
Etag: `"${this.#lastModified}"`,
};
}
/**
* Returns the route that matches a request path.
*
* @param {string} path
* A request path.
* @returns {object}
* If no route matches the path, returns an empty object. Otherwise returns
* an object with the following properties:
*
* {object} match
* An object describing the matched variables in the route spec. See
* `#match()` for details.
* {object} route
* The matched route. See `#routes` for details.
*/
#getRoute(path) {
for (let route of this.#routes) {
let specs = route.specs || [route.spec];
for (let spec of specs) {
let match = this.#match(path, spec);
if (match) {
return { match, route };
}
}
}
return {};
}
/**
* Matches a request path to a route spec.
*
* @param {string} path
* A request path.
* @param {string} spec
* A route spec. See `#routes` for details.
* @returns {object|null}
* If the spec doesn't match the path, returns null. Otherwise returns an
* object mapping variable names in the spec to their matched components in
* the path. Example:
*
* path : "/main/myfeature/foo"
* spec : "/$bucket/$collection/foo"
* returns: `{ bucket: "main", collection: "myfeature" }`
*/
#match(path, spec) {
let pathParts = path.split("/");
let specParts = spec.split("/");
if (pathParts.length != specParts.length) {
// If the path has only one more part than the spec and its last part is
// empty, then the path ends in a trailing slash but the spec does not.
// Consider that a match. Otherwise return null for no match.
if (
pathParts[pathParts.length - 1] ||
pathParts.length != specParts.length + 1
) {
return null;
}
pathParts.pop();
}
let match = {};
for (let i = 0; i < pathParts.length; i++) {
let pathPart = pathParts[i];
let specPart = specParts[i];
if (specPart.startsWith("$")) {
match[specPart.substring(1)] = pathPart;
} else if (pathPart != specPart) {
return null;
}
}
return match;
}
#getRecords(bucket, collection, request) {
let records = this.#records.get(this.#recordsKey(bucket, collection));
let params = new URLSearchParams(request.queryString);
let type = params.get("type");
if (type) {
records = records.filter(r => r.type == type);
}
let gtLastModified = params.get("gt_last_modified");
if (gtLastModified) {
records = records.filter(r => r.last_modified > gtLastModified);
}
let since = params.get("_since");
if (since) {
// Example value: "%221368273600004%22"
let match = /^"([0-9]+)"$/.exec(decodeURIComponent(since));
if (match) {
since = parseInt(match[1]);
records = records.filter(r => r.last_modified > since);
}
}
let sort = params.get("_sort");
if (sort == "last_modified") {
records = records.toSorted((a, b) => a.last_modified - b.last_modified);
}
return records;
}
#recordsKey(bucket, collection) {
return `${bucket}/${collection}`;
}
/**
* Registers an attachment for a record.
*
* @param {object} options
* @param {string} options.bucket
* @param {string} options.collection
* @param {object} options.record
* The record should have an `attachment` property as described in
* `addRecords()`.
*/
async #addAttachment({ bucket, collection, record }) {
let { attachment } = record;
let bytes;
if (attachment instanceof Array) {
bytes = Uint8Array.from(attachment);
} else {
let encoder = new TextEncoder();
bytes = encoder.encode(JSON.stringify(attachment));
}
let hashBuffer = await crypto.subtle.digest("SHA-256", bytes);
let hashBytes = new Uint8Array(hashBuffer);
let toHex = b => b.toString(16).padStart(2, "0");
let hash = Array.from(hashBytes, toHex).join("");
let filename = record.id;
this.#attachments.set(
this.#attachmentsKey(bucket, collection, filename),
attachment
);
// Replace `record.attachment` with appropriate metadata in order to conform
// with the remote settings API.
record.attachment = {
hash,
filename,
mimetype: record.attachmentMimetype ?? "application/json; charset=UTF-8",
size: bytes.length,
location: `attachments/${bucket}/${collection}/${filename}`,
};
delete record.attachmentMimetype;
}
#attachmentsKey(bucket, collection, filename) {
return `${bucket}/${collection}/${filename}`;
}
#getAttachment(bucket, collection, filename) {
return this.#attachments.get(
this.#attachmentsKey(bucket, collection, filename)
);
}
/**
* Prepares an HTTP response.
*
* @param {object} options
* @param {nsIHttpRequest} options.request
* @param {nsIHttpResponse} options.response
* @param {object|null} options.body
* Currently only JSON'able objects are supported. They will be converted to
* JSON in the response.
* @param {integer} options.status
* @param {string} options.statusText
*/
#prepareResponse({
request,
response,
body = null,
status = 200,
statusText = "OK",
}) {
let headers = { ...this.#responseHeaders };
if (body) {
headers["Content-Type"] = "application/json; charset=UTF-8";
}
this.#logResponse({ request, status, statusText, headers, body });
for (let [name, value] of Object.entries(headers)) {
response.setHeader(name, value, false);
}
if (body) {
response.write(JSON.stringify(body));
}
response.setStatusLine(request.httpVersion, status, statusText);
}
/**
* Prepares an HTTP error response.
*
* @param {object} options
* @param {nsIHttpRequest} options.request
* @param {nsIHttpResponse} options.response
* @param {HttpError} options.error
* An `HttpError` instance defined in `httpd.sys.mjs`.
*/
#prepareError({ request, response, error }) {
this.#prepareResponse({
request,
response,
status: error.code,
statusText: error.description,
});
}
/**
* Logs a request.
*
* @param {nsIHttpRequest} request
*/
#logRequest(request) {
let pathAndQuery = request.path;
if (request.queryString) {
pathAndQuery += "?" + request.queryString;
}
this.#log.debug(
`< HTTP ${request.httpVersion} ${request.method} ${pathAndQuery}`
);
}
/**
* Logs a response.
*
* @param {object} options
* @param {nsIHttpRequest} options.request
* The associated request.
* @param {integer} options.status
* The HTTP status code of the response.
* @param {object} options._headers
* An object mapping from response header names to values.
* @param {object} options.body
* The response body, if any.
*/
#logResponse({ request, status, _headers, body }) {
this.#log.debug(`> ${status} ${request.path}`);
if (body) {
this.#log.debug("Response body:", body);
}
}
// records key (see `#recordsKey()`) -> array of record objects
#records = new Map();
// attachments key (see `#attachmentsKey()`) -> attachment object
#attachments = new Map();
#log;
#server;
#originalServerPrefValue;
#url = null;
#lastModified = 1368273600000;
#nextRecordId = 1;
}