"use strict"; const { TestUtils } = ChromeUtils.importESModule( "resource://testing-common/TestUtils.sys.mjs" ); const { setTimeout } = ChromeUtils.importESModule( "resource://gre/modules/Timer.sys.mjs" ); let PYTHON; add_setup(async () => { PYTHON = await Subprocess.pathSearch(Services.env.get("PYTHON")); }); // Send a request to the test server. We expect a non-empty response. Any error // is caught and returned as an empty string. async function fetchResponseText(url, method) { const timeoutCtrl = new AbortController(); // We expect the (localhost) server to respond soon. If the server is not // listening, we will eventually time out. Let's have a generous deadlines: // - DELETE is given plenty of time, because the request is expected to // always be accepted, followed by IPC and process termination. // - GET is given a short deadline, because we expect either an immediate // response (server is around) or no response (server was closed). const timeout = method === "DELETE" ? 10000 : 1000; // eslint-disable-next-line mozilla/no-arbitrary-setTimeout setTimeout(() => timeoutCtrl.abort(), timeout); // (^ AbortSignal.timeout() would be nicer but cannot be used, because it // throws: "The current global does not support timeout".) try { let res = await fetch(url, { method, signal: timeoutCtrl.signal }); return await res.text(); } catch (e) { info(`fetch() request to kill parent failed: ${e}`); if ( e.name !== "AbortError" && e.message !== "NetworkError when attempting to fetch resource." ) { ok(false, `Unexpected error: ${e}`); } return ""; } } async function do_test_spawn_parent_with_child(isBreakAwayJob) { let testCmd; if (isBreakAwayJob) { Assert.equal(AppConstants.platform, "win", "Breakaway is Windows-only"); testCmd = "spawn_child_in_breakaway_job_and_exit"; } else { testCmd = "spawn_child_and_exit"; } const TEST_SCRIPT = do_get_file("data_test_child.py").path; info(`Launching proc: ${PYTHON} -u ${TEST_SCRIPT} ${testCmd}`); const proc = await Subprocess.call({ command: PYTHON, arguments: ["-u", TEST_SCRIPT, testCmd], }); const exitPromise = proc.wait(); let exited = false; exitPromise.then(() => { exited = true; }); info(`Spawned process with pid ${proc.pid}, waiting for child`); // We'll accumulate stdout, and reset what we've received so far after // checking that the content matches with our expectations. let stdoutText = ""; let stdoutDonePromise = (async () => { let seenParentExit = false; for (let s; (s = await proc.stdout.readString()); ) { // On Windows, print() uses \r\n instead of \n. Drop it. s = s.replaceAll("\r", ""); stdoutText += s; dump(`Received stdout from test script: ${s}\n`); seenParentExit ||= stdoutText.includes("parent_exit"); if (!seenParentExit) { Assert.ok(!exited, "Process should not have exited yet"); } } })(); const EXPECTED_STDOUT_UNTIL_LISTENING = `\ 1. parent_start 2. first_child_start_and_exit 3. parent_after_first_child_exit 4. spawned_child_start 5. Listening at `; // followed by http://127.0.0.1:\n const EXPECTED_STDOUT_AT_PARENT_EXIT = `\ 6. child_received_http_request 7. data_from_child:kill_parent 8. parent_exit `; const EXPECTED_STDOUT_AT_CHILD_EXIT = `\ 9. spawned_child_exit `; await TestUtils.waitForCondition( () => stdoutText.startsWith(EXPECTED_STDOUT_UNTIL_LISTENING), "Waiting for (parent) process to start child with listening HTTP server" ); const url = stdoutText.replace(EXPECTED_STDOUT_UNTIL_LISTENING, "").trim(); ok(/^http:\/\/127\.0\.0\.1:\d+$/.test(url), `Found server URL: ${url}`); stdoutText = ""; // Reset now that we have confirmed its content. // When the server receives this request, it triggers exit of the process. const promiseResponseToExitParent = fetchResponseText(url, "DELETE"); info("Waiting for spawned (parent) process to exit"); const { exitCode } = await exitPromise; equal(exitCode, 0, "Got expected exit code"); equal(stdoutText, EXPECTED_STDOUT_AT_PARENT_EXIT, "stdout before exit"); stdoutText = ""; // Reset again after checking its content. // Now two things can happen: // - Either the child lives on, and we can send a request to it to ask the // child to exit as well. // - Or the process tree rooted at the parent dies, and the child is taken // with it. The initial fetch() might succeed or reject. A new fetch() is // most likely going to reject. // On Linux and macOS, the child appears to live on. // On Windows, the whole process tree dies, when run under this test // (but somehow not when the Python script is run in isolation...?). const responseToExitParent = await promiseResponseToExitParent; if (!responseToExitParent) { info("fetch() request to kill parent failed"); } info("Checking whether the child process is still alive..."); const responseToChildAlive = await fetchResponseText(url, "GET"); const wasChildAlive = !!responseToChildAlive; if (wasChildAlive) { // Mainly a sanity check: If the second request gots through, then the // first request should have received the expected response. equal(responseToExitParent, "child_process_still_alive_1", "Still alive 1"); equal(responseToChildAlive, "child_process_still_alive_2", "Still alive 2"); } else { info("fetch() request to check child liveness failed"); } if (AppConstants.platform === "win" && !isBreakAwayJob) { ok(!wasChildAlive, "Child process exits when the parent exits"); } else { ok(wasChildAlive, "Child process outlives parent"); } await stdoutDonePromise; if (wasChildAlive) { equal(stdoutText, EXPECTED_STDOUT_AT_CHILD_EXIT, "Stdout from child"); } else { equal(stdoutText, "", "No more stdout after parent exited (with child)"); } } // Tests spawning a process that exits after spawning a child process, and // verify that the parent process is detected as exited, while the child // process is still running. add_task(async function test_spawn_child_outliving_parent_process() { // Our subprocess implementation (subprocess_win.worker.js) puts the spawned // process in a job, and cleans up that job upon process exit by calling // TerminateJobObject. This causes all other (child) processes that are part // of the job to terminate. To make sure that the child process outlives the // parent, we have to launch it with the CREATE_BREAKAWAY_FROM_JOB flag. const isBreakAwayJob = AppConstants.platform == "win"; await do_test_spawn_parent_with_child(isBreakAwayJob); }); // On Windows, child processes are terminated along with the parent by default, // because our subprocess implementation puts the process in a Job, and the // TerminateJobObject call terminates all associated child processes. This // test confirms the default behavior, as opposed to what we see in // test_spawn_child_outliving_parent_process. add_task( { skip_if: () => AppConstants.platform != "win" }, async function test_child_terminated_on_process_termination() { await do_test_spawn_parent_with_child(false); } ); add_task(async function test_cleanup() { let { getSubprocessImplForTest } = ChromeUtils.importESModule( "resource://gre/modules/Subprocess.sys.mjs" ); let worker = getSubprocessImplForTest().Process.getWorker(); let openFiles = await worker.call("getOpenFiles", []); let processes = await worker.call("getProcesses", []); equal(openFiles.size, 0, "No remaining open files"); equal(processes.size, 0, "No remaining processes"); });