tickets
All repositories: gitoria
16.2 KB
// hl:time plugin — native shared library (libtime.so, dlopen'd by the runtime)//// The clock (D22):// hl_time_now() → epoch milliseconds, UTC wall clock (Number)// hl_time_timestamp(ms?) → ISO-8601 UTC "YYYY-MM-DDTHH:MM:SS.mmmZ" (String)// hl_time_monotonic() → nanoseconds off a monotonic counter (Number)//// …and TIME AS AN EVENT SOURCE (mission 254):// hl_time_timer(secs, repeat) → { id, events } — `events` is a LOOP SOURCE// whose wake fd IS a timerfd, so the interpreter// loop's epoll (mission 256) blocks on the// kernel timer itself instead of polling// hl_time_timer_stop(id) → disarm, close, retire// hl_time_sleep(secs) → BLOCKING nanosleep (the creator's ruling: a// basic tool for tests and debugging)//// The clock calls allocate nothing and hold no state; `timestamp` renders into a// module-level buffer: the runtime dupes returned strings into its own tracker// the instant the call returns (plugin_loader.zig hlToValue .hl_string) and a// plugin call never yields, so the buffer cannot be observed stale or torn.//// Number is f64. Epoch ms (~1.8e12) is exact well past the year 200000, and// monotonic ns stays exact for the first ~104 days of counter uptime (2^53 ns);// beyond that the granularity coarsens above 1 ns, which is irrelevant for the// difference-measurement the call exists for.const std = @import("std");const linux = std.os.linux;const api = @import("plugin_api");const HlValue = api.HlValue;const HlObject = api.HlObject;const HlField = api.HlField;const HlIterator = api.HlIterator;const HlString = api.HlString;var gpa = std.heap.DebugAllocator(.{ .stack_trace_frames = 0 }){};const allocator = gpa.allocator();fn hlStr(s: []const u8) HlString {return .{ .ptr = s.ptr, .len = s.len };}fn nowMs() i64 {var ts: linux.timespec = undefined;_ = linux.clock_gettime(linux.CLOCK.REALTIME, &ts);return @as(i64, ts.sec) * 1000 + @divTrunc(@as(i64, ts.nsec), 1_000_000);}export fn hl_time_now(_: u32, _: [*]const HlValue) callconv(.c) HlValue {return api.makeNumber(@floatFromInt(nowMs()));}export fn hl_time_monotonic(_: u32, _: [*]const HlValue) callconv(.c) HlValue {var ts: linux.timespec = undefined;_ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts);const ns: i64 = @as(i64, ts.sec) * 1_000_000_000 + @as(i64, ts.nsec);return api.makeNumber(@floatFromInt(ns));}// "YYYY-MM-DDTHH:MM:SS.mmmZ" is 24 bytes; the year field widens for absurd// inputs, so give it room rather than truncating.var iso_buf: [40]u8 = undefined;export fn hl_time_timestamp(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {// Argument is optional: absent, null, or non-numeric all mean "now". The// .hl wrapper declares `timestamp(ms)`, so a no-arg call arrives as one// hl_null — the same thing.var ms: i64 = if (argc > 0 and argv[0].type == .hl_number)@intFromFloat(argv[0].data.number)elsenowMs();// Pre-epoch instants: floor the division so the millisecond part stays in// [0, 999] and the second walks backwards (std.time.epoch is u64-only).var secs: i64 = @divFloor(ms, 1000);var millis: i64 = ms - secs * 1000;if (secs < 0) {// Dates before 1970 have no u64 epoch representation; rather than// render garbage, clamp to the epoch itself and say so via the value.secs = 0;millis = 0;ms = 0;}const es = std.time.epoch.EpochSeconds{ .secs = @intCast(secs) };const yd = es.getEpochDay().calculateYearDay();const md = yd.calculateMonthDay();const ds = es.getDaySeconds();const out = std.fmt.bufPrint(&iso_buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}.{d:0>3}Z", .{yd.year,md.month.numeric(),@as(u32, md.day_index) + 1,ds.getHoursIntoDay(),ds.getMinutesIntoHour(),ds.getSecondsIntoMinute(),@as(u32, @intCast(millis)),}) catch return api.makeError("hl:time timestamp() could not format the instant");return api.makeString(out);}// ═══════════════════════════════════════════════════════════════════════════// TIME AS AN EVENT SOURCE (mission 254)// ═══════════════════════════════════════════════════════════════════════════//// `every(n)` / `after(n)` / `until(t)` are ONE primitive: a timerfd registered// with the interpreter's event loop as an ordinary source, exactly the shape// `spawn()` hands back from hl:proc. Two properties are the whole reason it is a// timerfd rather than a thread or a deadline the loop checks://// THE WAKE FD IS THE TIMER ITSELF. Mission 256 taught the loop to block in one// `epoll_wait` over its sources' `HlIterator.wake_fd`s. A timerfd IS such an// fd, so a timer costs one kernel wakeup at the instant it is due — not a 1ms// poll that could never resolve a 1ms interval in the first place. An idle// process with no timers is untouched (`tools/idle-cpu-gate.sh`).//// THE KERNEL OWNS THE SCHEDULE, so `every` cannot drift. `timerfd_settime` is// given an ABSOLUTE deadline on CLOCK_MONOTONIC plus an interval, and the// kernel re-arms from the previous EXPIRY, never from when anyone read the fd.// The handler's own runtime is therefore not added to the next round — which// is exactly the defect a `sleep(interval)` loop has.//// WHY `tryNext` READS THE CLOCK AND NOT THE FD. `loop_wait.Waiter` DRAINS every// fd it was woken by (`read(fd, &sink, 8)`) before the loop's poll round — that// is the level-triggered race argument in loop_wait.zig's header, and it means// the timerfd's expiration COUNT is already gone by the time this poll runs. So// the count is not the signal; the deadline is. Both sides read the same// CLOCK_MONOTONIC and the kernel expires at-or-after the absolute deadline it// was handed, so `now >= deadline` is guaranteed true when the bell rings. A// dropped or duplicated wake is harmless in both directions: a duplicate polls// to `null`, and a missed one is picked up by the loop's 250ms safety timeout.// RAW SYSCALLS, NO LIBC. This .so has never linked libc (`timestamp` renders// with std.fmt and the clock reads go through `linux.clock_gettime`), and the// timer half keeps it that way — `std.os.linux` has timerfd and nanosleep, so// nothing here needs `build.zig` to gain a `link_libc` it did not have.fn timerfdCreate() i32 {const rc = linux.timerfd_create(.MONOTONIC, .{ .NONBLOCK = true, .CLOEXEC = true });const fd: isize = @bitCast(rc);return if (fd < 0) -1 else @intCast(fd);}fn timerfdSetAbs(fd: i32, spec: *const linux.itimerspec) bool {const rc = linux.timerfd_settime(fd, .{ .ABSTIME = true }, spec, null);return @as(isize, @bitCast(rc)) == 0;}fn timerfdDisarm(fd: i32) void {const zero = linux.itimerspec{.it_interval = .{ .sec = 0, .nsec = 0 },.it_value = .{ .sec = 0, .nsec = 0 },};_ = linux.timerfd_settime(fd, .{}, &zero, null);}fn monoNs() i64 {var ts: linux.timespec = undefined;_ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts);return @as(i64, ts.sec) * 1_000_000_000 + @as(i64, ts.nsec);}/// THE FLOOR, and it is the SAME NUMBER IN BOTH REALMS (mission 254).////// The creator confirmed fractional seconds — "a millisecond is just 0.001 then/// thats okay" — and server-side that is not the limit: a timerfd carries/// nanoseconds and the loop blocks on it. THE BROWSER IS THE LIMIT, and it was/// MEASURED rather than quoted (tests/browser/tests/66-timers.mjs prints the/// number it measured on every run). HTML's timer nesting rule clamps a chained/// `setTimeout(…, 0)` to 4ms from the fifth nesting level on, and a `setInterval`/// asked for 1ms delivers ~4ms periods.////// So `every(0.001)` would mean two different things in the two realms, and the/// SAME `on t.tick()` handler is meant to run in both. The brief's choice was/// "document the divergence with the measured number, or clamp both realms to/// the same honest minimum" — CLAMPED, because a silent target divergence is/// already a named defect class in this tree and because a number the author/// writes should mean one thing everywhere. Anything below the floor is raised/// to it, in both realms (`server.js` carries the same constant), and/// `plugins/time/server.hl` says so where an author will read it.const FLOOR_NS: i64 = 4_000_000;/// ONE ARMED TIMER. Never freed: the struct is ~48 bytes, it is referenced by an/// iterator the runtime owns, and freeing it would need the loop's poll round and/// the GC sweep to agree on an order they have no reason to. `stop()` closes the/// fd (which is the only scarce resource) and marks it dead; the poll then answers/// `null` forever, which is what a retired source must do.const Timer = struct {fd: i32 = -1,/// 0 = ONE-SHOT. `after()` and `until()` are `every()` that fires once.interval_ns: i64 = 0,/// The next fire's ABSOLUTE CLOCK_MONOTONIC instant — the same value the/// kernel holds, which is what makes `now >= deadline` exact rather than/// approximate.deadline_ns: i64 = 0,count: f64 = 0,stopped: bool = false,};/// id → Timer, 1-based; `0` is "no timer" so a default-initialised member is/// never mistaken for one. Ids are never reused: `stop()` leaves the slot dead/// rather than freeing it, so a stale id can only ever answer "already stopped".var timers = std.ArrayListUnmanaged(*Timer).empty;fn timerById(id: f64) ?*Timer {if (id < 1) return null;const idx: usize = @intFromFloat(id - 1);if (idx >= timers.items.len) return null;return timers.items[idx];}fn timerTryNext(ctx: ?*anyopaque) callconv(.c) HlValue {const t: *Timer = @ptrCast(@alignCast(ctx orelse return api.makeNull()));if (t.stopped) return api.makeNull();const now = monoNs();if (now < t.deadline_ns) return api.makeNull();t.count += 1;if (t.interval_ns == 0) {// A ONE-SHOT IS SPENT. The .hl half retires the source and closes the fd// (mission 249: a source that will never speak again must not keep the// program alive); this flag makes the polls between here and there// answer null rather than fire a second time.t.stopped = true;} else {// SCHEDULED FROM THE ORIGIN, and CAUGHT UP BY SKIPPING. Advancing by one// interval keeps the phase the first fire established, so the handler's// own runtime never accumulates. If the loop was busy for longer than an// interval the missed rounds are DROPPED rather than delivered as a// burst — a periodic timer that owes you a backlog is a stampede, and// the kernel's own timerfd overrun count is discarded for the same// reason (see the header: the Waiter has already drained it).t.deadline_ns += t.interval_ns;while (t.deadline_ns <= now) t.deadline_ns += t.interval_ns;}const fields = allocator.alloc(HlField, 2) catch return api.makeNull();fields[0] = .{ .key = hlStr("count"), .value = api.makeNumber(t.count) };fields[1] = .{ .key = hlStr("at"), .value = api.makeNumber(@floatFromInt(nowMs())) };const obj = allocator.create(HlObject) catch return api.makeNull();obj.* = .{ .fields = fields.ptr, .field_count = 2, .deinit_fn = null };return api.makeObject(obj);}fn timerDeinit(ctx: ?*anyopaque) callconv(.c) void {const t: *Timer = @ptrCast(@alignCast(ctx orelse return));t.stopped = true;}/// hl_time_timer(seconds, repeat) → { id, events }export fn hl_time_timer(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_number) {return api.makeError("hl:time timer: pass the interval in SECONDS (fractions allowed)");}const secs = argv[0].data.number;if (!(secs == secs) or secs < 0) { // NaN or negativereturn api.makeError("hl:time timer: the interval must be a positive number of seconds");}const repeat = argc >= 2 and argv[1].type == .hl_bool and argv[1].data.boolean;var ns: i64 = @intFromFloat(secs * 1_000_000_000.0);if (ns < FLOOR_NS) ns = FLOOR_NS;const fd = timerfdCreate();if (fd < 0) return api.makeError("hl:time timer: timerfd_create failed");const t = allocator.create(Timer) catch return api.makeError("hl:time: out of memory");t.* = .{ .fd = fd, .interval_ns = if (repeat) ns else 0, .deadline_ns = monoNs() + ns };// ABSOLUTE, so the kernel and this plugin hold the SAME instant — the whole// exactness argument in the header. The interval is the kernel's own re-arm,// which is what makes `every` drift-free rather than nearly drift-free.const spec = linux.itimerspec{.it_interval = .{.sec = if (repeat) @intCast(@divTrunc(ns, 1_000_000_000)) else 0,.nsec = if (repeat) @intCast(@mod(ns, 1_000_000_000)) else 0,},.it_value = .{.sec = @intCast(@divTrunc(t.deadline_ns, 1_000_000_000)),.nsec = @intCast(@mod(t.deadline_ns, 1_000_000_000)),},};if (!timerfdSetAbs(fd, &spec)) {_ = linux.close(fd);return api.makeError("hl:time timer: timerfd_settime failed");}timers.append(allocator, t) catch return api.makeError("hl:time: out of memory");const id: f64 = @floatFromInt(timers.items.len);const iter = allocator.create(HlIterator) catch return api.makeError("hl:time: out of memory");iter.* = .{.context = @ptrCast(t),.next_fn = &timerTryNext,.deinit_fn = &timerDeinit,.try_next_fn = &timerTryNext,.wake_fd = fd,};const fields = allocator.alloc(HlField, 2) catch return api.makeNull();fields[0] = .{ .key = hlStr("id"), .value = api.makeNumber(id) };fields[1] = .{ .key = hlStr("events"), .value = api.makeIterator(iter) };const obj = allocator.create(HlObject) catch return api.makeNull();obj.* = .{ .fields = fields.ptr, .field_count = 2, .deinit_fn = null };return api.makeObject(obj);}/// hl_time_timer_stop(id) — disarm and close. TRUE when this call was the one/// that stopped it. The caller retires the event source FIRST (Timer.hl), so the/// loop has already dropped this fd from its epoll set by the time it is closed.export fn hl_time_timer_stop(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_number) {return api.makeError("hl:time timer stop: pass the id timer() returned");}const t = timerById(argv[0].data.number) orelse return api.makeBool(false);const was_open = t.fd >= 0;t.stopped = true;if (t.fd >= 0) {timerfdDisarm(t.fd);_ = linux.close(t.fd);t.fd = -1;}return api.makeBool(was_open);}/// hl_time_sleep(seconds) — BLOCKING, and that is the creator's ruling, not an/// omission: *"sleep you need mostly for testing or debugging stuff, its what i/// consider basic tools"*. It stops this thread, which inside a request handler/// means the loop is stopped too; `after()` is the thing to use there, and/// server.hl says so where an author will read it.export fn hl_time_sleep(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_number) {return api.makeError("hl:time sleep: pass the duration in SECONDS (fractions allowed)");}const secs = argv[0].data.number;if (!(secs == secs) or secs <= 0) return api.makeBool(false);const ns: i64 = @intFromFloat(secs * 1_000_000_000.0);// EINTR leaves the remainder; a signal must not silently shorten the sleep.var req = linux.timespec{ .sec = @intCast(@divTrunc(ns, 1_000_000_000)), .nsec = @intCast(@mod(ns, 1_000_000_000)) };var rem = linux.timespec{ .sec = 0, .nsec = 0 };while (@as(isize, @bitCast(linux.nanosleep(&req, &rem))) != 0) {if (rem.sec == 0 and rem.nsec == 0) break;req = rem;rem = .{ .sec = 0, .nsec = 0 };}return api.makeBool(true);}
Branches
- mainmain branch
Latest commits
- 2ab91ee9tickets: gate checks rows appear once (session sync); re-vendor to ff51cf46 stopped on hybriel#122, stays 837fe120mre
- e01c2b1dtickets#24: installable app (manifest, service worker, offline list), own icon; gate waits for the hello's pongmre
- 752fbb7fdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
- 38bdd5e4deploy.sh: never send .git or .gitignore to Byrodinmre
- f12fa1bcState of 2026-09-27, before the move to gitoriamre