also looking at this
fix(webview): recover from renderer crashes, and stop dropping window.open()
#72
4 files
+729
-23
| @@ -0,0 +1,289 @@ | ||
| 1 | //! Bringing a window back after WebKit's content process dies. | |
| 2 | //! | |
| 3 | //! WKWebView renders in a separate process. When that process is killed — out | |
| 4 | //! of memory, a GPU fault, a WebKit bug — the view does not error, close, or | |
| 5 | //! notify the page. It goes white and stays white, and the only route back is | |
| 6 | //! `reload`, which craft offered exclusively as a menu item the user had to | |
| 7 | //! know to click. For an app meant to sit open for days, a blip became a dead | |
| 8 | //! window. | |
| 9 | //! | |
| 10 | //! Reloading is the whole fix, and reloading unconditionally is the whole | |
| 11 | //! problem: a page that reliably kills the renderer — an OOM loop, a bad | |
| 12 | //! WebGL context — would be reloaded into the same crash forever, pinning a | |
| 13 | //! core. So the reloads are budgeted. | |
| 14 | //! | |
| 15 | //! ## Why the budget is a rate and not a count | |
| 16 | //! | |
| 17 | //! A plain "three tries and stop" is wrong for the case this exists to serve. | |
| 18 | //! An app open for a week that loses its renderer once a day is healthy, and | |
| 19 | //! it would spend its three lives in the first three days and then be a white | |
| 20 | //! window for the rest of the week. What distinguishes a crash loop from bad | |
| 21 | //! luck is not how many crashes there have been but how close together they | |
| 22 | //! are — so a window that has stayed up for `reset_after_ns` gets its budget | |
| 23 | //! back. `attempts` counts a burst, not a lifetime. | |
| 24 | //! | |
| 25 | //! This module is pure: the caller supplies the clock, and nothing here talks | |
| 26 | //! to Objective-C. That is what lets a crash loop be tested without crashing | |
| 27 | //! anything. | |
| 28 | ||
| 29 | const std = ; | |
| 30 | ||
| 31 | pub const Action = enum { | |
| 32 | /// Reload. The content process relaunches on the next load. | |
| 33 | reload, | |
| 34 | /// Stop reloading and show the page that says so. Reloading again would | |
| 35 | /// just be the same crash. | |
| 36 | give_up, | |
| 37 | }; | |
| 38 | ||
| 39 | pub const Budget = struct { | |
| 40 | /// Reloads allowed within one burst. | |
| 41 | max_attempts: u8 = 3, | |
| 42 | /// Stay up this long and the burst is over. A minute is far longer than a | |
| 43 | /// crash loop takes to go round and far shorter than the gap between two | |
| 44 | /// unrelated failures. | |
| 45 | reset_after_ns: i128 = 60 * std.time.ns_per_s, | |
| 46 | }; | |
| 47 | ||
| 48 | pub const State = struct { | |
| 49 | attempts: u8 = 0, | |
| 50 | last_crash_ns: ?i128 = null, | |
| 51 | }; | |
| 52 | ||
| 53 | /// Record a crash and say what to do about it. | |
| 54 | pub fn onCrash(state: *State, now_ns: i128, budget: Budget) Action { | |
| 55 | const in_same_burst = if (state.last_crash_ns) |last| | |
| 56 | now_ns -| last < budget.reset_after_ns | |
| 57 | else | |
| 58 | false; | |
| 59 | ||
| 60 | state.attempts = if (in_same_burst) state.attempts +| 1 else 1; | |
| 61 | state.last_crash_ns = now_ns; | |
| 62 | ||
| 63 | return if (state.attempts <= budget.max_attempts) .reload else .give_up; | |
| 64 | } | |
| 65 | ||
| 66 | // ============================================================================= | |
| 67 | // Per-window state | |
| 68 | // ============================================================================= | |
| 69 | ||
| 70 | /// One budget per webview, so a window in a crash loop cannot spend the budget | |
| 71 | /// of a window that is fine. Craft opens one window today; #67 opens more, and | |
| 72 | /// a single shared counter would be exactly the bug that lands then. | |
| 73 | const max_tracked = 8; | |
| 74 | ||
| 75 | const Slot = struct { | |
| 76 | /// The webview this belongs to, as an opaque handle. Zero means free. | |
| 77 | key: usize = 0, | |
| 78 | state: State = .{}, | |
| 79 | }; | |
| 80 | ||
| 81 | var slots: [max_tracked]Slot = @splat(.{}); | |
| 82 | ||
| 83 | /// The budget for `key`, creating one if this webview has not crashed before. | |
| 84 | /// | |
| 85 | /// Full table: the least recently troubled slot is reused. Losing a count is | |
| 86 | /// the right failure — it costs one extra reload attempt for a window that is | |
| 87 | /// probably fine, where refusing to track would cost a window its recovery. | |
| 88 | pub fn stateFor(key: usize) *State { | |
| 89 | for (&slots) |*slot| { | |
| 90 | if (slot.key == key) return &slot.state; | |
| 91 | } | |
| 92 | for (&slots) |*slot| { | |
| 93 | if (slot.key == 0) { | |
| 94 | slot.* = .{ .key = key }; | |
| 95 | return &slot.state; | |
| 96 | } | |
| 97 | } | |
| 98 | ||
| 99 | var oldest: *Slot = &slots[0]; | |
| 100 | for (&slots) |*slot| { | |
| 101 | const slot_time = slot.state.last_crash_ns orelse std.math.minInt(i128); | |
| 102 | const oldest_time = oldest.state.last_crash_ns orelse std.math.minInt(i128); | |
| 103 | if (slot_time < oldest_time) oldest = slot; | |
| 104 | } | |
| 105 | oldest.* = .{ .key = key }; | |
| 106 | return &oldest.state; | |
| 107 | } | |
| 108 | ||
| 109 | /// Forget a webview's history. For window teardown, and for tests. | |
| 110 | pub fn forget(key: usize) void { | |
| 111 | for (&slots) |*slot| { | |
| 112 | if (slot.key == key) slot.* = .{}; | |
| 113 | } | |
| 114 | } | |
| 115 | ||
| 116 | pub fn resetAllForTesting() void { | |
| 117 | slots = @splat(.{}); | |
| 118 | } | |
| 119 | ||
| 120 | // ============================================================================= | |
| 121 | // The page shown once craft stops trying | |
| 122 | // ============================================================================= | |
| 123 | ||
| 124 | /// What the window shows after the budget runs out. | |
| 125 | /// | |
| 126 | /// The alternative is the white window this whole file exists to prevent — | |
| 127 | /// bounded retries without this just reach the original bug more slowly. It | |
| 128 | /// says what happened and offers the reload craft will no longer do on its | |
| 129 | /// own, which is also how the burst gets broken: a person clicking it is not | |
| 130 | /// a crash loop. | |
| 131 | /// | |
| 132 | /// Self-contained by necessity. There is no server, no bundle path, and no | |
| 133 | /// network guarantee at the point this is shown. | |
| 134 | pub const give_up_page = | |
| 135 | \\<!doctype html><meta charset="utf-8"> | |
| 136 | \\<meta name="viewport" content="width=device-width,initial-scale=1"> | |
| 137 | \\<title>Page stopped responding</title> | |
| 138 | \\<style> | |
| 139 | \\:root{color-scheme:light dark} | |
| 140 | \\body{margin:0;height:100vh;display:flex;align-items:center;justify-content:center; | |
| 141 | \\background:#fff;color:#1d1d1f; | |
| 142 | \\font:15px/1.5 -apple-system,BlinkMacSystemFont,"SF Pro Text",system-ui,sans-serif} | |
| 143 | \\@media (prefers-color-scheme:dark){body{background:#1e1e1e;color:#f5f5f7}} | |
| 144 | \\main{max-width:26rem;padding:2rem;text-align:center} | |
| 145 | \\h1{font-size:1.0625rem;font-weight:600;margin:0 0 .5rem} | |
| 146 | \\p{margin:0 0 1.25rem;opacity:.7} | |
| 147 | \\button{font:inherit;font-weight:500;padding:.4rem 1.1rem;border-radius:.375rem; | |
| 148 | \\border:1px solid rgba(0,0,0,.15);background:#fff;color:inherit;cursor:pointer} | |
| 149 | \\@media (prefers-color-scheme:dark){button{background:#333;border-color:rgba(255,255,255,.15)}} | |
| 150 | \\</style> | |
| 151 | \\<main> | |
| 152 | \\<h1>This page stopped responding</h1> | |
| 153 | \\<p>It was reloaded a few times and kept failing, so it has been left alone.</p> | |
| 154 | \\<button onclick="location.reload()">Reload</button> | |
| 155 | \\</main> | |
| 156 | ; | |
| 157 | ||
| 158 | const testing = std.testing; | |
| 159 | const second = std.time.ns_per_s; | |
| 160 | ||
| 161 | test "the first crash reloads" { | |
| 162 | var state: State = .{}; | |
| 163 | try testing.expectEqual(Action.reload, onCrash(&state, 0, .{})); | |
| 164 | } | |
| 165 | ||
| 166 | test "a crash loop is cut off after the budget" { | |
| 167 | var state: State = .{}; | |
| 168 | const budget: Budget = .{ .max_attempts = 3 }; | |
| 169 | try testing.expectEqual(Action.reload, onCrash(&state, 0, budget)); | |
| 170 | try testing.expectEqual(Action.reload, onCrash(&state, 1 * second, budget)); | |
| 171 | try testing.expectEqual(Action.reload, onCrash(&state, 2 * second, budget)); | |
| 172 | try testing.expectEqual(Action.give_up, onCrash(&state, 3 * second, budget)); | |
| 173 | try testing.expectEqual(Action.give_up, onCrash(&state, 4 * second, budget)); | |
| 174 | } | |
| 175 | ||
| 176 | test "a window that stayed up gets its budget back" { | |
| 177 | // The case a lifetime counter gets wrong: an app open for a week that | |
| 178 | // loses its renderer once a day is healthy, and must not be a white window | |
| 179 | // by Thursday. | |
| 180 | var state: State = .{}; | |
| 181 | const budget: Budget = .{ .max_attempts = 3, .reset_after_ns = 60 * second }; | |
| 182 | ||
| 183 | var day: i128 = 0; | |
| 184 | while (day < 7) : (day += 1) { | |
| 185 | const t = day * 24 * 60 * 60 * second; | |
| 186 | try testing.expectEqual(Action.reload, onCrash(&state, t, budget)); | |
| 187 | try testing.expectEqual(@as(u8, 1), state.attempts); | |
| 188 | } | |
| 189 | } | |
| 190 | ||
| 191 | test "the reset boundary is exact" { | |
| 192 | var state: State = .{}; | |
| 193 | const budget: Budget = .{ .max_attempts = 1, .reset_after_ns = 60 * second }; | |
| 194 | ||
| 195 | try testing.expectEqual(Action.reload, onCrash(&state, 0, budget)); | |
| 196 | // One nanosecond short of the window: still the same burst. | |
| 197 | try testing.expectEqual(Action.give_up, onCrash(&state, 60 * second - 1, budget)); | |
| 198 | // Exactly the window, measured from the previous crash: a new burst. | |
| 199 | try testing.expectEqual(Action.reload, onCrash(&state, 2 * 60 * second - 1, budget)); | |
| 200 | } | |
| 201 | ||
| 202 | test "a burst is measured between crashes, not from the first one" { | |
| 203 | // Crashes every 40 seconds never reset a 60-second window, however long it | |
| 204 | // goes on — that is a slow loop, and it is still a loop. | |
| 205 | var state: State = .{}; | |
| 206 | const budget: Budget = .{ .max_attempts = 3, .reset_after_ns = 60 * second }; | |
| 207 | try testing.expectEqual(Action.reload, onCrash(&state, 0, budget)); | |
| 208 | try testing.expectEqual(Action.reload, onCrash(&state, 40 * second, budget)); | |
| 209 | try testing.expectEqual(Action.reload, onCrash(&state, 80 * second, budget)); | |
| 210 | try testing.expectEqual(Action.give_up, onCrash(&state, 120 * second, budget)); | |
| 211 | } | |
| 212 | ||
| 213 | test "a clock that goes backwards does not hand out extra reloads" { | |
| 214 | // The caller passes CLOCK_MONOTONIC, so this should not happen on macOS or | |
| 215 | // Linux — but `compat.nanoTimestamp` falls back to wall-clock on Windows, | |
| 216 | // where an NTP correction can step backwards, and returns 0 outright if | |
| 217 | // the clock cannot be read. Saturating subtraction makes all of that read | |
| 218 | // as "no time passed", which keeps the burst intact rather than handing | |
| 219 | // out a fresh budget on every backwards step. | |
| 220 | var state: State = .{}; | |
| 221 | const budget: Budget = .{ .max_attempts = 2, .reset_after_ns = 60 * second }; | |
| 222 | try testing.expectEqual(Action.reload, onCrash(&state, 1000 * second, budget)); | |
| 223 | try testing.expectEqual(Action.reload, onCrash(&state, 500 * second, budget)); | |
| 224 | try testing.expectEqual(Action.give_up, onCrash(&state, 0, budget)); | |
| 225 | } | |
| 226 | ||
| 227 | test "the attempt count cannot wrap around into a fresh budget" { | |
| 228 | var state: State = .{}; | |
| 229 | const budget: Budget = .{ .max_attempts = 3, .reset_after_ns = 60 * second }; | |
| 230 | var i: usize = 0; | |
| 231 | while (i < 1000) : (i += 1) { | |
| 232 | _ = onCrash(&state, @as(i128, @intCast(i)), budget); | |
| 233 | } | |
| 234 | try testing.expectEqual(@as(u8, 255), state.attempts); | |
| 235 | try testing.expectEqual(Action.give_up, onCrash(&state, 1000, budget)); | |
| 236 | } | |
| 237 | ||
| 238 | test "each window has its own budget" { | |
| 239 | resetAllForTesting(); | |
| 240 | const a = stateFor(0x1000); | |
| 241 | const b = stateFor(0x2000); | |
| 242 | const budget: Budget = .{ .max_attempts = 1 }; | |
| 243 | ||
| 244 | try testing.expectEqual(Action.reload, onCrash(a, 0, budget)); | |
| 245 | try testing.expectEqual(Action.give_up, onCrash(a, 1, budget)); | |
| 246 | // b has spent nothing, and a's loop must not have spent it for them. | |
| 247 | try testing.expectEqual(Action.reload, onCrash(stateFor(0x2000), 2, budget)); | |
| 248 | _ = b; | |
| 249 | } | |
| 250 | ||
| 251 | test "the same window gets the same budget back" { | |
| 252 | resetAllForTesting(); | |
| 253 | _ = onCrash(stateFor(0x1000), 0, .{}); | |
| 254 | try testing.expectEqual(@as(u8, 1), stateFor(0x1000).attempts); | |
| 255 | _ = onCrash(stateFor(0x1000), second, .{}); | |
| 256 | try testing.expectEqual(@as(u8, 2), stateFor(0x1000).attempts); | |
| 257 | } | |
| 258 | ||
| 259 | test "a closed window is forgotten" { | |
| 260 | resetAllForTesting(); | |
| 261 | _ = onCrash(stateFor(0x1000), 0, .{}); | |
| 262 | forget(0x1000); | |
| 263 | try testing.expectEqual(@as(u8, 0), stateFor(0x1000).attempts); | |
| 264 | } | |
| 265 | ||
| 266 | test "more windows than the table holds still each get tracked" { | |
| 267 | resetAllForTesting(); | |
| 268 | var i: usize = 1; | |
| 269 | while (i <= max_tracked + 4) : (i += 1) { | |
| 270 | const state = stateFor(i * 0x100); | |
| 271 | try testing.expectEqual(Action.reload, onCrash(state, @intCast(i), .{})); | |
| 272 | } | |
| 273 | // The most recent windows are the ones still held. | |
| 274 | try testing.expectEqual(@as(u8, 1), stateFor((max_tracked + 4) * 0x100).attempts); | |
| 275 | } | |
| 276 | ||
| 277 | test "the give-up page carries its own styling and a way back" { | |
| 278 | // It is shown when there is no server, no bundle path and possibly no | |
| 279 | // network, so anything it references it must contain. | |
| 280 | try testing.expect(std.mem.indexOf(u8, give_up_page, "location.reload()") != null); | |
| 281 | try testing.expect(std.mem.indexOf(u8, give_up_page, "<style>") != null); | |
| 282 | try testing.expect(std.mem.indexOf(u8, give_up_page, "prefers-color-scheme") != null); | |
| 283 | try testing.expect(std.mem.indexOf(u8, give_up_page, "src=") == null); | |
| 284 | try testing.expect(std.mem.indexOf(u8, give_up_page, "href=") == null); | |
| 285 | // No apostrophes: it is passed to Objective-C as a C string, but it is | |
| 286 | // also the kind of literal that ends up inside an `evaluateJavaScript` | |
| 287 | // single-quoted string one refactor later. | |
| 288 | try testing.expect(std.mem.indexOfScalar(u8, give_up_page, '\'') == null); | |
| 289 | } | |