diff --git a/lib/prelude.ml b/lib/prelude.ml
index 353e69f..ff16762 100644
--- a/lib/prelude.ml
+++ b/lib/prelude.ml
@@ -823,6 +823,111 @@ let source = {flan|
(defconst tau-f32 f32 6.28318530717958647692)
(defconst tau-f64 f64 6.28318530717958647692)
+;; ── The clock ─────────────────────────────────────────────────────────
+;;
+;; Until this section nothing in the language could tell the time. A game got
+;; one from raylib and a program that was not a game had none at all, which
+;; made "how long did that take" unanswerable in a tool — the half of daily
+;; use that has no window.
+;;
+;; **Two clocks, and they are not interchangeable.** This is the whole of what
+;; a caller has to know, and the names are chosen so that picking the wrong
+;; one reads wrong:
+;;
+;; `monotonic-…` measures. It never goes backwards, it is not moved by NTP
+;; or by a user setting the clock, and its zero is arbitrary — the first
+;; time the program reads it. It is meaningless on its own and correct as a
+;; difference.
+;;
+;; `unix-…` dates. Seconds (or nanoseconds) since 1970-01-01 UTC, which is
+;; what goes in a file, a log line or a save. It *can* jump, forwards or
+;; backwards, so a duration computed from two readings of it can be
+;; negative, and timing anything with it is the bug this pair exists to make
+;; hard to write.
+;;
+;; Odin draws exactly this line and this is its shape: core/time/time.odin has
+;; `Time` for the date and `Tick` for the measurement, both an i64 of
+;; nanoseconds, and core/time/time_linux.odin implements them as REALTIME and
+;; MONOTONIC. The nanosecond integer is the primitive there and the f64 of
+;; seconds is derived, which is why it is derived here too — three C functions,
+;; six names.
+;;
+;; **Which face to use.** The i64 of nanoseconds is exact and is what a
+;; difference should be taken in. The f64 of seconds is what a frame loop
+;; wants, and it is the shape raylib's `get-time` already answers with
+;; (vendor/raylib/raylib.flan, `(declare-c get-time [] f64 "GetTime")`), so the
+;; two mix without a conversion at every site. The monotonic origin is latched
+;; at the first read rather than being boot — see runtime/flan_rt.c — so that
+;; the f64 stays integer-exact in nanoseconds for a hundred days of process
+;; life, which a boot-relative clock on a long-lived machine does not.
+
+(declare monotonic-ns [] i64 "flan_monotonic_ns")
+(declare unix-ns [] i64 "flan_unix_ns")
+
+;; Nanoseconds, so the caller writes the unit rather than counting zeroes, and
+;; so that a duration in the language is one type rather than a per-unit
+;; family. Odin spells the same idea as `Duration` constants in core/time.
+(defconst ns-per-microsecond i64 1000)
+(defconst ns-per-millisecond i64 1000000)
+(defconst ns-per-second i64 1000000000)
+
+(defn monotonic-seconds [] f64
+ (/ (f64 (monotonic-ns)) 1000000000.0))
+
+(defn unix-seconds [] f64
+ (/ (f64 (unix-ns)) 1000000000.0))
+
+;; **Not a frame limiter.** A sleep asks the operating system to stop this
+;; thread for *at least* the time given and says nothing about the upper
+;; bound: a default Linux kernel wakes a sleeper on the timer tick after the
+;; deadline, so a request for one millisecond commonly returns after rather
+;; more, and the error is on the late side every time. A frame loop that
+;; sleeps a fixed slice per frame therefore runs slow and drifts; the shape
+;; that works is to sleep until a deadline computed from `monotonic-ns` and to
+;; recompute it from the same clock each turn, so that a long frame is
+;; absorbed instead of accumulated.
+;;
+;; A zero or negative request returns immediately rather than being refused,
+;; which is what a deadline that has already passed produces and is not an
+;; error — see flan_sleep_ns for why, and for the EINTR loop that keeps a
+;; signal from cutting the wait short.
+(declare sleep-ns [ns i64] () "flan_sleep_ns")
+
+(defn sleep-seconds [s f64] ()
+ (sleep-ns (i64 (* s 1000000000.0))))
+
+;; ── The environment ───────────────────────────────────────────────────
+;;
+;; One lookup, and `argv` and `exit` are the rest of the OS surface. Setting a
+;; variable is not here and is not an omission: `setenv` mutates a table the
+;; slice below views, and nothing in the language can spawn the process that
+;; would be the only reason to set one.
+;;
+;; **The result borrows.** It is a view of the process environment, not a copy:
+;; it needs no allocator and no free, and it stays valid because there is no
+;; writer — that is the same promise `slice-from-ptr` asks a caller to make,
+;; kept here once so that no caller has to. A program that wants to hold the
+;; value past a point where that reasoning stops being obvious should copy it
+;; into a Vec, which `concat` of one part already does.
+;;
+;; `None` and an empty `Some` are different answers and both occur: an unset
+;; variable is None, and `FOO=` set to nothing is `(Some [])`. A caller that
+;; wants to treat them alike says so.
+;;
+;; The absent case rides in the length and not in the pointer, because there is
+;; no null test to write here — a (Ptr T) in this language always addresses
+;; something. flan_getenv answers a length of -1 and a pointer at a valid empty
+;; string, so the test below is arithmetic and the pointer is never dereferenced
+;; on the absent path.
+(declare getenv-raw [name string out-len (Ptr i64)] (Ptr u8) "flan_getenv")
+
+(defn getenv [name string] (Option [u8])
+ (let [n (i64 0)
+ p (getenv-raw name (addr n))]
+ (if (< n 0)
+ None
+ (Some (slice-from-ptr p (i32 n))))))
+
;; ── Byte classes ──────────────────────────────────────────────────────
;;
;; ASCII only, and deliberately: a byte is a byte here, there is no code point
diff --git a/runtime/flan_rt.c b/runtime/flan_rt.c
index 2ca6ff4..33c0424 100644
--- a/runtime/flan_rt.c
+++ b/runtime/flan_rt.c
@@ -2749,3 +2749,125 @@ int8_t flan_slurp_into(flan_vec *v, const uint8_t *path, int64_t n) {
v->len = got;
return 1;
}
+
+/* ── Time, and the environment ─────────────────────────────────────────
+ *
+ * Three clock primitives and one environment lookup, added as a block at the
+ * end so that the two halves of this file — the milestone-2 ABI above and the
+ * host services below — stay separable. is included here rather than
+ * at the top for the reason is: an include beside the only section
+ * that needs it says which section that is.
+ *
+ * The rule the whole file is written to applies hardest here: a primitive is
+ * the only thing implemented twice, so the seconds-valued faces of all three
+ * (monotonic-seconds, unix-seconds, sleep-seconds) are Flan in the prelude,
+ * over these. Odin draws the same line — core/time/time_linux.odin is exactly
+ * _now, _tick_now, _sleep and _yield over clock_gettime and nanosleep, and
+ * duration_seconds is derived arithmetic in core/time/time.odin. */
+
+#include
+
+/* The two clocks are kept apart on purpose, because the mistake they invite is
+ * using one for the other's job.
+ *
+ * MONOTONIC never goes backwards and is not adjusted by NTP or by the user
+ * setting the clock, which is what makes it the one to *measure* with: a frame
+ * time taken across a daylight-saving change is still a frame time. It has no
+ * meaning as a date — its zero is arbitrary — so it can only ever be
+ * subtracted from another reading of itself.
+ *
+ * REALTIME is the date, and it is the one that jumps: it can move backwards,
+ * and a duration computed from two readings of it can be negative. It is here
+ * to answer "when", not "how long". */
+
+/* The origin is the first read of this clock in the process, not boot, and
+ * that is a decision rather than an accident.
+ *
+ * The reason is the f64 face above it. CLOCK_MONOTONIC counts from boot, so on
+ * a machine up a hundred days the raw value is past 2^53 nanoseconds — beyond
+ * where an f64 holds consecutive integers — and (monotonic-seconds) would
+ * quietly lose sub-microsecond resolution depending on how long the *machine*
+ * had been running, which is the worst kind of bug to be handed. Latched to
+ * first read, the f64 stays integer-exact for a hundred days of *process*
+ * life, and nothing this language builds runs that long without a restart.
+ *
+ * It also matches what a game already expects: raylib's GetTime is seconds
+ * since InitWindow, not seconds since boot, and the two now mix without a
+ * caller having to notice one of them is a much larger number.
+ *
+ * A plain static and no atomics, because the language has no threads. If it
+ * ever gets them, the worst a race here can do is latch two origins a few
+ * nanoseconds apart, which costs a reading that is early by that much and
+ * cannot make the clock run backwards. */
+static int64_t flan_mono_origin;
+static int flan_mono_armed;
+
+static int64_t flan_clock_ns(clockid_t which) {
+ struct timespec ts;
+ /* A failure here is not reachable with a constant clock id the platform
+ * has, and there is no channel to report it on that a caller could act on:
+ * the answer to "what time is it" cannot be a condition without every
+ * reading of it costing a handler search. A zeroed timespec is what a
+ * failure reads as, and for MONOTONIC that is the origin. */
+ if (clock_gettime(which, &ts) != 0) { ts.tv_sec = 0; ts.tv_nsec = 0; }
+ return (int64_t)ts.tv_sec * 1000000000 + (int64_t)ts.tv_nsec;
+}
+
+int64_t flan_monotonic_ns(void) {
+ int64_t now = flan_clock_ns(CLOCK_MONOTONIC);
+ if (!flan_mono_armed) { flan_mono_armed = 1; flan_mono_origin = now; }
+ return now - flan_mono_origin;
+}
+
+int64_t flan_unix_ns(void) { return flan_clock_ns(CLOCK_REALTIME); }
+
+/* A negative or zero request returns at once rather than being refused: the
+ * caller that computed "sleep until the frame's deadline" and arrived late
+ * wants to carry on, not to be told it is late, and that is by far the most
+ * common way this is called.
+ *
+ * The EINTR loop is the reason this is C and not two Flan lines over a raw
+ * nanosleep: a signal — a profiler's timer, the dev loop's own — otherwise
+ * cuts the wait short and the caller's frame pacing wobbles for reasons
+ * nothing in the program explains. The remaining time comes back in the same
+ * timespec, so resuming is a second call with no arithmetic. Odin's _sleep in
+ * core/time/time_linux.odin loops on EINTR for the same reason.
+ *
+ * On emscripten this is still nanosleep, which there spins rather than yields:
+ * the sleep is the length asked for, and it burns a core and blocks the frame
+ * doing it. Correct, and not what a browser build should be reaching for — a
+ * web frame loop waits by returning to the browser, not by sleeping. */
+void flan_sleep_ns(int64_t ns) {
+ struct timespec ts;
+ if (ns <= 0) return;
+ ts.tv_sec = (time_t)(ns / 1000000000);
+ ts.tv_nsec = (long)(ns % 1000000000);
+ while (nanosleep(&ts, &ts) != 0 && errno == EINTR) { }
+}
+
+/* getenv, with the absent case carried in the length rather than in the
+ * pointer, so that the Flan side never has to compare a pointer against null —
+ * a test the language does not offer, since a (Ptr T) only ever arrives from a
+ * declare and nothing in the type says it may be nothing. Absent is *len = -1
+ * and a pointer to a valid empty string; present is *len >= 0 and the
+ * environment's own bytes, which (slice-from-ptr) then views.
+ *
+ * The bytes are the process environment's and are not copied. They outlive the
+ * call — nothing in this language can call setenv or spawn a process, so there
+ * is no writer — and they are not the caller's to free. The prelude's `getenv`
+ * says so where a caller will read it.
+ *
+ * A name with an embedded NUL reads as absent rather than as the shorter name
+ * before it, which is flan_path_cstr's rule and for its reason: the name
+ * looked up must be the name written. */
+const uint8_t *flan_getenv(const uint8_t *name, int64_t n, int64_t *len) {
+ static const char empty[1] = { 0 };
+ char buf[FLAN_PATH_MAX];
+ const char *v;
+ *len = -1;
+ if (!flan_path_cstr(name, n, buf)) return (const uint8_t *)empty;
+ v = getenv(buf);
+ if (!v) return (const uint8_t *)empty;
+ *len = (int64_t)strlen(v);
+ return (const uint8_t *)v;
+}
diff --git a/test/programs/time.flan b/test/programs/time.flan
new file mode 100644
index 0000000..6836c24
--- /dev/null
+++ b/test/programs/time.flan
@@ -0,0 +1,71 @@
+;;;; The clock and the environment.
+;;;;
+;;;; Every line of output here is an invariant and not a reading, and that is
+;;;; forced rather than chosen: this file is in the corpus @x86 builds twice
+;;;; and diffs, and the acceptance table matches its stdout exactly, so a
+;;;; timestamp or an elapsed count would fail a correct compiler on the second
+;;;; run. What is left is what a clock actually has to promise — that it does
+;;;; not go backwards, that a sleep does not return early, that the two faces
+;;;; of one clock describe one instant — and those are the properties worth
+;;;; pinning anyway. A test that asserted "this took under 3ms" would be a
+;;;; test of the machine's load.
+
+(defn main [] i32
+ ;; Monotonic, twice. The whole contract in one line: it never goes
+ ;; backwards. Equal is allowed and is not a bug — two reads inside one tick
+ ;; of a coarse timer are the same nanosecond.
+ (let [t1 (monotonic-ns)
+ t2 (monotonic-ns)]
+ (println (>= t2 t1)))
+
+ ;; And the origin is the first read rather than boot, so the first readings
+ ;; a program takes are small. Bounded rather than pinned: the number is
+ ;; whatever this process spent between the calls above and this one, which
+ ;; is not a second on any machine that can run the suite at all.
+ (println (< (monotonic-ns) ns-per-second))
+
+ ;; The f64 face is the i64 one divided, and what is checked is that the two
+ ;; describe the same instant: a later reading in seconds is at or past an
+ ;; earlier reading in nanoseconds converted the same way. A clock whose two
+ ;; faces came from different sources fails this.
+ (let [a (/ (f64 (monotonic-ns)) 1000000000.0)
+ b (monotonic-seconds)]
+ (println (>= b a)))
+
+ ;; The wall clock is a date, so the invariant is a date one: it is after
+ ;; 2020 and before 2100. That pins the epoch and the unit at once — a clock
+ ;; counting microseconds, or counting from boot, fails both halves.
+ (let [now (unix-seconds)]
+ (println (and (> now 1577836800.0) (< now 4102444800.0))))
+
+ ;; Sleep is specified as *at least*, so at-least is what is asserted; the
+ ;; upper bound belongs to the scheduler and not to this language. Two
+ ;; milliseconds because the shortest sleep a default kernel actually
+ ;; performs is a timer tick, and a shorter request would make this a test of
+ ;; how that kernel was configured.
+ (let [before (monotonic-ns)]
+ (sleep-ns (* 2 ns-per-millisecond))
+ (println (>= (- (monotonic-ns) before) (* 2 ns-per-millisecond))))
+
+ ;; Zero and negative return at once rather than being refused, which is what
+ ;; a deadline already passed produces. That they return at all is the
+ ;; assertion; nothing here is timed.
+ (sleep-ns 0)
+ (sleep-ns -1)
+ (sleep-seconds 0.0)
+ (println "slept")
+
+ ;; ── The environment ──────────────────────────────────────────────
+
+ ;; A variable nothing sets. None is the answer, and it is a different answer
+ ;; from a variable set to nothing.
+ (match (getenv "FLAN_NO_SUCH_VARIABLE_AT_ALL")
+ (Some v) (println "unexpectedly set")
+ None (println "unset"))
+
+ ;; PATH is set for every process that gets as far as running this, and the
+ ;; only portable thing about its contents is that there are some.
+ (match (getenv "PATH")
+ (Some v) (println (> (len v) 0))
+ None (println "no PATH"))
+ 0)
diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml
index 4457ba1..5054422 100644
--- a/test/test_acceptance.ml
+++ b/test/test_acceptance.ml
@@ -285,6 +285,15 @@ let () =
outputs "the rest of libm, both widths" "programs/math3.flan" math3_out;
outputs ~opt:"-O0" "the rest of libm, both widths, -O0"
"programs/math3.flan" math3_out;
+ (* The clock and the environment. Every line of that program's output is
+ an invariant — a monotonicity, a date range, a sleep that did not
+ return early — and not a reading, because the same file is in the
+ corpus @x86 builds twice and diffs, so a timestamp would fail a correct
+ compiler on its second run. *)
+ let time_out = "true\ntrue\ntrue\ntrue\ntrue\nslept\nunset\ntrue\n" in
+ outputs "the clock and getenv" "programs/time.flan" time_out;
+ outputs ~opt:"-O0" "the clock and getenv, -O0" "programs/time.flan"
+ time_out;
(* index-of-bytes, trim, the byte classes and parse-f64. The search cases
are the ones that separate a correct loop from a lucky one: a match
only at the end, "aab" in "aaab" (where the first byte matches twice
diff --git a/web/index.html b/web/index.html
index 5536c84..59d57cf 100644
--- a/web/index.html
+++ b/web/index.html
@@ -1064,6 +1064,8 @@ over.
| building bytes | append!, append-i64!, append-f64!, concat, join, repeat-bytes, replace-bytes, slices-new, format-f64 |
| UTF-8 | decode-rune, rune-at, rune-count, rune-size, rune-start?, valid-utf8?, encode-rune! |
| numbers | sign-f32, lerp, clamp, floor-f32, ceil-f32, round-f32, abs-i32, abs-i64, the constants pi-f32, pi-f64, tau-f32, tau-f64, and libm through a declare at both widths: sqrt, abs, floor, ceil, round, fmod, sin, cos, tan, asin, acos, atan, atan2, log, log2, log10, exp, pow, hypot, cbrt — each spelled -f32 or -f64 |
+| time | monotonic-ns, monotonic-seconds, unix-ns, unix-seconds, sleep-ns, sleep-seconds, and ns-per-second and its two smaller siblings |
+| the operating system | getenv, which answers an (Option [u8]) viewing the process environment |
| random | rand-seed, rand-u32, rand-f32, rand-i32-range, rand-f32-range |
| forms, for macros | form-nil, form-cons, form-append, form-rest, form-items, form-pair, form-sym?, form-is-sym?, gensym, and unless and into, which are macros written here rather than special forms |
| the rest | pause, which signals the Pause condition the break loop stops on, and embed-find |
@@ -1103,6 +1105,18 @@ and the three libms do differ in the last bit, so the byte-identical-hash proper
RNG exists for does not survive a value routed through any of them. Every link carries
-lm.
+The clock is two clocks and they are not interchangeable.
+monotonic-ns measures: it never goes backwards, nothing adjusts it, and its
+zero is the first time the program reads it, so it is meaningless alone and correct as a
+difference. unix-ns dates: nanoseconds since 1970, which is what goes in a
+save file or a log line, and which can jump in either direction when the system clock is
+set. Odin draws the same line — Tick against Time in
+core/time — and the nanosecond integer is the primitive on both sides, with
+the -seconds faces derived from it. The f64 of seconds is the
+shape raylib's get-time already answers with, so the two mix; it stays
+integer-exact in nanoseconds for a hundred days of process life, which is why the
+monotonic origin is the first read and not boot.
+
The primitives underneath are few — a primitive is the only thing implemented
twice per backend: argv,
write-stdout, exit, len, at,