Ship

Old Docs · Time
On this page · 10 sections

Time

Ship separates exact elapsed time, wall-clock instants, calendar dates, monotonic measurement, and future civil time. These are related concepts, but they should not share one loose integer or string representation.

Standard Library Invariant

Standard library APIs pass time around as Instant, LocalDate, Duration, or an opaque runtime marker such as MonotonicInstant. They do not use plain Int values for timeouts, delays, timestamps, retry schedules, deadlines, elapsed time, or stored time fields.

Plain integers are allowed only at explicit conversion edges:

  • constructors such as Duration.millis(250);

  • measured numeric construction such as Duration.from(50<ms>);

  • conversion helpers such as Duration.to_millis(duration);

  • wire, database, runtime, or generated-code internals where the representation is not the Ship API.

    The public Duration, Instant, and Schedule module surfaces should live in Ship stdlib source wherever the language can express them. Host primitives use #builtin contracts on those same public modules for opaque representation, parsing/formatting, overflow-checked arithmetic, and system clock access; source-backed wrappers are ordinary .shp. Do not hide remaining host ops behind an Intrinsics module or path-installed primitive_* twins. Duration.micros stays #builtin: it is the i64-nanosecond scale bridge, so millis / seconds / hours can construct values past the tagged 63-bit Int intermediate. Duration.from is source-backed over millis (measured Int<ms> uses #builtin.magic at the representation edge). Remaining host edges (nanos, to_nanos_checked, ISO / RFC3339, :overflow arithmetic, compare, to_millis, display) stay #builtin on Duration / Instant. External projects load those modules from the compiler-matched external Core artifact rather than reparsing ship/stdlib on each run. Further wrapper migrations are tracked in ../questions/176-stdlib-source-backed-migrations.md. Provider runtime availability for time APIs follows the same use-site rule as other builtins: ship check rejects a call whose provider does not run on the program's target (book/100-LanguageReference/009-RejectedPrograms.shp).

LocalDate

LocalDate is a calendar date without a time of day or time zone. Workflow dates such as filming, publishing, and contractual due dates use LocalDate instead of midnight-shaped Instant values.

let due = LocalDate.parse_iso("2026-07-31");
let text = LocalDate.to_iso(due);
let order = LocalDate.compare(due, LocalDate.parse_iso("2026-08-01"));

Parsing is strict YYYY-MM-DD, including Gregorian leap-year and month-length validation. Years range from 0001 through 9999. Display, inspect, and wire output use the same canonical text shape.

Duration

Duration is the public stdlib type for exact elapsed time. Public APIs accept Duration, not raw millisecond Int values.

let quick = Duration.millis(250);
let slow = Duration.seconds(2);
let total = Duration.add(quick, slow);
let from_measured = Duration.from(50<ms>);

Measured numerics are a construction and conversion aid. Duration.from accepts Int<ms> so a boundary can be explicit about units, but the resulting value is a Duration.

Rules:

  • Duration is exact elapsed time, not calendar time.

  • Months and years do not belong in Duration.

  • Prefer Duration.hours(24) for exact elapsed time. A future Period.days(1) will carry calendar semantics.

  • Arithmetic that can overflow is fallible and uses Ship's expected-error channel with :overflow.

  • Stable serialization uses Duration.to_iso; human display uses Duration.display.

Instant

Instant is an absolute wall-clock timestamp for logs, database timestamps, and wire formats.

provide { Clock = Clock.SystemClock; } {
  let created_at = Clock.now();
  let parsed = Instant.parse_rfc3339("2026-06-02T12:00:00Z");
  let later = Instant.add(parsed, Duration.minutes(30));
}

Wire output is strict RFC3339 with UTC-normalized Z output:

Instant.to_rfc3339(later)

Instant is not a calendar date or local/civil date-time. Date-only values use LocalDate; future local scheduling should use separate types such as LocalDateTime, TimeZone, and ZonedDateTime.

Clock

Clock is an ambient standard service: ordinary signatures omit it, while an executable scope installs a checked provider explicitly. The standard native provider is Clock.SystemClock; tests and sandboxes install deterministic providers through the same provide path.

provide { Clock = Clock.SystemClock; } {
  let mark = Clock.mark();
  work();
  let elapsed = Clock.elapsed_since(mark);
}

Clock.now() returns wall-clock Instant. Clock.mark() returns an opaque MonotonicInstant for elapsed measurement; it is not serializable and should not be stored in domain data.

Clock.SystemClock implements Clock.now, Clock.mark, and Clock.elapsed_since through checked TimeTransport builtins. Runtime dispatch remains the ordinary provider-cell path; it does not match the Clock service or operation names.

Clock.virtual(start) is the deterministic provider for tests and sandboxes. now starts at start; marks come from the runtime's virtual timeline, which advances only when every fiber of a scheduler is parked on a virtual deadline.

Sleep

Clock.sleep(duration) parks the current fiber until the provided Clock passes Clock.mark() + duration. It is a source function over TimeTransport.sleep_after(mark, duration), the one suspending time primitive; the mark's timeline (system or virtual) selects the deadline queue.

  • Sleeping never blocks a host thread while another fiber of the same scheduler is runnable.

  • An idle scheduler with system deadlines waits for the earliest one, polling host cancellation, instead of spinning. With virtual deadlines it advances virtual time to the earliest one immediately.

  • Pending deadlines keep the scheduler alive; deadlock detection fires only when no fiber is runnable and no deadline is pending.

  • A zero or negative duration parks until every runnable fiber has had a turn.

  • Interrupting a sleeping fiber cancels its deadline and runs its cleanup.

  • Fiber code reached through a native callback, such as the function given to Vector.map, runs on a nested scheduler. While it sleeps, the outer scheduler's fibers do not run, on either timeline.

    Timers run on the native scheduler. The browser runtime implements no TimeTransport provider yet, so every Clock operation, Clock.sleep included, is native-only. Browser builds do not reject these calls at check time; they fail when reached.

    Executable coverage lives in Timers.

Schedule

Retry/repeat policies use Duration, not raw integer millisecond fields.

let retry_schedule =
  Schedule.exponential(Duration.millis(100))
  |> Schedule.jitter(factor = 0.2)
  |> Schedule.cap_delay(Duration.seconds(5))
  |> Schedule.max_elapsed(Duration.seconds(30))
  |> Schedule.limit_retries(5);

Retry.run(policy, f) executes a Retry.policy(schedule, when) in Ship source. After a failed attempt it retries only when the schedule allows another retry and when accepts the error, then sleeps through Clock.sleep:

  • limit_retries(n) / recurs(n) allow n retries after the first attempt. Without a retry limit, max_elapsed alone bounds the attempts; with neither, the first error propagates.

  • spaced(d) waits d after each failure. fixed(d) keeps a cadence: it waits what remains of d since the failed attempt started. exponential(base) waits base * 2^(n - 1) before retry n, saturating instead of overflowing.

  • jitter(factor = f) scales the delay by a uniform draw from [1 - f, 1 + f) through the ambient Random service (Random.SystemRandom, or Random.sequence(values) in tests).

  • cap_delay(max) bounds the delay after jitter.

  • max_elapsed(limit) measures from the first attempt's Clock.mark(); a retry whose delay would end past limit does not start.

  • The error that stops retrying propagates unchanged.

    Schedules touch Clock only for delays, cadence, and budgets, and Random only for jitter. Executable coverage lives in Retry schedules.

Boundaries

Default wire shapes:

  • Instant: RFC3339 UTC string, for example "2026-06-02T12:00:00Z".

  • LocalDate: strict ISO date string, for example "2026-06-02".

  • Duration: exact ISO duration string using time units, for example "PT2.25S".

  • MonotonicInstant: no wire shape.

    Default database shapes:

  • Postgres Instant: timestamptz.

  • SQLite Instant: TEXT using the same RFC3339 shape.

  • Postgres LocalDate: date.

  • SQLite LocalDate: TEXT using the same YYYY-MM-DD shape.

  • Postgres and SQLite Duration: integer nanoseconds. Ship intentionally does not default to Postgres interval because interval carries months/days/microseconds and mixes exact and calendar semantics.

    Runtime representation:

  • Native runtime: Duration and Instant are opaque scalar heap values backed by signed nanoseconds. LocalDate is an opaque scalar backed by a Gregorian day count.

  • JS runtime: LocalDate uses its validated canonical ISO text internally. This is not exposed as the Ship type representation and avoids JavaScript Date time-zone behavior.

Golden Coverage

Current focused coverage:

  • Runtime duration constructors, arithmetic, ISO parse/format, display, measured Int<ms> construction, RFC3339 instant parse/format, instant arithmetic, and the checked Clock.SystemClock provider path.

  • Project builds that use the external Core artifact's lowered Schedule IR and inspect duration-backed schedule values.

  • Sleep ordering, yielding, interruption, deadline races, and the virtual and system clocks in Timers.

  • Retry progressions, jitter, caps, budgets, classification, and interruption under Clock.virtual and Random.sequence in Retry schedules.

  • GC rooting of sleeping fibers and their providers under stress in crates/ship-types/tests/fiber_runtime.rs.

    Next coverage to add with future runtime slices:

  • JS backend parity once Duration and Instant lowering and a browser timer queue exist for the JS target.

  • Negative tests proving Duration.parse_iso rejects calendar months and years.

Follow-Ups

  • Remaining calendar types: Period, LocalTime, LocalDateTime, TimeZone, and ZonedDateTime.

  • DST ambiguity policy: reject, earlier, later, or compatible.

  • Browser timers over the same TimeTransport.sleep_after contract.

  • Timeout and deadline helpers beyond composing spawn with Clock.sleep.