From 2dd13b5ae0ebbb0237d49ed71f9a65c8325527a2 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Thu, 17 Sep 2026 21:45:10 +0700 Subject: [PATCH] The language can tell the time, and read its environment Nothing in it could. A game got a clock from raylib and a program without a window had none at all, so "how long did that take" was unanswerable in the half of daily use that is a tool rather than a game. Two clocks, because the mistake a single one invites is using it for the other job. monotonic-ns measures: it never goes backwards, nothing adjusts it, and its zero is arbitrary, so it is meaningless alone and correct as a difference. unix-ns dates: nanoseconds since 1970, which is what goes in a save file, and which jumps in either direction when somebody sets the system clock. The names are picked so that reaching for the wrong one reads wrong. This is Odin's shape, from core/time/time.odin and core/time/time_linux.odin: Tick against Time, both an i64 of nanoseconds, over MONOTONIC and REALTIME, with the seconds-valued face derived rather than a second syscall. Three C functions here and six Flan names over them, which is the rule flan_rt.c's own header states -- a primitive is the only thing implemented twice. The monotonic origin is the first read of the clock in the process, not boot, and that is the one decision worth arguing. CLOCK_MONOTONIC counts from boot, so on a machine up a hundred days the raw value is past 2^53 nanoseconds and monotonic-seconds would lose sub-microsecond resolution depending on the machine's uptime rather than on anything the program did. Latched to first read it stays integer-exact for a hundred days of process life, and it also matches what a game already has: raylib's GetTime is seconds since InitWindow, so the two numbers now mix without a conversion at every site. sleep-ns loops on EINTR, because otherwise a signal cuts the wait short and a frame loop wobbles for reasons nothing in the program explains. It is documented as at-least and not as a frame limiter; the shape that actually paces a loop is a deadline recomputed from monotonic-ns each turn, and the comment says so where somebody will read it. getenv answers an (Option [u8]) viewing the process environment, which needs no allocator and no free and is safe precisely because nothing in this language can call setenv or spawn a process. The absent case rides in the length rather than in the pointer: there is no null test to write, since a (Ptr T) here always addresses something, so flan_getenv answers -1 and a pointer at a valid empty string and the Flan side tests arithmetic. The runtime additions are a single block at the end of flan_rt.c, with inside it for the reason sits beside the file section. programs/time.flan asserts invariants and never a reading -- t2 >= t1, a sleep that did not return early, a date after 2020 and before 2100 -- 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. --- lib/prelude.ml | 105 ++++++++++++++++++++++++++++++++++ runtime/flan_rt.c | 122 ++++++++++++++++++++++++++++++++++++++++ test/programs/time.flan | 71 +++++++++++++++++++++++ test/test_acceptance.ml | 9 +++ web/index.html | 14 +++++ 5 files changed, 321 insertions(+) create mode 100644 test/programs/time.flan 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 bytesappend!, append-i64!, append-f64!, concat, join, repeat-bytes, replace-bytes, slices-new, format-f64 UTF-8decode-rune, rune-at, rune-count, rune-size, rune-start?, valid-utf8?, encode-rune! numberssign-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 +timemonotonic-ns, monotonic-seconds, unix-ns, unix-seconds, sleep-ns, sleep-seconds, and ns-per-second and its two smaller siblings +the operating systemgetenv, which answers an (Option [u8]) viewing the process environment randomrand-seed, rand-u32, rand-f32, rand-i32-range, rand-f32-range forms, for macrosform-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 restpause, 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,