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 {...@@ -151,7 +151,6 @@ mod web {
151 timeout: Duration,151 timeout: Duration,
152 ) -> Result<T, std::sync::mpsc::RecvTimeoutError> {152 ) -> Result<T, std::sync::mpsc::RecvTimeoutError> {
153 let deadline = web_time::Instant::now() + timeout;153 let deadline = web_time::Instant::now() + timeout;
154 let mut armed = false;
155 let mut answer = std::pin::pin!(receive(receiver));154 let mut answer = std::pin::pin!(receive(receiver));
156 std::future::poll_fn(|context| {155 std::future::poll_fn(|context| {
157 if let Poll::Ready(answer) = answer.as_mut().poll(context) {156 if let Poll::Ready(answer) = answer.as_mut().poll(context) {
...@@ -162,9 +161,10 @@ mod web {...@@ -162,9 +161,10 @@ mod web {
162 if web_time::Instant::now() >= deadline {161 if web_time::Instant::now() >= deadline {
163 return Poll::Ready(Err(std::sync::mpsc::RecvTimeoutError::Timeout));162 return Poll::Ready(Err(std::sync::mpsc::RecvTimeoutError::Timeout));
164 }163 }
165 if !std::mem::replace(&mut armed, true) {164 after(
166 after(timeout, context.waker().clone());165 deadline.saturating_duration_since(web_time::Instant::now()),
167 }166 context.waker().clone(),
167 );
168 Poll::Pending168 Poll::Pending
169 })169 })
170 .await170 .await
...@@ -227,12 +227,11 @@ mod web {...@@ -227,12 +227,11 @@ mod web {
227 return waker.wake();227 return waker.wake();
228 };228 };
229 let wake = Closure::once_into_js(move || waker.wake());229 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());
231 }231 }
232232
233 pub(crate) async fn wait(receiver: &Receiver<()>, timeout: Option<Duration>) {233 pub(crate) async fn wait(receiver: &Receiver<()>, timeout: Option<Duration>) {
234 let deadline = timeout.map(|timeout| web_time::Instant::now() + timeout);234 let deadline = timeout.map(|timeout| web_time::Instant::now() + timeout);
235 let mut armed = false;
236 std::future::poll_fn(|context| {235 std::future::poll_fn(|context| {
237 let Ok(mut bell) = receiver.0.lock() else {236 let Ok(mut bell) = receiver.0.lock() else {
238 return Poll::Ready(());237 return Poll::Ready(());
...@@ -243,9 +242,7 @@ mod web {...@@ -243,9 +242,7 @@ mod web {
243 return Poll::Ready(());242 return Poll::Ready(());
244 }243 }
245 bell.waiting = Some(context.waker().clone());244 bell.waiting = Some(context.waker().clone());
246 if let Some(deadline) = deadline245 if let Some(deadline) = deadline {
247 && !std::mem::replace(&mut armed, true)
248 {
249 after(246 after(
250 deadline.saturating_duration_since(web_time::Instant::now()),247 deadline.saturating_duration_since(web_time::Instant::now()),
251 context.waker().clone(),248 context.waker().clone(),
tools/web/receipt_probe.mjs+10-2
...@@ -7,7 +7,14 @@ import { setTimeout } from 'node:timers';...@@ -7,7 +7,14 @@ import { setTimeout } from 'node:timers';
7globalThis.crypto ??= webcrypto;7globalThis.crypto ??= webcrypto;
8let failure = 0;8let failure = 0;
9let phase = 'initializing wasm';9let 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};
11const failures = [0, 0, 0];18const failures = [0, 0, 0];
12globalThis.snowboundReceiptPhase = value => { phase = value; };19globalThis.snowboundReceiptPhase = value => { phase = value; };
13globalThis.snowboundReceiptFailure = value => { failure = value; };20globalThis.snowboundReceiptFailure = value => { failure = value; };
...@@ -26,7 +33,8 @@ try {...@@ -26,7 +33,8 @@ try {
26 await wasm.receipt_probe();33 await wasm.receipt_probe();
27 assert.ok(failures[1] >= 2, 'The active and dropped owner each retry synchronous storage failure');34 assert.ok(failures[1] >= 2, 'The active and dropped owner each retry synchronous storage failure');
28 assert.ok(failures[2] >= 1, 'An asynchronous storage rejection also keeps the receipt pending');35 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`);
30} finally {38} finally {
31 clearTimeout(timeout);39 clearTimeout(timeout);
32}40}