ReviewOS

also looking at this

craft-native/craft

fix(webview): recover from renderer crashes, and stop dropping window.open()

#72
Merged glennmichael123 wants to merge fix/webview-delegates into main
4 files +729 -23
packages/zig/src/webview_recovery.zigadded+289-0
Changes to packages/zig/src/webview_recovery.zig
@@ -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
29const std = ;
30
31pub 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
39pub 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
48pub 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.
54pub 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.
73const max_tracked = 8;
74
75const Slot = struct {
76 /// The webview this belongs to, as an opaque handle. Zero means free.
77 key: usize = 0,
78 state: State = .{},
79};
80
81var 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.
88pub 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.
110pub fn forget(key: usize) void {
111 for (&slots) |*slot| {
112 if (slot.key == key) slot.* = .{};
113 }
114}
115
116pub 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.
134pub 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
158const testing = std.testing;
159const second = std.time.ns_per_s;
160
161test "the first crash reloads" {
162 var state: State = .{};
163 try testing.expectEqual(Action.reload, onCrash(&state, 0, .{}));
164}
165
166test "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
176test "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
191test "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
202test "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
213test "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
227test "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
238test "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
251test "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
259test "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
266test "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
277test "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}