authorclover caruso <git@paperclover.net> 2026-10-10 19:11:58-07:00
committerclover caruso <git@paperclover.net> 2026-10-10 22:56:05-07:00
log7802ff53b2f17ccccf6e2589b59f9513202f7e07
tree26708b848f0fad2cfec866d0ad17e2fa92f5141b
parenta217722937719101c12d3c2521a03b33abc32c8c
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix(web): rearm browser waits after early timers

A browser timer can fire before its requested Instant deadline. One-shot armed flags then left waits pending forever without another wake, stranding durability retries and Live Share response timeouts. Rearm remaining waits on each pending poll and round host millisecond delays upward. Force an early retry timer in the actual-wasm receipt probe and require both accepted edit and later barrier to stay pending through storage failures, then recover with one publication and retained dropped-owner ownership. Validation: actual wasm probe passed with 3 synchronous host throws, 1 Promise rejection, forced early timer, 1 publication, responsive reads, and dropped-owner busy/reopen. Native all-feature notebook+snowbound check, wasm wgpu/live check, JavaScript 9/9, and formatting passed on the identical combined source tree. Fixes #113 Assisted-by: gpt-6.1-sol

2 files changed, 16 insertions(+), 11 deletions(-)

crates/notebook/src/task.rs+6-9
......@@ -151,7 +151,6 @@ mod web {
151151 timeout: Duration,
152152 ) -> Result<T, std::sync::mpsc::RecvTimeoutError> {
153153 let deadline = web_time::Instant::now() + timeout;
154 let mut armed = false;
155154 let mut answer = std::pin::pin!(receive(receiver));
156155 std::future::poll_fn(|context| {
157156 if let Poll::Ready(answer) = answer.as_mut().poll(context) {
......@@ -162,9 +161,10 @@ mod web {
162161 if web_time::Instant::now() >= deadline {
163162 return Poll::Ready(Err(std::sync::mpsc::RecvTimeoutError::Timeout));
164163 }
165 if !std::mem::replace(&mut armed, true) {
166 after(timeout, context.waker().clone());
167 }
164 after(
165 deadline.saturating_duration_since(web_time::Instant::now()),
166 context.waker().clone(),
167 );
168168 Poll::Pending
169169 })
170170 .await
......@@ -227,12 +227,11 @@ mod web {
227227 return waker.wake();
228228 };
229229 let wake = Closure::once_into_js(move || waker.wake());
230 let _ = set_timeout.call2(&global, &wake, &(delay.as_secs_f64() * 1e3).into());
230 let _ = set_timeout.call2(&global, &wake, &(delay.as_secs_f64() * 1e3).ceil().into());
231231 }
232232
233233 pub(crate) async fn wait(receiver: &Receiver<()>, timeout: Option<Duration>) {
234234 let deadline = timeout.map(|timeout| web_time::Instant::now() + timeout);
235 let mut armed = false;
236235 std::future::poll_fn(|context| {
237236 let Ok(mut bell) = receiver.0.lock() else {
238237 return Poll::Ready(());
......@@ -243,9 +242,7 @@ mod web {
243242 return Poll::Ready(());
244243 }
245244 bell.waiting = Some(context.waker().clone());
246 if let Some(deadline) = deadline
247 && !std::mem::replace(&mut armed, true)
248 {
245 if let Some(deadline) = deadline {
249246 after(
250247 deadline.saturating_duration_since(web_time::Instant::now()),
251248 context.waker().clone(),
tools/web/receipt_probe.mjs+10-2
......@@ -7,7 +7,14 @@ import { setTimeout } from 'node:timers';
77globalThis.crypto ??= webcrypto;
88let failure = 0;
99let phase = 'initializing wasm';
10globalThis.setTimeout = (callback, delay, ...args) => setTimeout(callback, delay, ...args).unref();
10let early = true;
11globalThis.setTimeout = (callback, delay, ...args) => {
12 if (early && failure === 2 && delay <= 1000) {
13 early = false;
14 delay = 0;
15 }
16 return setTimeout(callback, delay, ...args).unref();
17};
1118const failures = [0, 0, 0];
1219globalThis.snowboundReceiptPhase = value => { phase = value; };
1320globalThis.snowboundReceiptFailure = value => { failure = value; };
......@@ -26,7 +33,8 @@ try {
2633 await wasm.receipt_probe();
2734 assert.ok(failures[1] >= 2, 'The active and dropped owner each retry synchronous storage failure');
2835 assert.ok(failures[2] >= 1, 'An asynchronous storage rejection also keeps the receipt pending');
29 console.log(`Actual wasm receipts passed: ${failures[1]} throws, ${failures[2]} rejections, one publication, responsive reads, retained ownership`);
36 assert.equal(early, false, 'A retry timer fired before its deadline');
37 console.log(`Actual wasm receipts passed: ${failures[1]} throws, ${failures[2]} rejections, early timer, one publication, responsive reads, retained ownership`);
3038} finally {
3139 clearTimeout(timeout);
3240}