diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/interval-slow-callback.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/interval-slow-callback.ts.json new file mode 100644 index 000000000..9e7ab676c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/interval-slow-callback.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/interval-slow-callback.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 43b38e77a..5647a596f 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -58,6 +58,9 @@ #include #include #include +#ifdef __APPLE__ +#include +#endif #endif #if defined(__has_feature) @@ -1052,6 +1055,44 @@ double scr_now_ms(void) { return (double)ts.tv_sec * 1000.0 + (double)ts.tv_nsec / 1e6; } +/* The loop's sleep to its next timer when nothing else can wake it. macOS + * gives nanosleep the timer slack of the thread's QoS class: a 5 ms sleep + * takes about 38 ms at utility QoS and 180 ms at background QoS, which is + * where launchd services, CI jobs among them, run. A kevent timeout keeps + * its deadline at every QoS, as libuv's loop does. */ +#if defined(__APPLE__) +/* One queue with no events for every thread that sleeps (worker loops + * included), created on first use. */ +static _Atomic int scr_sleep_kq = -1; +#endif +static void scr_loop_sleep(double wait_ms) { + struct timespec ts = {(time_t)(wait_ms / 1000.0), + (long)((wait_ms - (double)((time_t)(wait_ms / 1000.0)) * 1000.0) * 1e6)}; +#if defined(__APPLE__) + int kq = atomic_load_explicit(&scr_sleep_kq, memory_order_acquire); + if (kq < 0) { + int created = kqueue(); + if (created >= 0) { + (void)fcntl(created, F_SETFD, FD_CLOEXEC); + int none = -1; + if (atomic_compare_exchange_strong(&scr_sleep_kq, &none, created)) { + kq = created; + } else { + close(created); + kq = none; + } + } + } + if (kq >= 0) { + /* An interrupted wait returns early, as nanosleep does. */ + struct kevent ignored; + (void)kevent(kq, NULL, 0, &ignored, 1, &ts); + return; + } +#endif + nanosleep(&ts, NULL); +} + static bool scr_timer_before(const ScrTimer *a, const ScrTimer *b) { if (a->deadline_ms != b->deadline_ms) return a->deadline_ms < b->deadline_ms; return a->seq < b->seq; @@ -5962,9 +6003,7 @@ static bool scr_loop_run_pass(ScrPromise *top_level, bool first_checkpoint) { } else #endif if (due > now) { - double wait = due - now; - struct timespec ts = {(time_t)(wait / 1000.0), (long)((wait - (double)((time_t)(wait / 1000.0)) * 1000.0) * 1e6)}; - nanosleep(&ts, NULL); + scr_loop_sleep(due - now); now = due; } } @@ -5986,13 +6025,16 @@ static bool scr_loop_run_pass(ScrPromise *top_level, bool first_checkpoint) { scr_firing_reffed = t.reffed; /* unref/ref during the callback lands here */ scr_firing_refresh = false; } + double started = t.id != 0 && t.repeat_ms > 0 ? scr_now_ms() : 0; ((void (*)(ScrClosure *))t.cb->fn)(t.cb); if (t.id != 0 && t.repeat_ms > 0 && !scr_firing_cleared) { - /* Re-arm relative to the post-callback clock (libuv's uv_timer - * repeat behavior: no catch-up bursts after a slow callback). The - * ref state carries across ticks (a self-unref'd interval stays - * unref'd). */ - ScrTimer again = {scr_now_ms() + t.repeat_ms, scr_timer_seq++, t.cb, t.repeat_ms, t.id, scr_firing_reffed, t.delay_ms}; + /* Re-arm relative to the time the callback started, as Node's + * listOnTimeout does (getLibuvNow() before _onTimeout): a slow + * callback does not push the next tick back by its own duration. + * One slower than the interval makes the next tick due on the next + * turn, without catch-up bursts. The ref state carries across + * ticks (a self-unref'd interval stays unref'd). */ + ScrTimer again = {started + t.repeat_ms, scr_timer_seq++, t.cb, t.repeat_ms, t.id, scr_firing_reffed, t.delay_ms}; scr_timer_push(again); } else if (t.id != 0 && scr_firing_refresh && !scr_firing_cleared) { /* refresh() from inside the one-shot's own callback: re-arm to diff --git a/tests/corpus/interval-slow-callback.ts b/tests/corpus/interval-slow-callback.ts new file mode 100644 index 000000000..01f0f21d1 --- /dev/null +++ b/tests/corpus/interval-slow-callback.ts @@ -0,0 +1,23 @@ +// setInterval schedules each tick from the time its callback started, as +// Node does: a callback that takes 30 ms of a 50 ms interval keeps the ticks +// 50 ms apart, and one that takes 70 ms makes the next tick due at once (70 +// ms apart, not 120). Each line prints whether every gap after the first +// tick stays under a bound between those spacings and the ones a re-arm +// after the callback would give (80 and 120 ms). +function run(work: number, bound: number, done: () => void): void { + const gaps: number[] = []; + let last = -1; + const interval = setInterval(() => { + const now = performance.now(); + if (last >= 0) gaps.push(now - last); + last = now; + while (performance.now() - now < work) {} + if (gaps.length === 4) { + clearInterval(interval); + console.log(`work ${work} ms:`, gaps.every((gap) => gap < bound)); + done(); + } + }, 50); +} + +run(30, 75, () => run(70, 110, () => console.log("done"))); diff --git a/tests/harness/corpus-costs/interval-slow-callback.ts.json b/tests/harness/corpus-costs/interval-slow-callback.ts.json new file mode 100644 index 000000000..0a62b4037 --- /dev/null +++ b/tests/harness/corpus-costs/interval-slow-callback.ts.json @@ -0,0 +1 @@ +{ "plain": 5676, "san": 6800 }