Skip to content

Commit

Permalink
Add service worker support (#3419)
Browse files Browse the repository at this point in the history
* Generate Web Worker script for every test file

This will be needed to launch Service Workers, which cannot be generated
at runtime and cannot use dynamic import().

* Add service worker support

* Update wpt.ts

* Run tests

* Address kainino0x feedback

* Address feedback | part 2

* Address feedback | part 3

* Address feedback | part 4

* Address feedback | part 5

* Address feedback | part 6

* Address feedback | part 7

* Address feedback | part 8

* Apply suggestions from code review

* use WorkerTestRunRequest in the postMessage/onmessage interface

* Clean up resolvers map

* Use express routing for .worker.js

* Skip worker tests when run in a worker that can't support them

DedicatedWorker can be created from DedicatedWorker, but none of the
other nested worker pairs are allowed.

* Clean up all service workers on startup and shutdown

* Avoid reinitializing service workers for every single case

* lint fixes

* Catch errors in wrapTestGroupForWorker onMessage

* Make sure the service worker has the correct URL

---------

Co-authored-by: Kai Ninomiya <[email protected]>
  • Loading branch information
beaufortfrancois and kainino0x authored Mar 11, 2024
1 parent f9f6c90 commit 5ad7589
Show file tree
Hide file tree
Showing 27 changed files with 512 additions and 279 deletions.
10 changes: 5 additions & 5 deletions Gruntfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ module.exports = function (grunt) {
cmd: 'node',
args: ['tools/gen_version'],
},
'generate-listings': {
'generate-listings-and-webworkers': {
cmd: 'node',
args: ['tools/gen_listings', 'gen/', ...kAllSuites.map(s => 'src/' + s)],
args: ['tools/gen_listings_and_webworkers', 'gen/', ...kAllSuites.map(s => 'src/' + s)],
},
validate: {
cmd: 'node',
Expand Down Expand Up @@ -159,14 +159,14 @@ module.exports = function (grunt) {
// Must run after generate-common and run:build-out.
files: [
{ expand: true, dest: 'out/', cwd: 'gen', src: 'common/internal/version.js' },
{ expand: true, dest: 'out/', cwd: 'gen', src: '*/listing.js' },
{ expand: true, dest: 'out/', cwd: 'gen', src: '*/**/*.js' },
],
},
'gen-to-out-wpt': {
// Must run after generate-common and run:build-out-wpt.
files: [
{ expand: true, dest: 'out-wpt/', cwd: 'gen', src: 'common/internal/version.js' },
{ expand: true, dest: 'out-wpt/', cwd: 'gen', src: 'webgpu/listing.js' },
{ expand: true, dest: 'out-wpt/', cwd: 'gen', src: 'webgpu/**/*.js' },
],
},
'htmlfiles-to-out': {
Expand Down Expand Up @@ -243,7 +243,7 @@ module.exports = function (grunt) {

grunt.registerTask('generate-common', 'Generate files into gen/ and src/', [
'run:generate-version',
'run:generate-listings',
'run:generate-listings-and-webworkers',
'run:generate-cache',
]);
grunt.registerTask('build-standalone', 'Build out/ (no checks; run after generate-common)', [
Expand Down
1 change: 1 addition & 0 deletions docs/intro/developing.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ The following url parameters change how the harness runs:
- `debug=1` enables verbose debug logging from tests.
- `worker=dedicated` runs the tests on a dedicated worker instead of the main thread.
- `worker=shared` runs the tests on a shared worker instead of the main thread.
- `worker=service` runs the tests on a service worker instead of the main thread.
- `power_preference=low-power` runs most tests passing `powerPreference: low-power` to `requestAdapter`
- `power_preference=high-performance` runs most tests passing `powerPreference: high-performance` to `requestAdapter`

Expand Down
2 changes: 1 addition & 1 deletion docs/terms.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ Each Suite has one Listing File (`suite/listing.[tj]s`), containing a list of th
in the suite.

In `src/suite/listing.ts`, this is computed dynamically.
In `out/suite/listing.js`, the listing has been pre-baked (by `tools/gen_listings`).
In `out/suite/listing.js`, the listing has been pre-baked (by `tools/gen_listings_and_webworkers`).

**Type:** Once `import`ed, `ListingFile`

Expand Down
5 changes: 4 additions & 1 deletion src/common/internal/query/compare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ function compareOneLevel(ordering: Ordering, aIsBig: boolean, bIsBig: boolean):
return Ordering.Unordered;
}

function comparePaths(a: readonly string[], b: readonly string[]): Ordering {
/**
* Compare two file paths, or file-local test paths, returning an Ordering between the two.
*/
export function comparePaths(a: readonly string[], b: readonly string[]): Ordering {
const shorter = Math.min(a.length, b.length);

for (let i = 0; i < shorter; ++i) {
Expand Down
2 changes: 1 addition & 1 deletion src/common/internal/test_suite_listing.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// A listing of all specs within a single suite. This is the (awaited) type of
// `groups` in '{cts,unittests}/listing.ts' and `listing` in the auto-generated
// 'out/{cts,unittests}/listing.js' files (see tools/gen_listings).
// 'out/{cts,unittests}/listing.js' files (see tools/gen_listings_and_webworkers).
export type TestSuiteListing = TestSuiteListingEntry[];

export type TestSuiteListingEntry = TestSuiteListingEntrySpec | TestSuiteListingEntryReadme;
Expand Down
3 changes: 2 additions & 1 deletion src/common/runtime/helper/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function optionString(
* The possible options for the tests.
*/
export interface CTSOptions {
worker?: 'dedicated' | 'shared' | '';
worker?: 'dedicated' | 'shared' | 'service' | '';
debug: boolean;
compatibility: boolean;
forceFallbackAdapter: boolean;
Expand Down Expand Up @@ -68,6 +68,7 @@ export const kCTSOptionsInfo: OptionsInfos<CTSOptions> = {
{ value: '', description: 'no worker' },
{ value: 'dedicated', description: 'dedicated worker' },
{ value: 'shared', description: 'shared worker' },
{ value: 'service', description: 'service worker' },
],
},
debug: { description: 'show more info' },
Expand Down
27 changes: 4 additions & 23 deletions src/common/runtime/helper/test_worker-worker.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import { setBaseResourcePath } from '../../framework/resources.js';
import { globalTestConfig } from '../../framework/test_config.js';
import { DefaultTestFileLoader } from '../../internal/file_loader.js';
import { Logger } from '../../internal/logging/logger.js';
import { parseQuery } from '../../internal/query/parseQuery.js';
import { TestQueryWithExpectation } from '../../internal/query/query.js';
import { setDefaultRequestAdapterOptions } from '../../util/navigator_gpu.js';
import { assert } from '../../util/util.js';

import { CTSOptions } from './options.js';
import { setupWorkerEnvironment, WorkerTestRunRequest } from './utils_worker.js';

// Should be WorkerGlobalScope, but importing lib "webworker" conflicts with lib "dom".
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
Expand All @@ -18,24 +14,9 @@ const loader = new DefaultTestFileLoader();
setBaseResourcePath('../../../resources');

async function reportTestResults(this: MessagePort | Worker, ev: MessageEvent) {
const query: string = ev.data.query;
const expectations: TestQueryWithExpectation[] = ev.data.expectations;
const ctsOptions: CTSOptions = ev.data.ctsOptions;
const { query, expectations, ctsOptions } = ev.data as WorkerTestRunRequest;

const { debug, unrollConstEvalLoops, powerPreference, compatibility } = ctsOptions;
globalTestConfig.unrollConstEvalLoops = unrollConstEvalLoops;
globalTestConfig.compatibility = compatibility;

Logger.globalDebugMode = debug;
const log = new Logger();

if (powerPreference || compatibility) {
setDefaultRequestAdapterOptions({
...(powerPreference && { powerPreference }),
// MAINTENANCE_TODO: Change this to whatever the option ends up being
...(compatibility && { compatibilityMode: true }),
});
}
const log = setupWorkerEnvironment(ctsOptions);

const testcases = Array.from(await loader.loadCases(parseQuery(query)));
assert(testcases.length === 1, 'worker query resulted in != 1 cases');
Expand All @@ -48,7 +29,7 @@ async function reportTestResults(this: MessagePort | Worker, ev: MessageEvent) {
}

self.onmessage = (ev: MessageEvent) => {
void reportTestResults.call(self, ev);
void reportTestResults.call(ev.source || self, ev);
};

self.onconnect = (event: MessageEvent) => {
Expand Down
152 changes: 102 additions & 50 deletions src/common/runtime/helper/test_worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,95 +2,147 @@ import { LogMessageWithStack } from '../../internal/logging/log_message.js';
import { TransferredTestCaseResult, LiveTestCaseResult } from '../../internal/logging/result.js';
import { TestCaseRecorder } from '../../internal/logging/test_case_recorder.js';
import { TestQueryWithExpectation } from '../../internal/query/query.js';
import { timeout } from '../../util/timeout.js';
import { assert } from '../../util/util.js';

import { CTSOptions, kDefaultCTSOptions } from './options.js';
import { WorkerTestRunRequest } from './utils_worker.js';

export class TestDedicatedWorker {
private readonly ctsOptions: CTSOptions;
private readonly worker: Worker;
private readonly resolvers = new Map<string, (result: LiveTestCaseResult) => void>();
/** Query all currently-registered service workers, and unregister them. */
function unregisterAllServiceWorkers() {
void navigator.serviceWorker.getRegistrations().then(registrations => {
for (const registration of registrations) {
void registration.unregister();
}
});
}

constructor(ctsOptions?: CTSOptions) {
this.ctsOptions = { ...(ctsOptions || kDefaultCTSOptions), ...{ worker: 'dedicated' } };
const selfPath = import.meta.url;
const selfPathDir = selfPath.substring(0, selfPath.lastIndexOf('/'));
const workerPath = selfPathDir + '/test_worker-worker.js';
this.worker = new Worker(workerPath, { type: 'module' });
this.worker.onmessage = ev => {
const query: string = ev.data.query;
const result: TransferredTestCaseResult = ev.data.result;
if (result.logs) {
for (const l of result.logs) {
Object.setPrototypeOf(l, LogMessageWithStack.prototype);
}
// NOTE: This code runs on startup for any runtime with worker support. Here, we use that chance to
// delete any leaked service workers, and register to clean up after ourselves at shutdown.
unregisterAllServiceWorkers();
window.addEventListener('beforeunload', () => {
unregisterAllServiceWorkers();
});

class TestBaseWorker {
protected readonly ctsOptions: CTSOptions;
protected readonly resolvers = new Map<string, (result: LiveTestCaseResult) => void>();

constructor(worker: CTSOptions['worker'], ctsOptions?: CTSOptions) {
this.ctsOptions = { ...(ctsOptions || kDefaultCTSOptions), ...{ worker } };
}

onmessage(ev: MessageEvent) {
const query: string = ev.data.query;
const result: TransferredTestCaseResult = ev.data.result;
if (result.logs) {
for (const l of result.logs) {
Object.setPrototypeOf(l, LogMessageWithStack.prototype);
}
this.resolvers.get(query)!(result as LiveTestCaseResult);
}
this.resolvers.get(query)!(result as LiveTestCaseResult);
this.resolvers.delete(query);

// MAINTENANCE_TODO(kainino0x): update the Logger with this result (or don't have a logger and
// update the entire results JSON somehow at some point).
};
// MAINTENANCE_TODO(kainino0x): update the Logger with this result (or don't have a logger and
// update the entire results JSON somehow at some point).
}

async run(
async makeRequestAndRecordResult(
target: MessagePort | Worker | ServiceWorker,
rec: TestCaseRecorder,
query: string,
expectations: TestQueryWithExpectation[] = []
): Promise<void> {
this.worker.postMessage({
expectations: TestQueryWithExpectation[]
) {
const request: WorkerTestRunRequest = {
query,
expectations,
ctsOptions: this.ctsOptions,
});
};
target.postMessage(request);

const workerResult = await new Promise<LiveTestCaseResult>(resolve => {
assert(!this.resolvers.has(query), "can't request same query twice simultaneously");
this.resolvers.set(query, resolve);
});
rec.injectResult(workerResult);
}
}

export class TestDedicatedWorker extends TestBaseWorker {
private readonly worker: Worker;

constructor(ctsOptions?: CTSOptions) {
super('dedicated', ctsOptions);
const selfPath = import.meta.url;
const selfPathDir = selfPath.substring(0, selfPath.lastIndexOf('/'));
const workerPath = selfPathDir + '/test_worker-worker.js';
this.worker = new Worker(workerPath, { type: 'module' });
this.worker.onmessage = ev => this.onmessage(ev);
}

async run(
rec: TestCaseRecorder,
query: string,
expectations: TestQueryWithExpectation[] = []
): Promise<void> {
await this.makeRequestAndRecordResult(this.worker, rec, query, expectations);
}
}

export class TestWorker extends TestDedicatedWorker {}

export class TestSharedWorker {
private readonly ctsOptions: CTSOptions;
export class TestSharedWorker extends TestBaseWorker {
private readonly port: MessagePort;
private readonly resolvers = new Map<string, (result: LiveTestCaseResult) => void>();

constructor(ctsOptions?: CTSOptions) {
this.ctsOptions = { ...(ctsOptions || kDefaultCTSOptions), ...{ worker: 'shared' } };
super('shared', ctsOptions);
const selfPath = import.meta.url;
const selfPathDir = selfPath.substring(0, selfPath.lastIndexOf('/'));
const workerPath = selfPathDir + '/test_worker-worker.js';
const worker = new SharedWorker(workerPath, { type: 'module' });
this.port = worker.port;
this.port.start();
this.port.onmessage = ev => {
const query: string = ev.data.query;
const result: TransferredTestCaseResult = ev.data.result;
if (result.logs) {
for (const l of result.logs) {
Object.setPrototypeOf(l, LogMessageWithStack.prototype);
}
}
this.resolvers.get(query)!(result as LiveTestCaseResult);
this.port.onmessage = ev => this.onmessage(ev);
}

// MAINTENANCE_TODO(kainino0x): update the Logger with this result (or don't have a logger and
// update the entire results JSON somehow at some point).
};
async run(
rec: TestCaseRecorder,
query: string,
expectations: TestQueryWithExpectation[] = []
): Promise<void> {
await this.makeRequestAndRecordResult(this.port, rec, query, expectations);
}
}

export class TestServiceWorker extends TestBaseWorker {
constructor(ctsOptions?: CTSOptions) {
super('service', ctsOptions);
}

async run(
rec: TestCaseRecorder,
query: string,
expectations: TestQueryWithExpectation[] = []
): Promise<void> {
this.port.postMessage({
query,
expectations,
ctsOptions: this.ctsOptions,
});
const workerResult = await new Promise<LiveTestCaseResult>(resolve => {
this.resolvers.set(query, resolve);
const [suite, name] = query.split(':', 2);
const fileName = name.split(',').join('/');
const serviceWorkerURL = new URL(
`/out/${suite}/webworker/${fileName}.worker.js`,
window.location.href
).toString();

// If a registration already exists for this path, it will be ignored.
const registration = await navigator.serviceWorker.register(serviceWorkerURL, {
type: 'module',
});
rec.injectResult(workerResult);
// Make sure the registration we just requested is active. (We don't worry about it being
// outdated from a previous page load, because we wipe all service workers on shutdown/startup.)
while (!registration.active || registration.active.scriptURL !== serviceWorkerURL) {
await new Promise(resolve => timeout(resolve, 0));
}
const serviceWorker = registration.active;

navigator.serviceWorker.onmessage = ev => this.onmessage(ev);
await this.makeRequestAndRecordResult(serviceWorker, rec, query, expectations);
}
}
34 changes: 34 additions & 0 deletions src/common/runtime/helper/utils_worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { globalTestConfig } from '../../framework/test_config.js';
import { Logger } from '../../internal/logging/logger.js';
import { TestQueryWithExpectation } from '../../internal/query/query.js';
import { setDefaultRequestAdapterOptions } from '../../util/navigator_gpu.js';

import { CTSOptions } from './options.js';

export interface WorkerTestRunRequest {
query: string;
expectations: TestQueryWithExpectation[];
ctsOptions: CTSOptions;
}

/**
* Set config environment for workers with ctsOptions and return a Logger.
*/
export function setupWorkerEnvironment(ctsOptions: CTSOptions): Logger {
const { debug, unrollConstEvalLoops, powerPreference, compatibility } = ctsOptions;
globalTestConfig.unrollConstEvalLoops = unrollConstEvalLoops;
globalTestConfig.compatibility = compatibility;

Logger.globalDebugMode = debug;
const log = new Logger();

if (powerPreference || compatibility) {
setDefaultRequestAdapterOptions({
...(powerPreference && { powerPreference }),
// MAINTENANCE_TODO: Change this to whatever the option ends up being
...(compatibility && { compatibilityMode: true }),
});
}

return log;
}
Loading

0 comments on commit 5ad7589

Please sign in to comment.