1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
|
const DIRPATH = getRootDirectory(gTestPath).replace(
"chrome://mochitests/content/",
""
);
/**
* We choose blob contents that will roundtrip cleanly through the `textContent`
* of our returned HTML page.
*/
const TEST_BLOB_CONTENTS = `I'm a disk-backed test blob! Hooray!`;
add_setup(async function () {
await SpecialPowers.pushPrefEnv({
set: [
// Set preferences so that opening a page with the origin "example.org"
// will result in a remoteType of "privilegedmozilla" for both the
// page and the ServiceWorker.
["browser.tabs.remote.separatePrivilegedMozillaWebContentProcess", true],
["browser.tabs.remote.separatedMozillaDomains", "example.org"],
["dom.ipc.processCount.privilegedmozilla", 1],
["dom.ipc.processPrelaunch.enabled", false],
["dom.serviceWorkers.enabled", true],
["dom.serviceWorkers.testing.enabled", true],
// ServiceWorker worker instances should stay alive until explicitly
// caused to terminate by dropping these timeouts to 0 in
// `waitForWorkerAndProcessShutdown`.
["dom.serviceWorkers.idle_timeout", 299999],
["dom.serviceWorkers.idle_extended_timeout", 299999],
],
});
});
function countRemoteType(remoteType) {
return ChromeUtils.getAllDOMProcesses().filter(
p => p.remoteType == remoteType
).length;
}
/**
* Helper function to get a list of all current processes and their remote
* types. Note that when in used in a templated literal that it is
* synchronously invoked when the string is evaluated and captures system state
* at that instant.
*/
function debugRemotes() {
return ChromeUtils.getAllDOMProcesses()
.map(p => p.remoteType || "parent")
.join(",");
}
/**
* Wait for there to be zero processes of the given remoteType. This check is
* considered successful if there are already no processes of the given type
* at this very moment.
*/
async function waitForNoProcessesOfType(remoteType) {
info(`waiting for there to be no ${remoteType} procs`);
await TestUtils.waitForCondition(
() => countRemoteType(remoteType) == 0,
"wait for the worker's process to shutdown"
);
}
/**
* Given a ServiceWorkerRegistrationInfo with an active ServiceWorker that
* has no active ExtendableEvents but would otherwise continue running thanks
* to the idle keepalive:
* - Assert that there is a ServiceWorker instance in the given registration's
* active slot. (General invariant check.)
* - Assert that a single process with the given remoteType currently exists.
* (This doesn't mean the SW is alive in that process, though this test
* verifies that via other checks when appropriate.)
* - Induce the worker to shutdown by temporarily dropping the idle timeout to 0
* and causing the idle timer to be reset due to rapid debugger attach/detach.
* - Wait for the the single process with the given remoteType to go away.
* - Reset the idle timeouts back to their previous high values.
*/
async function waitForWorkerAndProcessShutdown(swRegInfo, remoteType) {
info(`terminating worker and waiting for ${remoteType} procs to shut down`);
ok(swRegInfo.activeWorker, "worker should be in the active slot");
is(
countRemoteType(remoteType),
1,
`should have a single ${remoteType} process but have: ${debugRemotes()}`
);
// Let's not wait too long for the process to shutdown.
await SpecialPowers.pushPrefEnv({
set: [
["dom.serviceWorkers.idle_timeout", 0],
["dom.serviceWorkers.idle_extended_timeout", 0],
],
});
// We need to cause the worker to re-evaluate its idle timeout. The easiest
// way to do this I could think of is to attach and then detach the debugger
// from the active worker.
swRegInfo.activeWorker.attachDebugger();
await new Promise(resolve => Cu.dispatch(resolve));
swRegInfo.activeWorker.detachDebugger();
// Eventually the length will reach 0, meaning we're done!
await waitForNoProcessesOfType(remoteType);
is(
countRemoteType(remoteType),
0,
`processes with remoteType=${remoteType} type should have shut down`
);
// Make sure we never kill workers on idle except when this is called.
await SpecialPowers.popPrefEnv();
}
async function do_test_sw(host, remoteType, swMode, fileBlob) {
info(
`### entering test: host=${host}, remoteType=${remoteType}, mode=${swMode}`
);
const prin = Services.scriptSecurityManager.createContentPrincipal(
Services.io.newURI(`https://${host}`),
{}
);
const sw = `https://${host}/${DIRPATH}file_service_worker_fetch_synthetic.js`;
const scope = `https://${host}/${DIRPATH}server_fetch_synthetic.sjs`;
const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService(
Ci.nsIServiceWorkerManager
);
const swRegInfo = await swm.registerForTest(prin, scope, sw);
swRegInfo.QueryInterface(Ci.nsIServiceWorkerRegistrationInfo);
info(
`service worker registered: ${JSON.stringify({
principal: swRegInfo.principal.spec,
scope: swRegInfo.scope,
})}`
);
// Wait for the worker to install & shut down.
await TestUtils.waitForCondition(
() => swRegInfo.activeWorker,
"wait for the worker to become active"
);
await waitForWorkerAndProcessShutdown(swRegInfo, remoteType);
info(
`test navigation interception with mode=${swMode} starting from about:blank`
);
await BrowserTestUtils.withNewTab(
{
gBrowser,
url: "about:blank",
},
async browser => {
// NOTE: We intentionally trigger the navigation from content in order to
// make sure frontend doesn't eagerly process-switch for us.
SpecialPowers.spawn(
browser,
[scope, swMode, fileBlob],
// eslint-disable-next-line no-shadow
async (scope, swMode, fileBlob) => {
const pageUrl = `${scope}?mode=${swMode}`;
if (!fileBlob) {
content.location.href = pageUrl;
} else {
const doc = content.document;
const formElem = doc.createElement("form");
doc.body.appendChild(formElem);
formElem.action = pageUrl;
formElem.method = "POST";
formElem.enctype = "multipart/form-data";
const fileElem = doc.createElement("input");
formElem.appendChild(fileElem);
fileElem.type = "file";
fileElem.name = "foo";
fileElem.mozSetFileArray([fileBlob]);
formElem.submit();
}
}
);
await BrowserTestUtils.browserLoaded(browser);
is(
countRemoteType(remoteType),
1,
`should have spawned a content process with remoteType=${remoteType}`
);
const { source, blobContents } = await SpecialPowers.spawn(
browser,
[],
() => {
return {
source: content.document.getElementById("source").textContent,
blobContents: content.document.getElementById("blob").textContent,
};
}
);
is(
source,
swMode === "synthetic" ? "ServiceWorker" : "ServerJS",
"The page contents should come from the right place."
);
is(
blobContents,
fileBlob ? TEST_BLOB_CONTENTS : "",
"The request blob contents should be the blob/empty as appropriate."
);
// Ensure the worker was loaded in this process.
const workerDebuggerURLs = await SpecialPowers.spawn(
browser,
[sw],
async url => {
if (!content.navigator.serviceWorker.controller) {
throw new Error("document not controlled!");
}
const wdm = Cc[
"@mozilla.org/dom/workers/workerdebuggermanager;1"
].getService(Ci.nsIWorkerDebuggerManager);
return Array.from(wdm.getWorkerDebuggerEnumerator())
.map(wd => {
return wd.url;
})
.filter(swURL => swURL == url);
}
);
if (remoteType.startsWith("webServiceWorker=")) {
Assert.notDeepEqual(
workerDebuggerURLs,
[sw],
"Isolated workers should not be running in the content child process"
);
} else {
Assert.deepEqual(
workerDebuggerURLs,
[sw],
"The worker should be running in the correct child process"
);
}
// Unregister the ServiceWorker. The registration will continue to control
// `browser` and therefore continue to exist and its worker to continue
// running until the tab is closed.
await SpecialPowers.spawn(browser, [], async () => {
let registration = await content.navigator.serviceWorker.ready;
await registration.unregister();
});
}
);
// Now that the controlled tab is closed and the registration has been
// removed, the ServiceWorker will be made redundant which will forcibly
// terminate it, which will result in the shutdown of the given content
// process. Wait for that to happen both as a verification and so the next
// test has a sufficiently clean slate.
await waitForNoProcessesOfType(remoteType);
}
/**
* Create a File-backed blob. This will happen synchronously from the main
* thread, which isn't optimal, but the test blocks on this progress anyways.
* Bug 1669578 has been filed on improving this idiom and avoiding the sync
* writes.
*/
async function makeFileBlob(blobContents) {
const tmpFile = Cc["@mozilla.org/file/directory_service;1"]
.getService(Ci.nsIDirectoryService)
.QueryInterface(Ci.nsIProperties)
.get("TmpD", Ci.nsIFile);
tmpFile.append("test-file-backed-blob.txt");
tmpFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600);
var outStream = Cc[
"@mozilla.org/network/file-output-stream;1"
].createInstance(Ci.nsIFileOutputStream);
outStream.init(
tmpFile,
0x02 | 0x08 | 0x20, // write, create, truncate
0o666,
0
);
outStream.write(blobContents, blobContents.length);
outStream.close();
const fileBlob = await File.createFromNsIFile(tmpFile);
return fileBlob;
}
function getSWTelemetrySums() {
let telemetry = Cc["@mozilla.org/base/telemetry;1"].getService(
Ci.nsITelemetry
);
let keyedhistograms = telemetry.getSnapshotForKeyedHistograms(
"main",
false
).parent;
let keyedscalars = telemetry.getSnapshotForKeyedScalars("main", false).parent;
// We're not looking at the distribution of the histograms, just that they changed
return {
SERVICE_WORKER_RUNNING_All: keyedhistograms.SERVICE_WORKER_RUNNING
? keyedhistograms.SERVICE_WORKER_RUNNING.All.sum
: 0,
SERVICE_WORKER_RUNNING_Fetch: keyedhistograms.SERVICE_WORKER_RUNNING
? keyedhistograms.SERVICE_WORKER_RUNNING.Fetch.sum
: 0,
SERVICEWORKER_RUNNING_MAX_All: keyedscalars["serviceworker.running_max"]
? keyedscalars["serviceworker.running_max"].All
: 0,
SERVICEWORKER_RUNNING_MAX_Fetch: keyedscalars["serviceworker.running_max"]
? keyedscalars["serviceworker.running_max"].Fetch
: 0,
};
}
add_task(async function test() {
// Can't test telemetry without this since we may not be on the nightly channel
let oldCanRecord = Services.telemetry.canRecordExtended;
Services.telemetry.canRecordExtended = true;
registerCleanupFunction(() => {
Services.telemetry.canRecordExtended = oldCanRecord;
});
let initialSums = getSWTelemetrySums();
// ## Isolated Privileged Process
// Trigger a straightforward intercepted navigation with no request body that
// returns a synthetic response.
await do_test_sw("example.org", "privilegedmozilla", "synthetic", null);
// Trigger an intercepted navigation with FormData containing an
// <input type="file"> which will result in the request body containing a
// RemoteLazyInputStream which will be consumed in the content process by the
// ServiceWorker while generating the synthetic response.
const fileBlob = await makeFileBlob(TEST_BLOB_CONTENTS);
await do_test_sw("example.org", "privilegedmozilla", "synthetic", fileBlob);
// Trigger an intercepted navigation with FormData containing an
// <input type="file"> which will result in the request body containing a
// RemoteLazyInputStream which will be relayed back to the parent process
// via direct invocation of fetch() on the event.request but without any
// cloning.
await do_test_sw("example.org", "privilegedmozilla", "fetch", fileBlob);
// Same as the above but cloning the request before fetching it.
await do_test_sw("example.org", "privilegedmozilla", "clone", fileBlob);
// ## Fission Isolation
if (Services.appinfo.fissionAutostart) {
// ## ServiceWorker isolation
const isolateUrl = "example.com";
const isolateRemoteType = `webServiceWorker=https://` + isolateUrl;
await do_test_sw(isolateUrl, isolateRemoteType, "synthetic", null);
await do_test_sw(isolateUrl, isolateRemoteType, "synthetic", fileBlob);
}
let telemetrySums = getSWTelemetrySums();
info(JSON.stringify(telemetrySums));
info(
"Initial Running All: " +
initialSums.SERVICE_WORKER_RUNNING_All +
", Fetch: " +
initialSums.SERVICE_WORKER_RUNNING_Fetch
);
info(
"Initial Max Running All: " +
initialSums.SERVICEWORKER_RUNNING_MAX_All +
", Fetch: " +
initialSums.SERVICEWORKER_RUNNING_MAX_Fetch
);
info(
"Running All: " +
telemetrySums.SERVICE_WORKER_RUNNING_All +
", Fetch: " +
telemetrySums.SERVICE_WORKER_RUNNING_Fetch
);
info(
"Max Running All: " +
telemetrySums.SERVICEWORKER_RUNNING_MAX_All +
", Fetch: " +
telemetrySums.SERVICEWORKER_RUNNING_MAX_Fetch
);
ok(
telemetrySums.SERVICE_WORKER_RUNNING_All >
initialSums.SERVICE_WORKER_RUNNING_All,
"ServiceWorker running count changed"
);
ok(
telemetrySums.SERVICE_WORKER_RUNNING_Fetch >
initialSums.SERVICE_WORKER_RUNNING_Fetch,
"ServiceWorker running count changed"
);
// We don't use ok()'s for MAX because MAX may have been set before we
// set canRecordExtended, and if so we won't record a new value unless
// the max increases again.
});
|