Timers look trivial until you ask what “pending” means. The honest answer has three states: a ref’d pending timer is work the run must wait for; an unref’d one is work that may be abandoned; and a cancelled one is gone — all three now exist and are pinned end to end.
Borrowed machinery, owned contract. deno_core already ships Node-style
timers (Deno.core.createTimer) with ref-counting wired into the event loop
— ref’d timers keep the isolate’s loop alive, unref’d ones don’t. What was
missing is the public surface: no setTimeout global exists by default, and
the rt.* contract says everything a script can do lives in one namespace.
So rt.setTimeout/setInterval wrap createTimer and return ids from a map
that fired one-shots remove themselves from — the same map that feeds the
pending-work report in checkpoint 2.3.
What the tests pinned. rt.setTimeout(cb, 30) — the loop held, “end”
printed before “fired” (ordering matters: it proves the loop stayed open for
the timer, not that it raced). clearTimeout — the callback never ran.
setInterval — fired exactly three times, clearInterval stopped it.
unrefTimer on a 60-second timer — the process exited in ~10ms, proof the
loop isn’t hostage to abandoned work. The ref’d 60-second timer — died to
the watchdog at 400ms with exit 6, proof the bound holds where Node itself
would just wait a minute. And setTimeout bare (not under rt.) —
ReferenceError, exit 5: the sandbox doesn’t leak ambient globals.
Lesson: “add timers” is the wrong frame. The frame is who keeps the
loop alive — ref-counting is the whole semantics, and it’s the difference
between a runtime whose run command finishes and one that waits an hour
for a timer nobody wanted.