summaryrefslogtreecommitdiffstats
path: root/toolkit/components/extensions/test/xpcshell/test_extension_process_alive.js
blob: fb82f85140bf09dace29e830b3ddcdf11814f2a0 (plain)
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
"use strict";

// Helper to observe process shutdowns. Used to detect when extension processes
// have shut down. For simplicity, this helper does not filter by extension
// processes because the callers knowingly pass extension process childIDs only.
class ProcessWatcher {
  constructor() {
    // Map of childID to boolean (whether process ended abnormally)
    this.seenChildIDs = new Map();
    this.onShutdownCallbacks = new Set();
    Services.obs.addObserver(this, "ipc:content-shutdown");

    // See setExtProcessTerminationDeadline and waitAndCheckIsProcessAlive.
    // We measure the duration of an earlier test to determine the reasonable
    // duration during which a terminated extension process should stay alive.
    // Use a high default in case that task was skipped, e.g. by .only().
    this.deadDeadline = 5000;
  }

  unregister() {
    Services.obs.removeObserver(this, "ipc:content-shutdown");
  }

  observe(subject, topic, data) {
    const childID = parseInt(data, 10);
    const abnormal = subject.QueryInterface(Ci.nsIPropertyBag2).get("abnormal");
    info(`Observed content shutdown, childID=${childID}, abnormal=${abnormal}`);
    this.seenChildIDs.set(childID, !!abnormal);
    for (let onShutdownCallback of this.onShutdownCallbacks) {
      onShutdownCallback(childID);
    }
  }

  isProcessAlive(childID) {
    return !this.seenChildIDs.has(childID);
  }

  async waitForTermination(childID, expectAbnormal = false) {
    // We only expect content processes, so childID should never be zero.
    Assert.ok(childID, `waitForTermination: ${childID}`);

    if (!this.isProcessAlive(childID)) {
      info(`Process has already shut down: ${childID}`);
    } else {
      info(`Waiting for process to shut down: ${childID}`);
      await new Promise(resolve => {
        const onShutdownCallback = _childID => {
          if (_childID === childID) {
            info(`Process has shut down: ${childID}`);
            this.onShutdownCallbacks.delete(onShutdownCallback);
            resolve();
          }
        };
        this.onShutdownCallbacks.add(onShutdownCallback);
      });
    }

    // When we get here, !isProcessAlive or onShutdownCallback was called,
    // which implies that childID is a key in the seenChildIDs Map.
    const abnormal = this.seenChildIDs.get(childID);
    if (expectAbnormal) {
      Assert.ok(abnormal, "Process should have ended abnormally.");
    } else if (AppConstants.platform === "android" && abnormal) {
      // On Android, the implementation sometimes triggers abnormal shutdowns
      // when we expect normal shutdown. This is undesired, but as it happens
      // intermittently, pretend that everything is OK and log a message.
      Assert.ok(true, "Process should have ended normally, but did not.");
    } else {
      Assert.ok(!abnormal, "Process should have ended normally.");
    }
  }

  // Set the deadline as used by "waitAndCheckIsProcessAlive". The deadline is
  // the time by which an unexpected process termination should happen to catch
  // unexpected process termination.
  setExtProcessTerminationDeadline(deadline) {
    // Have some reasonably small minimum deadline, in case the caller
    // experiences a drifted timer that results in negative value.
    const MIN_DEADLINE = 1000;
    // Tests time out after 30 seconds. Enforce a maximum deadline below that
    // limit, e.g. in case a process is being debugged.
    const MAX_DEADLINE = 20000;
    if (deadline < MIN_DEADLINE) {
      this.deadDeadline = MIN_DEADLINE;
    } else if (deadline > MAX_DEADLINE) {
      this.deadDeadline = MAX_DEADLINE;
    } else {
      this.deadDeadline = deadline;
    }
  }

  async waitAndCheckIsProcessAlive(childID) {
    Assert.ok(this.isProcessAlive(childID), `Process ${childID} is alive`);

    // We want to verify that the extension process does not shut down too soon.
    // There is no great way to verify this, other than waiting for a bit and
    // verifying that the process is still around.
    info(`Waiting for ${this.deadDeadline} ms and process ${childID}`);

    // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
    await new Promise(r => setTimeout(r, this.deadDeadline));

    Assert.ok(this.isProcessAlive(childID), `Process ${childID} still alive`);
  }
}

// Register early so we catch all terminations.
const processWatcher = new ProcessWatcher();
registerCleanupFunction(() => processWatcher.unregister());

function pidOfContentPage(contentPage) {
  return contentPage.browsingContext.currentWindowGlobal.domProcess.childID;
}

function pidOfBackgroundPage(extension) {
  return extension.extension.backgroundContext.xulBrowser.browsingContext
    .currentWindowGlobal.domProcess.childID;
}

async function loadExtensionAndGetPid() {
  let extension = ExtensionTestUtils.loadExtension({
    background() {
      browser.test.sendMessage("bg_loaded");
    },
  });
  await extension.startup();
  await extension.awaitMessage("bg_loaded");
  let pid = pidOfBackgroundPage(extension);
  await extension.unload();
  return pid;
}

add_setup(async function setup_start_and_quit_addon_manager() {
  // None of this setup is strictly required for the test file to pass, but
  // exists to trigger conditions that were historically associated with bugs
  // and test failures.

  // As a regression test for bug 1845352: Verify that (simulating) shut down
  // of the AddonManager does not break the behavior of extension process
  // spawning. For details see bug 1845352 and bug 1845778.
  ExtensionTestUtils.mockAppInfo();
  AddonTestUtils.init(globalThis);
  await AddonTestUtils.promiseStartupManager();
  info("Starting an extension to load the extension process");
  let extension = ExtensionTestUtils.loadExtension({
    background() {
      window.onload = () => browser.test.sendMessage("first_run");
    },
  });
  await extension.startup();
  await extension.awaitMessage("first_run");
  info("Fully loaded initial extension and its process, shutting down now");
  await extension.unload();
  await AddonTestUtils.promiseShutdownManager();
  // Bug 1845352 regression test: the above call broke the test that verified
  // process reuse, because unexpectedly the extension process was shut down
  // when promiseShutdownManager triggered "quit-application-granted".
});

add_task(
  {
    // Here we confirm the usual default behavior. We explicitly set the pref
    // to 0 because head_remote.js sets the value to 1.
    pref_set: [["dom.ipc.keepProcessesAlive.extension", 0]],
  },
  async function shutdown_extension_process_on_extension_background_unload() {
    info("Starting and unloading first extension");
    let pid1 = await loadExtensionAndGetPid();

    info("Extension process should end after unloading the only extension doc");
    await processWatcher.waitForTermination(pid1);
  }
);

add_task(
  {
    // This test verifies that dom.ipc.keepProcessesAlive.extension=1 works,
    // because we rely on it in unit tests, mainly to minimize overhead.
    pref_set: [["dom.ipc.keepProcessesAlive.extension", 1]],
  },
  async function extension_process_reused_between_background_page_restarts() {
    info("Starting and unloading first extension");
    let pid1 = await loadExtensionAndGetPid();

    info("Process should be alive after unloading the only extension (1)");
    await processWatcher.waitAndCheckIsProcessAlive(pid1);

    info("Starting and unloading second extension");
    let pid2 = await loadExtensionAndGetPid();
    Assert.equal(pid1, pid2, "Extension process was reused");

    info("Process should be alive after unloading the only extension (2)");
    await processWatcher.waitAndCheckIsProcessAlive(pid1);

    // Try again repeatedly for many times to verify that this is not a fluke.
    // The number of attempts is arbitrarily chosen.
    for (let i = 1; i <= 9; ++i) {
      let pid3 = await loadExtensionAndGetPid();
      Assert.equal(pid1, pid3, `Extension process was reused at attempt ${i}`);
    }

    info("Process should be alive after unloading the only extension (3)");
    await processWatcher.waitAndCheckIsProcessAlive(pid1);

    // Note: while this task started without extension process, we end this
    // task with an extension process still running.
  }
);

add_task(
  {
    // Here we confirm the usual default behavior. We explicitly set the pref
    // to 0 because head_remote.js sets the value to 1.
    pref_set: [["dom.ipc.keepProcessesAlive.extension", 0]],
  },
  async function shutdown_extension_process_on_last_extension_page_unload() {
    let extension = ExtensionTestUtils.loadExtension({
      files: {
        "page.html": `<!DOCTYPE html><script src="page.js"></script>`,
        "page.js": () => browser.test.sendMessage("page_loaded"),
      },
    });

    await extension.startup();
    const EXT_PAGE = `moz-extension://${extension.uuid}/page.html`;
    async function openOnlyExtensionPageAndGetPid() {
      let contentPage = await ExtensionTestUtils.loadContentPage(EXT_PAGE);
      await extension.awaitMessage("page_loaded");
      let pid = pidOfContentPage(contentPage);
      await contentPage.close();
      return pid;
    }

    const timeStart = Date.now();
    info("Opening first page");
    let contentPage = await ExtensionTestUtils.loadContentPage(EXT_PAGE);
    await extension.awaitMessage("page_loaded");
    let pid1 = pidOfContentPage(contentPage);

    info("Opening and closing second page while the first is open");
    let pid2 = await openOnlyExtensionPageAndGetPid();
    Assert.equal(pid1, pid2, "Second page should re-use first page's process");
    Assert.ok(processWatcher.isProcessAlive(pid1), "Process not dead");
    await contentPage.close();
    info("Closed last page - extension process should terminate");
    // pid1 should have died when we closed ContentPage. But in case shut down
    // is not immediate, wait a little bit.
    await processWatcher.waitForTermination(pid1);

    let pid3 = await openOnlyExtensionPageAndGetPid();
    Assert.notEqual(pid2, pid3, "Should get a new extension process");

    await extension.unload();
    await processWatcher.waitForTermination(pid3);

    // By now, we have witnessed:
    // 1. extension process spawned.
    // 2. first extension tab loaded.
    // 3. second extension tab loaded.
    // 4. extension process terminated after closing tabs.
    // 5. extension process spawned + terminated after opening and closing tab.
    // This should be plenty of time for any unexpected process termination to
    // have been observed. So wait for that time and not longer.
    processWatcher.setExtProcessTerminationDeadline(Date.now() - timeStart);
  }
);

add_task(
  {
    // This test verifies that dom.ipc.keepProcessesAlive.extension=1 works,
    // because we rely on it in unit tests, mainly to minimize overhead.
    pref_set: [["dom.ipc.keepProcessesAlive.extension", 1]],
  },
  async function keep_extension_process_on_last_extension_page_unload() {
    let extension = ExtensionTestUtils.loadExtension({
      files: {
        "page.html": `<!DOCTYPE html><script src="page.js"></script>`,
        "page.js": () => browser.test.sendMessage("page_loaded"),
      },
    });

    await extension.startup();
    const EXT_PAGE = `moz-extension://${extension.uuid}/page.html`;
    async function openOnlyExtensionPageAndGetPid() {
      let contentPage = await ExtensionTestUtils.loadContentPage(EXT_PAGE);
      await extension.awaitMessage("page_loaded");
      let pid = pidOfContentPage(contentPage);
      await contentPage.close();
      return pid;
    }

    info("Opening and closing first page");
    let pid1 = await openOnlyExtensionPageAndGetPid();

    info("No extension pages, but extension process should still be alive (1)");
    await processWatcher.waitAndCheckIsProcessAlive(pid1);

    let pid2 = await openOnlyExtensionPageAndGetPid();
    Assert.equal(pid1, pid2, "Extension process is reused by second page");

    info("No extension pages, but extension process should still be alive (2)");
    await processWatcher.waitAndCheckIsProcessAlive(pid1);

    await extension.unload();
    info("No extensions around, but extension process should still be alive");

    await processWatcher.waitAndCheckIsProcessAlive(pid1);

    // Note: while this task started without extension process, we end this
    // task with an extension process still running.
  }
);