[2026-07-10] eagle: work/projects/navigation-framework-tests-webkit-process-cleanup.md
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
# Navigation Framework Tests — WebKit Web Content Process Cleanup
|
||||
|
||||
tags: #webkit #testing #navigation #webcontentprocess
|
||||
date: 2026-07-10
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Investigating how the WebKit test suite handles web content process lifetime between tests, specifically to prevent processes from piling up in memory and causing test runner hangs.
|
||||
|
||||
---
|
||||
|
||||
## How WebKit Tests Handle Web Content Process Lifetime
|
||||
|
||||
### The dominant approach: reuse, not kill
|
||||
|
||||
LayoutTests (via `WebKitTestRunner`) **deliberately reuse the same web content process** across tests for speed. Between tests, `resetStateToConsistentValues()` performs a soft reset:
|
||||
|
||||
1. Navigate to `about:blank`
|
||||
2. Call `WKPageResetStateBetweenTests` (soft in-process reset — process stays alive)
|
||||
3. Clear back/forward list and cache
|
||||
4. Only fall back to `WKPageTerminate` if loading `about:blank` fails
|
||||
|
||||
If the process becomes fully unresponsive, `TestInvocation.cpp` terminates it and calls `reattachPageToWebProcess()` to get a fresh one:
|
||||
|
||||
```cpp
|
||||
if (TestController::singleton().resetStateToConsistentValues(...))
|
||||
return;
|
||||
// The process is unresponsive, so let's start a new one.
|
||||
TestController::singleton().terminateWebContentProcess();
|
||||
TestController::singleton().reattachPageToWebProcess();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Explicit Termination APIs
|
||||
|
||||
| API | Granularity | Notes |
|
||||
|-----|------------|-------|
|
||||
| `_killWebContentProcessAndResetState` (ObjC) | Single `WKWebView` | Graceful via `requestTermination`, also kills provisional process |
|
||||
| `_killWebContentProcess` (ObjC) | Single `WKWebView` | Hard kill via `AuxiliaryProcessProxy::terminate()` |
|
||||
| `_terminateAllWebContentProcesses` (ObjC) | Entire `WKProcessPool` | Calls `requestTermination` on every process in pool |
|
||||
| `WKPageTerminate` (C API) | Single page | Used internally by `WebKitTestRunner` |
|
||||
| `webkit_web_view_terminate_web_process` (GLib) | Single view | GTK/WPE equivalent |
|
||||
| `terminateWebContentProcess()` (Swift `@_spi(Testing)`) | `WebPage` | Wraps `_killWebContentProcess` |
|
||||
| `Internals::terminateWebContentProcess()` | From JS inside page | Calls `exit(0)` in web process |
|
||||
|
||||
---
|
||||
|
||||
## LRU-Based Automatic Eviction (`_setWebProcessCountLimit`)
|
||||
|
||||
`[WKProcessPool _setWebProcessCountLimit:N]` sets a hard cap. When a new process would exceed it, the **least-recently-used** process is terminated:
|
||||
|
||||
```cpp
|
||||
// WebProcessProxy::create()
|
||||
if (liveProcessesLRU().computeSize() >= s_maxProcessCount) {
|
||||
for (auto& processPool : WebProcessPool::allProcessPools())
|
||||
processPool->webProcessCache().clear();
|
||||
if (liveProcessesLRU().computeSize() >= s_maxProcessCount)
|
||||
protect(liveProcessesLRU().first())->requestTermination(
|
||||
ProcessTerminationReason::ExceededProcessCountLimit);
|
||||
}
|
||||
ASSERT(liveProcessesLRU().computeSize() < s_maxProcessCount);
|
||||
liveProcessesLRU().add(proxy.get());
|
||||
proxy->connect();
|
||||
```
|
||||
|
||||
Default limit is **400**. Test `WebProcessLimit` in `WebContentProcessDidTerminate.mm` exercises this.
|
||||
|
||||
---
|
||||
|
||||
## Will `_setWebProcessCountLimit` Resolve Test Runner Hangs?
|
||||
|
||||
**Short answer: No — and it can introduce a new kind of hang.**
|
||||
|
||||
### What happens at the limit
|
||||
|
||||
Eviction is **fully synchronous** on the main thread — `requestTermination` immediately calls `processDidTerminateOrFailedToLaunch`, which removes the LRU from the tracking set before the new process is even added. No blocking wait at the limit.
|
||||
|
||||
### Why it can cause hangs instead
|
||||
|
||||
`processDidTerminateOrFailedToLaunch` fires `dispatchProcessDidTerminate` on every page owned by the evicted process:
|
||||
|
||||
```cpp
|
||||
for (auto& page : pages)
|
||||
page->resetStateAfterProcessTermination(reason);
|
||||
for (auto& page : pages)
|
||||
page->dispatchProcessDidTerminate(*this, reason); // fires delegate callback
|
||||
```
|
||||
|
||||
If a **test is blocked in a run loop waiting for a response from the now-killed process** (navigation completion, JS evaluation, policy decision), that callback never fires and the test hangs indefinitely.
|
||||
|
||||
### `ExceededProcessCountLimit` does NOT trigger auto-reload
|
||||
|
||||
Unlike crashes or memory-limit kills, LRU eviction is explicitly excluded from the auto-reload path:
|
||||
|
||||
```cpp
|
||||
static bool shouldReloadAfterProcessTermination(ProcessTerminationReason reason)
|
||||
{
|
||||
switch (reason) {
|
||||
case ProcessTerminationReason::Crash:
|
||||
case ProcessTerminationReason::ExceededMemoryLimit:
|
||||
return true; // ← auto-reload
|
||||
case ProcessTerminationReason::ExceededProcessCountLimit:
|
||||
case ProcessTerminationReason::RequestedByClient:
|
||||
break; // ← no auto-reload
|
||||
}
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
So the evicted pages are left in a dead-process state with no recovery.
|
||||
|
||||
---
|
||||
|
||||
## Correct Approaches for Test Runner Process Accumulation
|
||||
|
||||
| Approach | Notes |
|
||||
|----------|-------|
|
||||
| Explicitly `nil` / release `WKWebView` after each test | Processes are released by their owners |
|
||||
| `[pool _terminateAllWebContentProcesses]` in `tearDown` / `afterEach` | Controlled bulk eviction at a safe point |
|
||||
| Single shared `WKWebView` per suite, reset with `WKPageResetStateBetweenTests` + `about:blank` | The LayoutTests model — fastest |
|
||||
| `nonPersistentDataStore` per test | Data isolation without spawning extra processes |
|
||||
| `[WKWebsiteDataStore removeDataOfTypes:...]` | Storage cleanup without touching processes |
|
||||
|
||||
Using the process count cap as the **primary** mechanism is fragile: the cap is applied globally and may evict a process that the currently-running test is actively waiting on, trading a memory problem for a deadlock.
|
||||
Reference in New Issue
Block a user