Skip to content

Commit 6ff050c

Browse files
authored
Merge pull request #6041 from cloudflare/yagiz/fix-settimeout-inconsistencies-2
fix: use raw timer time for setTimeout scheduling to prevent stale clock clamping
2 parents 232c276 + cfe283c commit 6ff050c

File tree

6 files changed

+57
-25
lines changed

6 files changed

+57
-25
lines changed
Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,57 @@
11
import { ok } from 'node:assert';
2+
import timers from 'node:timers/promises';
23

34
// Allow up to 10ms of jitter because the precise_timers compat flag
45
// can introduce +/-3ms of variance in timer resolution, and coverage
56
// builds add additional overhead.
67
const JITTER = 10;
78

8-
export default {
9+
// The first setTimeout was firing too early because
10+
// kj::Timer::now() was stale after script compilation/startup.
11+
// Ref: https://github.com/cloudflare/workerd/issues/6019
12+
export const basicAccuracy = {
913
async test() {
1014
const t0 = Date.now();
11-
await new Promise((accept) => setTimeout(accept, 100));
15+
await timers.setTimeout(100);
1216
const t1 = Date.now();
1317
ok(t1 - t0 >= 100 - JITTER, `Received a difference of ${t1 - t0}`);
14-
await new Promise((accept) => setTimeout(accept, 100));
18+
await timers.setTimeout(100);
1519
const t2 = Date.now();
1620
ok(t2 - t1 >= 100 - JITTER, `Received a difference of ${t2 - t1}`);
1721
},
1822
};
23+
24+
// After CPU-heavy work between setTimeout calls,
25+
// subsequent consecutive setTimeouts must still each wait their full delay
26+
// rather than all firing at the same instant.
27+
// Ref: https://github.com/cloudflare/workerd/issues/6037
28+
export const accuracyAfterCpuWork = {
29+
async test() {
30+
await timers.setTimeout(50);
31+
32+
// Let's burn some CPU
33+
for (let j = 0; j < 1e9; j++);
34+
35+
const a = Date.now();
36+
await timers.setTimeout(50);
37+
const b = Date.now();
38+
ok(
39+
b - a >= 50 - JITTER,
40+
`After CPU work, first sleep: expected ~50ms, got ${b - a}ms`
41+
);
42+
43+
await timers.setTimeout(50);
44+
const c = Date.now();
45+
ok(
46+
c - b >= 50 - JITTER,
47+
`After CPU work, second sleep: expected ~50ms, got ${c - b}ms`
48+
);
49+
50+
await timers.setTimeout(50);
51+
const d = Date.now();
52+
ok(
53+
d - c >= 50 - JITTER,
54+
`After CPU work, third sleep: expected ~50ms, got ${d - c}ms`
55+
);
56+
},
57+
};

src/workerd/io/io-channels.h

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,10 @@ class TimerChannel {
5656
// Call each time control enters the isolate to set up the clock.
5757
virtual void syncTime() = 0;
5858

59-
// Return the current time.
60-
virtual kj::Date now() = 0;
59+
// Return the current time. `nextTimeout` is the time at which the next setTimeout() callback
60+
// is scheduled; implementations performing Spectre mitigations should clamp to this value so
61+
// that Date.now() never goes backwards or reveals timing side channels.
62+
virtual kj::Date now(kj::Maybe<kj::Date> nextTimeout = kj::none) = 0;
6163

6264
// Returns a promise that resolves once `now() >= when`.
6365
virtual kj::Promise<void> atTime(kj::Date when) = 0;

src/workerd/io/io-context.c++

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -278,9 +278,9 @@ void IoContext::IncomingRequest::delivered(kj::SourceLocation location) {
278278
}
279279
}
280280

281-
kj::Date IoContext::IncomingRequest::now() {
281+
kj::Date IoContext::IncomingRequest::now(kj::Maybe<kj::Date> nextTimeout) {
282282
metrics->clockRead();
283-
return ioChannelFactory->getTimer().now();
283+
return ioChannelFactory->getTimer().now(kj::mv(nextTimeout));
284284
}
285285

286286
IoContext::IncomingRequest::~IoContext_IncomingRequest() noexcept(false) {
@@ -730,8 +730,7 @@ void IoContext::TimeoutManagerImpl::setTimeoutImpl(IoContext& context, Iterator
730730

731731
auto paf = kj::newPromiseAndFulfiller<void>();
732732

733-
// Always schedule the timeout relative to what Date.now() currently returns, so that the delay
734-
// appear exact. Otherwise, the delay could reveal non-determinism containing side channels.
733+
// Schedule relative to Date.now() so the delay appears exact to the application.
735734
auto when = context.now() + state.params.msDelay * kj::MILLISECONDS;
736735
// TODO(cleanup): The manual use of run() here (including carrying over the critical section) is
737736
// kind of ugly, but using awaitIo() doesn't work here because we need the ability to cancel
@@ -892,17 +891,9 @@ kj::Date IoContext::now(IncomingRequest& incomingRequest) {
892891
return kj::UNIX_EPOCH + roundedMs * kj::MILLISECONDS;
893892
}
894893

895-
kj::Date adjustedTime = incomingRequest.now();
896-
897-
KJ_IF_SOME(maybeNextTimeout, timeoutManager->getNextTimeout()) {
898-
// Don't return a time beyond when the next setTimeout() callback is intended to run. This
899-
// ensures that Date.now() inside the callback itself always returns exactly the time at which
900-
// the callback was scheduled (hiding non-determinism which could contain side channels), and
901-
// that the time returned by Date.now() never goes backwards.
902-
return kj::min(adjustedTime, maybeNextTimeout);
903-
} else {
904-
return adjustedTime;
905-
}
894+
// Let TimerChannel decide whether to clamp to the next timeout time. This is how Spectre
895+
// mitigations ensure Date.now() inside a callback returns exactly the scheduled time.
896+
return incomingRequest.now(timeoutManager->getNextTimeout());
906897
}
907898

908899
kj::Date IoContext::now() {

src/workerd/io/io-context.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ class IoContext_IncomingRequest final {
166166
// Access the event loop's current time point. This will remain constant between ticks. This is
167167
// used to implement IoContext::now(), which should be preferred so that time can be adjusted
168168
// based on setTimeout() when needed.
169-
kj::Date now();
169+
kj::Date now(kj::Maybe<kj::Date> nextTimeout = kj::none);
170170

171171
RequestObserver& getMetrics() {
172172
return *metrics;

src/workerd/server/server.c++

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3381,12 +3381,12 @@ class Server::WorkerService final: public Service,
33813381
// Nothing to do
33823382
}
33833383

3384-
kj::Date now() override {
3384+
kj::Date now(kj::Maybe<kj::Date>) override {
33853385
return kj::systemPreciseCalendarClock().now();
33863386
}
33873387

33883388
kj::Promise<void> atTime(kj::Date when) override {
3389-
auto delay = when - now();
3389+
auto delay = when - now(kj::none);
33903390
// We can't use `afterDelay(delay)` here because kj::Timer::afterDelay() is equivalent to
33913391
// `atTime(timer.now() + delay)`, and kj::Timer::now() only advances when the event loop
33923392
// polls for I/O. If JavaScript executed for a significant amount of time since the last

src/workerd/tests/test-fixture.c++

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ class DummyErrorHandler final: public kj::TaskSet::ErrorHandler {
5757
struct MockTimerChannel final: public TimerChannel {
5858
void syncTime() override {}
5959

60-
kj::Date now() override {
60+
kj::Date now(kj::Maybe<kj::Date>) override {
6161
return kj::systemPreciseCalendarClock().now();
6262
}
6363

@@ -78,7 +78,7 @@ struct RealTimerChannel final: public TimerChannel {
7878

7979
void syncTime() override {}
8080

81-
kj::Date now() override {
81+
kj::Date now(kj::Maybe<kj::Date>) override {
8282
return kj::systemPreciseCalendarClock().now();
8383
}
8484

0 commit comments

Comments
 (0)