/
Search
EN

Time: clocks, deadlines, and civil dates

Programs ask three different questions and call all of them "the time": how long did that take, what is today's date, and how much of the world went by while the machine was asleep. kola gives each its own reading with its own unit, and it treats reading a clock as an authority a function must be handed. This chapter covers both halves, then the std::time library for calendars and RFC 3339 timestamps.

There is no clock to reach for

In C you can call clock_gettime from anywhere, and in Python time.time(). kola has no such global functions. Access to the outside world comes as a capability: an ordinary value that grants one kind of access, such as reading the clock. The runtime hands the full set of capabilities (sys) to main and to nothing else, so sys.clock reaches a function only if someone passes it down as an argument. A Clock is an authority token: you may drop it on any path, but no code can create one.

A function also declares its effects after its return type: !{} means it does nothing but compute, !{io} means it touches the outside world, and !{io, block} means it may also wait. The practical consequence is that the signature is the audit. A function that takes no &Clock and declares no io cannot read time, and a reviewer can establish that without reading the body.

use std::log;

fn timed_work(clock: &Clock, n: u64) -> u64 !{io}
    req n <= 100000
{
    let t0 = clock.now();
    let mut acc: u64 = 0;
    let mut i: u64 = 0;
    while i < n {
        acc = acc +% i;
        i = i + 1;
    }
    let t1 = clock.now();
    t1 - t0
}

fn checksum(b: &[u8]) -> u64 !{} {
    let mut h: u64 = 0;
    let mut i: usize = 0;
    while i < b.len {
        h = h *% 31u64 +% widen[u64](b[i]);
        i = i + 1;
    }
    h
}

pub fn main(sys: Sys) -> u8 !{io, log} {
    let ns = timed_work(&sys.clock, 1000);
    let h = checksum(&b"kola");
    log::info(f"elapsed_ns={ns} checksum={h}");
    0
}

test the_stopwatch_measures_a_duration (mut sim: Sim) {
    // the virtual clock ticks 1000 ns per read, so two reads differ by 1000
    assert timed_work(&sim.clock, 10) == 1000u64;
}

Leave out either half and the compiler names the missing one. Here checksum tries to read a clock it was never given:

fn checksum(b: &[u8]) -> u64 !{} {
    let t = clock.now();
    widen[u64](b.len) +% t
}

The checker answers error[card §1] with "unknown name" for clock: there is nothing by that name in scope. Pass the Clock in but keep the !{} row, and the error becomes card §6: the function performs io (Clock::now) but does not declare it, with the help line "add io to the effect clause, or hand the work to a caller that has it".

Three readings

A Clock answers three questions, and underneath they are different operating-system clocks. A monotonic clock only ever moves forward at a steady rate, but its zero point is arbitrary, so it is good for measuring how long something took and useless as a date. A wall clock reads the calendar time, but the system may move it forwards or backwards at any moment (when NTP corrects the time, or an administrator sets it), so subtracting two wall readings can give nonsense. Every reading is a method on the clock.

Reading

Unit and origin

Use it for

clock.now() -> u64

nanoseconds since this capability was created; monotonic

A stopwatch: durations, timeouts, rate limits, latency.

clock.now_wall_ns() -> i64

nanoseconds since 1970-01-01 UTC, microsecond resolution; signed

A date: certificate validity, log timestamps, anything a person or a peer reads.

clock.now_boot() -> u64

nanoseconds, same origin as now, but time the machine spent suspended (a laptop with its lid closed, say) counts

Leases and expiries that a sleeping machine must not extend.

The wall reading is signed so that instants before 1970 are representable, which keeps date arithmetic total. The other fact to keep from this table is that now is a stopwatch: its zero is when the capability was made, not boot and certainly not 1970.

now_boot is never less than now. The two come from different OS clocks (Linux CLOCK_MONOTONIC and CLOCK_BOOTTIME; on Darwin CLOCK_UPTIME_RAW and CLOCK_MONOTONIC, which is the suspend-inclusive one there), and nothing in POSIX orders one against the other, so the runtime clamps now_boot against now. The result is that now_boot - now cannot underflow and really is the time spent suspended. On a freestanding build (a program that runs directly on the hardware with no operating system underneath) now_boot is the same as now, because there is nothing to suspend it.

Two more readings measure CPU time rather than elapsed time: clock.cpu_ns() for the whole process and clock.thread_cpu_ns() for the current OS thread. Their values are absolute with no shared origin, so only the difference of two readings means anything. On Windows their resolution is the scheduler tick (about 15.6 ms), and a short phase can read a delta of zero.

use std::log;

fn readings(clock: &Clock) -> (u64, i64, u64) !{io} {
    let mono = clock.now();          // ns since the capability was made
    let wall = clock.now_wall_ns();  // ns since 1970-01-01
    let boot = clock.now_boot();     // ns, host suspend included
    (mono, wall, boot)
}

// A lease measured on the suspend-inclusive stopwatch, with saturating
// arithmetic: a clock reading the program does not control must not trap it.
pub fn lease_deadline_ns(c: &Clock, ttl_ms: u64) -> u64 !{io}
    req ttl_ms <= 3600000
{
    let now = c.now_boot();
    let ttl_ns = ttl_ms * 1000000;
    if now > NO_DEADLINE - ttl_ns { NO_DEADLINE } else { now + ttl_ns }
}

pub fn main(sys: Sys) -> u8 !{io, log} {
    let (mono, wall, boot) = readings(&sys.clock);
    let d = lease_deadline_ns(&sys.clock, 30000);
    let live = d > boot;
    log::info(f"mono={mono} wall={wall} boot={boot} lease_live={live}");
    0
}

test the_three_readings_under_sim (mut sim: Sim) {
    let (mono, wall, boot) = readings(&sim.clock);
    // now and now_boot draw from one virtual counter, 1000 ns per read.
    // The wall counter is separate and moves 1 ms per read.
    assert mono == 1000u64;
    assert boot == 2000u64;
    assert wall == 1000000i64;
}

test a_lease_is_ahead_of_the_reading_it_came_from (mut sim: Sim) {
    let base = sim.clock.now_boot();
    let d = lease_deadline_ns(&sim.clock, 1000);
    assert d > base;
    assert d == 2000u64 + 1000000000u64;
}

The lease is why now_boot exists. A lease is a time-limited claim, such as a lock that expires unless its holder renews it. A service that measures lease lifetimes with now has a quiet bug: when the host suspends, that clock stops, and the lease is extended by exactly the interval during which the machine was not there to renew it.

A stopwatch is not a date

Here is the classic mistake, and it is easy to make because every line of it looks reasonable. A background service issues X.509 certificates (the certificates TLS uses to prove a server's identity), and each certificate carries notBefore, the moment from which it is valid. The service already has a clock reading in hand, so it scales clock.now() to the right unit and uses it as notBefore. The arithmetic is correct. The origin is not: the certificate now claims to become valid at 1970 plus the process's uptime, and since every peer checks now < not_before, every TLS handshake with it fails.

use std::log;
use std::time::instant;
use std::time::instant::Instant;

// WRONG: clock.now counts from when the capability was made.
fn broken_timestamp(clock: &Clock) -> Instant !{io} {
    let ms = clock.now() / 1000000u64;
    Instant { micros: widen[i64](ms) * 1000i64 }
}

// RIGHT: instant_now reads the wall clock and converts it to an Instant.
fn real_timestamp(clock: &Clock) -> Instant !{io} {
    instant::instant_now(clock)
}

// Unix seconds, for APIs that want them (X.509 validity, a JWT `exp`).
fn unix_seconds(clock: &Clock) -> u64 !{io} {
    narrow[u64](clock.now_wall_ns() / 1000000000i64).unwrap()
}

pub fn main(sys: Sys) -> u8 !{io, log} {
    let bad = instant::instant_to_datetime(broken_timestamp(&sys.clock)).date.year;
    let good = instant::instant_to_datetime(real_timestamp(&sys.clock)).date.year;
    let secs = unix_seconds(&sys.clock);
    log::info(f"stopwatch as a date: year {bad}; wall clock: year {good}; unix {secs}");
    0
}

test both_spellings_look_identical_under_sim (mut sim: Sim) {
    // The virtual wall clock starts at the epoch, so here the broken
    // reading and the correct one agree. No simulated test separates them.
    let bad = instant::instant_to_datetime(broken_timestamp(&sim.clock));
    let good = instant::instant_to_datetime(real_timestamp(&sim.clock));
    assert bad.date.year == 1970i64;
    assert good.date.year == 1970i64;
}

Run it for real and the broken version reports 1970 while the correct one reports the current year. The test at the bottom shows why a bug like this survives a good test suite: under --sim the virtual wall clock starts at the epoch, so both readings land in January 1970 and the test passes either way. Only a real clock tells them apart, and a program whose main never runs in any test never meets one.

A second variant of the same bug uses the right source with the wrong divisor: a local called now_ns that actually holds microseconds, divided by 1e9. Both are i64, and the compiler has no opinion about what a number means.

Sleeping

clock.sleep(ms: u64) !{io, block} is the only Clock operation that waits, which is why it is the only one that carries block as well as io. When a kola thread blocks on a capability operation, the runtime switches to another thread that has work to do, so a sleeping thread never stops its siblings from running (see Threads and channels). Declare io but not block and the checker reports that the function performs block (Clock::sleep) but does not declare it.

use std::log;

// Bounded retry with exponential backoff.
pub fn retry_backoff(clock: &Clock, attempts: u64) -> u64 !{io, block}
    req attempts <= 8
{
    let mut i: u64 = 0;
    let mut delay: u64 = 1;
    while i < attempts {
        clock.sleep(delay);
        delay = delay * 2;
        i = i + 1;
    }
    delay
}

pub fn main(sys: Sys) -> u8 !{io, log, block} {
    let t0 = sys.clock.now();
    let d = retry_backoff(&sys.clock, 8);
    let t1 = sys.clock.now();
    let waited_ms = (t1 - t0) / 1000000;
    log::info(f"next_delay={d}ms waited~{waited_ms}ms");
    0
}

test backoff_is_exact_under_sim (mut sim: Sim) {
    let t0 = sim.clock.now();
    let d = retry_backoff(&sim.clock, 8);
    let t1 = sim.clock.now();
    assert d == 256u64;
    // 1+2+...+128 = 255 ms of sleep, plus 1000 ns for t1's own read
    assert t1 - t0 == 255001000u64;
}

test zero_is_a_legal_no_wait (mut sim: Sim) {
    let t0 = sim.clock.now();
    sim.clock.sleep(0);
    let t1 = sim.clock.now();
    assert t1 - t0 == 1000u64;
}

kola run on this file really waits about 255 ms and can only report roughly how long it took. kola test finishes in milliseconds and asserts the elapsed time to the nanosecond, because the simulated clock advances by rule instead of by waiting. A suite full of timeout paths can therefore stay fast and still make exact assertions.

A freestanding build refuses sleep, since bare metal has no scheduler to yield to; park on your own timer instead. The refusal comes from the C compiler as an undeclared identifier whose name is the explanation: k_clock_sleep_UNAVAILABLE_FREESTANDING__no_scheduler_to_yield_to__park_on_your_own_timer. The other readings work freestanding as usual.

The simulated clock

A test that declares (mut sim: Sim) receives a simulated capability set driven by a random seed (a number that fixes every random choice in the run), and sim.clock is virtual. The rules are short, and knowing them keeps you from asserting something the simulation cannot deliver:

  • Every read ticks. A read of now or now_boot advances the monotonic counter by 1000 ns, so a stopwatch around no work reports 1000, not 0.

  • now and now_boot share that one counter. The simulator has no model of suspend, so do not write a test expecting them to diverge.

  • now_wall_ns has its own counter starting at the epoch. Each read advances it by 1 ms; the first read answers 1000000.

  • A sleep advances both counters by exactly its argument and is not a read: sleep(10) adds 10 000 000 ns, sleep(0) adds nothing.

  • With several threads, every bounded wait (sleep, a channel recv, a socket wait_readable) is due at an absolute virtual time. The earliest one fires first and virtual time jumps to it, so a 30-second wait beside a sibling's 700-second sleep measures 30 seconds. Ties go to the lower thread id, which keeps the same seed producing the same trace.

  • cpu_ns and thread_cpu_ns draw from the monotonic sequence too.

Everything derives from the seed, so a failure replays bit for bit with --seed N. The C backend implements the same rules independently, and kola test runs both executors and compares them, so an exact timing assertion is a real check of two implementations.

std::time: calendar, epoch, wire format

Everything so far is the capability. Turning an integer into a date is a library job, and std::time splits it into three modules.

  • std::time::civil is the calendar as pure data: Date, Time, DateTime; constructors mk_date and mk_time that return Option; is_leap_year, days_in_month, days_from_civil and civil_from_days. Every function is !{} and knows nothing about epochs or clocks.

  • std::time::instant is the epoch bridge. Instant and Duration are both a signed i64 of microseconds since 1970. instant_to_datetime and datetime_to_instant cross to the calendar; duration_add, duration_sub, instant_diff, instant_lt and instant_le do the arithmetic; pg_micros_to_unix_micros and its siblings convert to and from PostgreSQL's 2000-01-01 epoch. One function here touches a capability: instant_now(clock: &Clock) -> Instant, which divides the wall reading down to microseconds.

  • std::time::iso8601 is the wire codec, a strict subset of RFC 3339 over bytes. parse_rfc3339 returns Result[(DateTime, Option[i32]), TimeErr], where the Option[i32] is the UTC offset in minutes (None for Z), and write_rfc3339 is its inverse. TimeErr is BadFormat, BadDate or BadTime. Lowercase t or z, a space instead of T and reduced precision are all refused: a parser that accepts more than its writer produces cannot round-trip, and every extra spelling is attack surface (see Serialization and encodings).

use std::log;
use std::text::utf8;
use std::time::civil;
use std::time::instant;
use std::time::iso8601;
use std::time::civil::DateTime;
use std::time::instant::Instant;

fn christmas_2026() -> DateTime !{} {
    // Validity is checked once, at construction: mk_date returns Option,
    // so February 30th never becomes a Date.
    let d = civil::mk_date(2026i64, 12u32, 25u32).unwrap();
    let t = civil::mk_time(9u32, 30u32, 0u32, 0u32).unwrap();
    DateTime { date: d, time: t }
}

fn render[r](out: &r mut Vec[u8], dt: DateTime) !{alloc(r)} {
    let z: Option[i32] = None;
    iso8601::write_rfc3339(out, dt, z);
}

pub fn main(sys: Sys) -> u8 !{io, log, alloc(heap)} {
    let dt = christmas_2026();
    let us = instant::datetime_to_instant(dt).micros;
    region r {
        let buf: &r mut Vec[u8] = alloc in r mut Vec::new();
        render(buf, dt);
        let s = utf8::to_str(buf).unwrap();
        log::info(f"{s} = {us} us since the epoch");
    }
    0
}

test parse_then_format_round_trips {
    region r {
        // Untrusted bytes get a Result, never a trap.
        let (dt, off) = iso8601::parse_rfc3339(&b"2026-12-25T09:30:00Z").unwrap();
        assert dt.date.year == 2026i64;
        assert dt.date.month == 12u32;
        assert dt.time.hour == 9u32;
        assert off == None;

        let out: &r mut Vec[u8] = alloc in r mut Vec::new();
        iso8601::write_rfc3339(out, dt, off);
        let s = utf8::to_str(out).unwrap();
        assert s == "2026-12-25T09:30:00Z";
    }
}

test the_calendar_is_pure_data {
    assert civil::is_leap_year(2024i64);
    assert !civil::is_leap_year(2100i64);
    assert civil::days_in_month(2024i64, 2u32) == 29u32;
    assert civil::mk_date(2026i64, 2u32, 30u32) == None;
    assert civil::days_from_civil(1970i64, 1u32, 1u32) == 0i64;
}

test the_epoch_bridge_is_total {
    let epoch = instant::instant_to_datetime(Instant { micros: 0i64 });
    assert epoch.date.year == 1970i64;
    // One microsecond before the epoch: every i64 maps to a civil instant.
    let before = instant::instant_to_datetime(Instant { micros: 0i64 - 1 });
    assert before.date.year == 1969i64;
    assert before.time.hour == 23u32;
}

instant_to_datetime is total: every i64 maps to exactly one civil instant, with no Option and no trap, and instants before 1970 come out right because the module uses floor division rather than kola's truncating /. The formatter writes into a growable byte buffer, Vec[u8], allocated in a region, a block whose memory is all freed at its closing brace; that is why its effect row is !{alloc(r)}. Certificate building in std::crypto::x509_build has its own datetime_from_unix(secs: u64), which takes seconds; feed it from the unix_seconds conversion shown earlier.

Deadlines on the operations that wait

Most time-related code is about deadlines, and those live on the operations that wait rather than on Clock:

  • rx.recv(timeout_ms: u64) -> Recv[T] receives from a channel and answers Msg(v), Timeout or Closed, keeping "the sender is slow" apart from "the sender is gone". See Threads and channels.

  • c.wait_readable(timeout_ms) and c.wait_writable(timeout_ms) wait for socket, file or device readiness and answer Ok(true) when ready, Ok(false) when the wait expired. c.wait_ready(timeout_ms) answers Data, Closed or Timeout from a single probe. See Networking.

Every timeout_ms is a u64 that is waited out in full. A timeout of 0 means "check now and tell me", which is what a serve loop asks after each request it finishes. To wait with no deadline at all, pass NO_DEADLINE, a reserved literal equal to the largest u64. Be careful when you bridge to POSIX here: a SO_RCVTIMEO of zero means "no timeout" there, the opposite of kola's probe.

A timeout bounds one wait, not a loop of them. A peer that answers just inside each timeout keeps a loop of bounded waits alive forever. A real deadline is one clock.now() taken before the loop and compared on every iteration.

One last trap sits on the filesystem side. stat answers a FileInfo { size, mtime_ns, kind }, and under the simulated filesystem mtime_ns is a deterministic increasing stamp, not a wall-clock time. Never compare it against clock.now_wall_ns(). It is the same category error as the certificate bug: two integers of the same type and magnitude with different origins.