tracker
All repositories: gitoria
38.8 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)//// …and TIME ZONES (ticket #5), over the system's TZif files — see the last// section: hl_time_offset / _local / _fromLocal / _localTimestamp / _isZone.//// 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;// the plugins' allocator (plugin_api.zig)const allocator = api.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);}// ═══════════════════════════════════════════════════════════════════════════// TIME ZONES (ticket #5)// ═══════════════════════════════════════════════════════════════════════════//// The creator's ruling (ident.worldapi.org#14): "hybriel needs time zone// support". Five calls, one primitive://// hl_time_offset(ms, zone) → seconds EAST of UTC at that instant// hl_time_local(ms, zone) → { year, month, day, hour, minute, second,// millisecond, weekday, offset }// hl_time_fromLocal(fields, zone) → epoch ms of that wall-clock reading// hl_time_localTimestamp(ms, zone) → "YYYY-MM-DDTHH:MM:SS.mmm+HH:MM"// hl_time_isZone(name) → Boolean//// THE DATABASE IS THE SYSTEM'S: /usr/share/zoneinfo, the TZif files (RFC 8536)// every Linux ships, read with raw syscalls like the rest of this .so — no libc,// no new dependency. A zone is parsed once and cached for the life of the// process. The browser twin (server.js) asks Intl instead, and the two agree// because everything EXCEPT the offset is computed by the same arithmetic on// both sides: `offset()` is the only question either realm puts to its// database, and `local`, `fromLocal` and `localTimestamp` are built on it.//// OFFSET LOOKUP, per RFC 8536: before the first transition, time type 0; between// transitions, the type the last one selected; after the last, the footer's// POSIX TZ rule (a "slim" TZif carries nothing else for the future), or the// last type when there is no footer.//// Same threading assumption as `timestamp` above: a plugin call never yields and// the runtime copies a returned value before the next call, so the results are// rendered into module-level buffers and the zone cache has no lock.const ZONEINFO = "/usr/share/zoneinfo/";const MAX_MS: f64 = 8.64e15; // the range an ECMAScript Date (and Intl) acceptsconst TzType = struct { utoff: i32, isdst: bool };/// A POSIX TZ string's rule date: Jn (1..365, Feb 29 never counted), n (0..365,/// Feb 29 counted) or Mm.w.d (weekday d of week w of month m; w=5 is "last").const RuleDate = struct {kind: enum { julian1, julian0, mwd } = .mwd,n: i32 = 0,m: i32 = 0,w: i32 = 0,d: i32 = 0,/// local time of day the change happens, seconds (may be negative or > 24h)time: i32 = 7200,};const PosixRule = struct {std_off: i32 = 0, // seconds east of UTCdst_off: i32 = 0,has_dst: bool = false,start: RuleDate = .{},end: RuleDate = .{},};const Zone = struct {trans: []i64,idx: []u8,types: []TzType,rule: ?PosixRule,};var zones = std.StringHashMapUnmanaged(?*Zone).empty;fn floorDiv(a: i64, b: i64) i64 {return @divFloor(a, b);}/// Days since 1970-01-01 of a proleptic Gregorian date (H. Hinnant's/// days_from_civil). `m` is 1..12; `d` may be any integer — it simply adds.fn daysFromCivil(y0: i64, m: i64, d: i64) i64 {const y = if (m <= 2) y0 - 1 else y0;const era = floorDiv(y, 400);const yoe = y - era * 400;const mp = @mod(m + 9, 12);const doy = @divFloor(153 * mp + 2, 5);const doe = yoe * 365 + @divFloor(yoe, 4) - @divFloor(yoe, 100) + doy;return era * 146097 + doe - 719468 + (d - 1);}const Civil = struct { y: i64, m: i64, d: i64 };fn civilFromDays(z0: i64) Civil {const z = z0 + 719468;const era = floorDiv(z, 146097);const doe = z - era * 146097;const yoe = @divFloor(doe - @divFloor(doe, 1460) + @divFloor(doe, 36524) - @divFloor(doe, 146096), 365);const doy = doe - (365 * yoe + @divFloor(yoe, 4) - @divFloor(yoe, 100));const mp = @divFloor(5 * doy + 2, 153);const d = doy - @divFloor(153 * mp + 2, 5) + 1;const m = if (mp < 10) mp + 3 else mp - 9;return .{ .y = if (m <= 2) yoe + era * 400 + 1 else yoe + era * 400, .m = m, .d = d };}fn isLeap(y: i64) bool {return @mod(y, 4) == 0 and (@mod(y, 100) != 0 or @mod(y, 400) == 0);}/// Seconds since the epoch at which rule date `r` happens in year `y`, in the/// local time whose offset is `off` (the offset in force just before it).fn ruleInstant(r: RuleDate, y: i64, off: i32) i64 {const jan1 = daysFromCivil(y, 1, 1);const day: i64 = switch (r.kind) {.julian1 => blk: {var n: i64 = r.n - 1;if (isLeap(y) and r.n >= 60) n += 1;break :blk jan1 + n;},.julian0 => jan1 + r.n,.mwd => blk: {const first = daysFromCivil(y, r.m, 1);// 1970-01-01 was a Thursday; POSIX d is 0 = Sundayconst wd_first = @mod(first + 4, 7);var day = first + @mod(r.d - wd_first, 7) + (r.w - 1) * 7;if (r.w == 5) {const next = if (r.m == 12) daysFromCivil(y + 1, 1, 1) else daysFromCivil(y, r.m + 1, 1);while (day >= next) day -= 7;}break :blk day;},};return day * 86400 + r.time - off;}fn ruleOffset(r: PosixRule, t: i64) i32 {if (!r.has_dst) return r.std_off;const y = civilFromDays(floorDiv(t + r.std_off, 86400)).y;const start = ruleInstant(r.start, y, r.std_off);const end = ruleInstant(r.end, y, r.dst_off);const in_dst = if (start < end) (t >= start and t < end) else !(t >= end and t < start);return if (in_dst) r.dst_off else r.std_off;}fn zoneOffset(z: *const Zone, t: i64) i32 {if (z.trans.len == 0) {if (z.rule) |r| return ruleOffset(r, t);return z.types[0].utoff;}if (t < z.trans[0]) return z.types[0].utoff;if (t >= z.trans[z.trans.len - 1]) {if (z.rule) |r| return ruleOffset(r, t);return z.types[z.idx[z.idx.len - 1]].utoff;}var lo: usize = 0;var hi: usize = z.trans.len - 1; // trans[lo] <= t < trans[hi]while (hi - lo > 1) {const mid = lo + (hi - lo) / 2;if (z.trans[mid] <= t) lo = mid else hi = mid;}return z.types[z.idx[lo]].utoff;}// ── the POSIX TZ footer ─────────────────────────────────────────────────────const Cursor = struct {s: []const u8,i: usize = 0,fn peek(c: *Cursor) u8 {return if (c.i < c.s.len) c.s[c.i] else 0;}fn num(c: *Cursor) ?i32 {var v: i32 = 0;const from = c.i;while (c.i < c.s.len and c.s[c.i] >= '0' and c.s[c.i] <= '9') : (c.i += 1) {v = v * 10 + @as(i32, c.s[c.i] - '0');}return if (c.i == from) null else v;}};fn parseName(c: *Cursor) bool {if (c.peek() == '<') {while (c.i < c.s.len and c.s[c.i] != '>') c.i += 1;if (c.peek() != '>') return false;c.i += 1;return true;}const from = c.i;while (c.i < c.s.len and std.ascii.isAlphabetic(c.s[c.i])) c.i += 1;return c.i - from >= 3;}/// [+-]hh[:mm[:ss]] → seconds, sign as writtenfn parseHms(c: *Cursor) ?i32 {var sign: i32 = 1;if (c.peek() == '+') c.i += 1 else if (c.peek() == '-') {sign = -1;c.i += 1;}var v = (c.num() orelse return null) * 3600;if (c.peek() == ':') {c.i += 1;v += (c.num() orelse return null) * 60;if (c.peek() == ':') {c.i += 1;v += c.num() orelse return null;}}return sign * v;}fn parseRuleDate(c: *Cursor) ?RuleDate {var r = RuleDate{};if (c.peek() == 'J') {c.i += 1;r.kind = .julian1;r.n = c.num() orelse return null;} else if (c.peek() == 'M') {c.i += 1;r.kind = .mwd;r.m = c.num() orelse return null;if (c.peek() != '.') return null;c.i += 1;r.w = c.num() orelse return null;if (c.peek() != '.') return null;c.i += 1;r.d = c.num() orelse return null;if (r.m < 1 or r.m > 12 or r.w < 1 or r.w > 5 or r.d > 6) return null;} else {r.kind = .julian0;r.n = c.num() orelse return null;}if (c.peek() == '/') {c.i += 1;r.time = parseHms(c) orelse return null;}return r;}fn parsePosix(s: []const u8) ?PosixRule {var c = Cursor{ .s = s };var r = PosixRule{};if (!parseName(&c)) return null;// POSIX writes the offset WEST of UTC ("CET-1"); this plugin speaks eastr.std_off = -(parseHms(&c) orelse return null);if (c.i == s.len) return r;if (!parseName(&c)) return null;r.has_dst = true;r.dst_off = r.std_off + 3600;if (c.peek() != ',' and c.i < s.len) r.dst_off = -(parseHms(&c) orelse return null);if (c.peek() != ',') return null; // a DST name without rules: no reliable answerc.i += 1;r.start = parseRuleDate(&c) orelse return null;if (c.peek() != ',') return null;c.i += 1;r.end = parseRuleDate(&c) orelse return null;return r;}// ── TZif ────────────────────────────────────────────────────────────────────fn be32(b: []const u8) i32 {return std.mem.readInt(i32, b[0..4], .big);}fn be64(b: []const u8) i64 {return std.mem.readInt(i64, b[0..8], .big);}/// A name the zone database may answer for: the shape of an IANA name, and/// never a path out of the directory or one of its non-zone entries.fn plausibleName(name: []const u8) bool {if (name.len == 0 or name.len > 64) return false;if (!std.ascii.isAlphabetic(name[0])) return false;for (name) |ch| {if (!(std.ascii.isAlphanumeric(ch) or ch == '/' or ch == '_' or ch == '-' or ch == '+')) return false;}if (std.mem.indexOf(u8, name, "//") != null or name[name.len - 1] == '/') return false;// the directory's own furniture: alternative trees, and entries Intl rejectsconst not_zones = [_][]const u8{ "posix/", "right/" };for (not_zones) |p| if (std.mem.startsWith(u8, name, p)) return false;const not_names = [_][]const u8{ "posixrules", "localtime", "Factory" };for (not_names) |n| if (std.mem.eql(u8, name, n)) return false;return true;}fn readZoneFile(name: []const u8) ?[]u8 {var path_buf: [128]u8 = undefined;const path = std.fmt.bufPrintZ(&path_buf, "{s}{s}", .{ ZONEINFO, name }) catch return null;const rc = linux.open(path, .{ .ACCMODE = .RDONLY, .CLOEXEC = true }, 0);const sfd: isize = @bitCast(rc);if (sfd < 0) return null;const fd: i32 = @intCast(sfd);defer _ = linux.close(fd);var out: std.ArrayList(u8) = .empty;var buf: [8192]u8 = undefined;while (true) {const n: isize = @bitCast(linux.read(fd, &buf, buf.len));if (n < 0) {out.deinit(allocator);return null; // EISDIR for "Europe", among others}if (n == 0) break;out.appendSlice(allocator, buf[0..@intCast(n)]) catch {out.deinit(allocator);return null;};if (out.items.len > 1 << 20) break; // no zone is this large}return out.toOwnedSlice(allocator) catch null;}const Counts = struct { isut: usize, isstd: usize, leap: usize, time: usize, typ: usize, chr: usize };fn header(b: []const u8) ?Counts {if (b.len < 44 or !std.mem.eql(u8, b[0..4], "TZif")) return null;const n = struct {fn at(x: []const u8, o: usize) usize {const v = be32(x[o..]);return if (v < 0) 0 else @intCast(v);}};return .{.isut = n.at(b, 20),.isstd = n.at(b, 24),.leap = n.at(b, 28),.time = n.at(b, 32),.typ = n.at(b, 36),.chr = n.at(b, 40),};}fn parseTzif(b: []const u8) ?*Zone {const h1 = header(b) orelse return null;const version = b[4];var p: usize = 44;var tsize: usize = 4;var h = h1;if (version >= '2') {// skip the 32-bit block; the 64-bit one follows with its own headerp += h1.time * 4 + h1.time + h1.typ * 6 + h1.chr + h1.leap * 8 + h1.isstd + h1.isut;h = header(b[p..]) orelse return null;p += 44;tsize = 8;}const need = h.time * tsize + h.time + h.typ * 6 + h.chr + h.leap * (tsize + 4) + h.isstd + h.isut;if (h.typ == 0 or b.len < p + need) return null;const z = allocator.create(Zone) catch return null;z.trans = allocator.alloc(i64, h.time) catch return null;z.idx = allocator.alloc(u8, h.time) catch return null;z.types = allocator.alloc(TzType, h.typ) catch return null;for (0..h.time) |i| {z.trans[i] = if (tsize == 8) be64(b[p + i * 8 ..]) else be32(b[p + i * 4 ..]);}p += h.time * tsize;for (0..h.time) |i| {z.idx[i] = b[p + i];if (z.idx[i] >= h.typ) return null;}p += h.time;for (0..h.typ) |i| {const e = b[p + i * 6 ..];z.types[i] = .{ .utoff = be32(e), .isdst = e[4] != 0 };}p += h.typ * 6 + h.chr + h.leap * (tsize + 4) + h.isstd + h.isut;z.rule = null;if (version >= '2' and p < b.len and b[p] == '\n') {const rest = b[p + 1 ..];if (std.mem.indexOfScalar(u8, rest, '\n')) |end| {if (end > 0) z.rule = parsePosix(rest[0..end]);}}return z;}/// The cached zone, or null when the database has no such zone.fn zoneByName(name: []const u8) ?*Zone {if (zones.get(name)) |cached| return cached;const z: ?*Zone = if (!plausibleName(name)) null else blk: {const bytes = readZoneFile(name) orelse break :blk null;defer allocator.free(bytes);break :blk parseTzif(bytes);};// unknown names are cached too, so a typo in a loop costs one open()if (zones.count() < 4096) {const key = allocator.dupe(u8, name) catch return z;zones.put(allocator, key, z) catch allocator.free(key);}return z;}// ── the calls ───────────────────────────────────────────────────────────────var err_buf: [160]u8 = undefined;fn unknownZone(name: []const u8) HlValue {const shown = if (name.len > 64) name[0..64] else name;const msg = std.fmt.bufPrint(&err_buf, "hl:time: unknown time zone '{s}'", .{shown}) catch "hl:time: unknown time zone";return api.makeError(msg);}const ZoneArg = union(enum) { zone: *Zone, err: HlValue };fn zoneArg(argc: u32, argv: [*]const HlValue, at: u32) ZoneArg {if (argc <= at or argv[at].type != .hl_string) {return .{ .err = api.makeError("hl:time: pass the time zone as an IANA name, e.g. 'Europe/Vienna'") };}const s = argv[at].data.string;const name = s.ptr[0..s.len];return if (zoneByName(name)) |z| .{ .zone = z } else .{ .err = unknownZone(name) };}/// The instant argument: a finite epoch-ms Number within Date's range, floored/// to a whole millisecond.fn instantArg(argc: u32, argv: [*]const HlValue) ?i64 {if (argc < 1 or argv[0].type != .hl_number) return null;const v = argv[0].data.number;if (!(v == v) or v > MAX_MS or v < -MAX_MS) return null;return @intFromFloat(@floor(v));}const BAD_INSTANT = "hl:time: pass the instant in epoch milliseconds (what now() returns)";fn offsetAtMs(z: *const Zone, ms: i64) i32 {return zoneOffset(z, floorDiv(ms, 1000));}/// hl_time_offset(ms, zone) → seconds east of UTC in force at that instantexport fn hl_time_offset(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {const ms = instantArg(argc, argv) orelse return api.makeError(BAD_INSTANT);const z = switch (zoneArg(argc, argv, 1)) {.zone => |z| z,.err => |e| return e,};return api.makeNumber(@floatFromInt(offsetAtMs(z, ms)));}const FIELD_NAMES = [_][]const u8{ "year", "month", "day", "hour", "minute", "second", "millisecond", "weekday", "offset" };var local_fields: [FIELD_NAMES.len]HlField = undefined;var local_obj: HlObject = undefined;/// hl_time_local(ms, zone) → the wall-clock reading in that zone at that instantexport fn hl_time_local(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {const ms = instantArg(argc, argv) orelse return api.makeError(BAD_INSTANT);const z = switch (zoneArg(argc, argv, 1)) {.zone => |z| z,.err => |e| return e,};const off = offsetAtMs(z, ms);const local = ms + @as(i64, off) * 1000;const days = floorDiv(local, 86_400_000);const in_day = local - days * 86_400_000;const c = civilFromDays(days);const values = [_]i64{c.y, c.m, c.d,@divFloor(in_day, 3_600_000), @mod(@divFloor(in_day, 60_000), 60), @mod(@divFloor(in_day, 1000), 60),@mod(in_day, 1000), @mod(days + 3, 7) + 1, // ISO: 1 = Monday … 7 = Sundayoff,};for (FIELD_NAMES, values, 0..) |k, v, i| {local_fields[i] = .{ .key = hlStr(k), .value = api.makeNumber(@floatFromInt(v)) };}local_obj = .{ .fields = &local_fields, .field_count = local_fields.len, .deinit_fn = null };return api.makeObject(&local_obj);}fn fieldNumber(obj: *const HlObject, key: []const u8, default: f64) ?f64 {for (obj.fields[0..obj.field_count]) |f| {if (!std.mem.eql(u8, f.key.ptr[0..f.key.len], key)) continue;return switch (f.value.type) {.hl_null => default,.hl_number => if (f.value.data.number == f.value.data.number and@abs(f.value.data.number) < 1e12) @floor(f.value.data.number) else null,else => null,};}return default;}/// The wall-clock reading as if it were UTC, in ms. Out-of-range fields carry/// over the way Date.UTC's do: month 13 is January of the next year, minute 90/// is an hour and a half.fn wallMs(y: f64, mo: f64, d: f64, h: f64, mi: f64, s: f64, milli: f64) i64 {const m0: i64 = @as(i64, @intFromFloat(mo)) - 1;const yy: i64 = @as(i64, @intFromFloat(y)) + floorDiv(m0, 12);const days = daysFromCivil(yy, @mod(m0, 12) + 1, @intFromFloat(d));return days * 86_400_000 + @as(i64, @intFromFloat(h)) * 3_600_000 +@as(i64, @intFromFloat(mi)) * 60_000 + @as(i64, @intFromFloat(s)) * 1000 + @as(i64, @intFromFloat(milli));}/// THE INVERSE, and the one place a zone can answer twice or not at all./// A reading inside a FOLD (the hour a fall-back repeats) is the EARLIER/// instant; a reading inside a GAP (the hour a spring-forward skips) is moved/// forward by the gap's length — 02:30 on Vienna's spring-forward night is/// 03:30 CEST. That is Temporal's "compatible" disambiguation, and server.js/// runs the same steps.fn fromWall(z: *const Zone, wall: i64) i64 {const before = offsetAtMs(z, wall - 86_400_000);const after = offsetAtMs(z, wall + 86_400_000);const a = wall - @as(i64, before) * 1000;const b = wall - @as(i64, after) * 1000;const a_ok = offsetAtMs(z, a) == before;const b_ok = offsetAtMs(z, b) == after;if (a_ok and b_ok) return @min(a, b);if (a_ok) return a;if (b_ok) return b;return a; // the gap: read with the offset in force before it}/// hl_time_fromLocal(fields, zone) → epoch msexport fn hl_time_fromLocal(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_object) {return api.makeError("hl:time fromLocal: pass the fields as { year, month, day, hour, minute, second, millisecond }");}const z = switch (zoneArg(argc, argv, 1)) {.zone => |z| z,.err => |e| return e,};const o = argv[0].data.object;const bad = api.makeError("hl:time fromLocal: every field must be a Number (year is required)");const y = fieldNumber(o, "year", std.math.nan(f64)) orelse return bad;if (!(y == y)) return bad;const mo = fieldNumber(o, "month", 1) orelse return bad;const d = fieldNumber(o, "day", 1) orelse return bad;const h = fieldNumber(o, "hour", 0) orelse return bad;const mi = fieldNumber(o, "minute", 0) orelse return bad;const s = fieldNumber(o, "second", 0) orelse return bad;const milli = fieldNumber(o, "millisecond", 0) orelse return bad;const wall = wallMs(y, mo, d, h, mi, s, milli);if (@abs(wall) > 8_640_000_000_000_000 + 86_400_000) return api.makeError(BAD_INSTANT);const ms = fromWall(z, wall);if (@abs(ms) > 8_640_000_000_000_000) return api.makeError(BAD_INSTANT);return api.makeNumber(@floatFromInt(ms));}var local_iso_buf: [48]u8 = undefined;/// hl_time_localTimestamp(ms, zone) → ISO-8601 with the zone's offsetexport fn hl_time_localTimestamp(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {const ms = instantArg(argc, argv) orelse return api.makeError(BAD_INSTANT);const z = switch (zoneArg(argc, argv, 1)) {.zone => |z| z,.err => |e| return e,};const off = offsetAtMs(z, ms);const local = ms + @as(i64, off) * 1000;const days = floorDiv(local, 86_400_000);const in_day: u64 = @intCast(local - days * 86_400_000);const c = civilFromDays(days);var w: std.Io.Writer = .fixed(&local_iso_buf);// 0000..9999 as four digits; beyond, ISO's expanded form (Date#toISOString's)if (c.y >= 0 and c.y <= 9999) {w.print("{d:0>4}", .{@as(u64, @intCast(c.y))}) catch unreachable;} else {w.print("{c}{d:0>6}", .{ @as(u8, if (c.y < 0) '-' else '+'), @abs(c.y) }) catch unreachable;}const abs_off: u64 = @abs(off);w.print("-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}.{d:0>3}{c}{d:0>2}:{d:0>2}", .{@as(u64, @intCast(c.m)), @as(u64, @intCast(c.d)),in_day / 3_600_000, in_day / 60_000 % 60,in_day / 1000 % 60, in_day % 1000,@as(u8, if (off < 0) '-' else '+'), abs_off / 3600,abs_off / 60 % 60,}) catch unreachable;// an offset with seconds (local mean time before a zone's first rule)if (abs_off % 60 != 0) w.print(":{d:0>2}", .{abs_off % 60}) catch unreachable;return api.makeString(w.buffered());}/// hl_time_isZone(name) → true when the system's zone database knows the nameexport fn hl_time_isZone(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_string) return api.makeBool(false);const s = argv[0].data.string;return api.makeBool(zoneByName(s.ptr[0..s.len]) != null);}
Branches
- mainmain branch
Latest commits
- 96ba683adeploy.sh: a gate without a 'passed,' line (check-theme) no longer ends the scriptmre
- eb3b9205tracker: report 031mre
- 9b5d2e89tracker mission 031: README (What it does, Test: four gates + the #32 checks, Files: theme/, new pages), STATUS (real copy, A/B load, how to repeat, open points), LOGmre
- 39950e4ctracker#32 (mission 031): the WorldAPI theme (theme/ vendored verbatim from layouts.worldapi.org 85b5654; styles.hl inherits it: accent green-dark, type colours 1-6; own base/header rules, row lines, genre-pill and inverted-button frames removed, the season foldable keeps its line; check-theme 21 -> 0, 4th deploy gate; main actions class primary) and the #32 header (theme AppHeader/MainMenu/UserMenu/Sidebar/ContentFirst: desktop brand, search, Series|Shows|Movies|Genres|People, user icon with Unwatched..Settings, Logout; signed out the ident selector, phone the iD icon dropdown; phone menu in the sidebar overlay; marked entry by :has); /find -> /search/<q>, /genres, /people(/<letter>), /settings; main { ContentFirst { slot } } works around the hl:web one-line slot bug; gates 365/0, 32/0, 52/0, check-theme 0mre
- a386dc92tracker: reports 029 + 030mre
- 71e0fd7dtracker missions 029 + 030: README (What it does, Files, gate count), STATUS (real-copy numbers, how to repeat, open points), LOGmre
- d36ea6eatracker#34 + #35 (mission 030): Follow directly under the poster, as wide as the poster (show.hl, styles.hl); the status pill next to a series' title — TVmaze's status (new tvmazeStatus, stored by the sync's TVmaze merge) else TMDB's, TVmaze Ended + TMDB Canceled = Canceled, inverted (filled, dark text, no border), green running / yellow pending / red canceled / muted ended (shows.hl statusOf); the daily delta asks TVmaze's status of an unfollowed series TVmaze's change list names (dailysync.hl syncRunStep, sync.hl syncTvmazeStatus); the status backfill after the details repair (backfill.hl, jobs.hl statusTick; resumable, 550 ms per TVmaze request); gates 354/0, 32/0, 52/0mre
- 7d7d4487tracker#33 (mission 029): reduced titles — every title TMDB's details never went through this app (no detailsAt, no tmdbSync) is incomplete (shows.hl isIncomplete; the old tracker's migrated rows passed #26's test: 5,697 non-adult on the live copy, 691 series without seasons); the repair job does the visibly reduced first (shows.hl missingParts), the page completes one on open; a title TMDB has no poster for (The Remaining) shows the placeholder; tools/count-incomplete.hl; gate fixtures stand for synced titles (tmdbSync), tests/seed-reduced.hl + #33 checks; gates 347/0, 32/0, 52/0mre
- 661c2592tracker: report 028mre
- 27c916fatracker mission 028: README ("Code order", the new file map), STATUS (counts before/after, tests, how to repeat, open), LOGmre
- d924f398tracker mission 028: comments name the new files (sync.hl, dailysync.hl, backfill.hl, credits.hl, jobs.hl, images.hl …); tools/ref-params.py + tools/lambda-audit.py also scan lib/ (they globbed the root only), lambda-audit counts a plain `x = p` alias like `let x = p`mre
- 2e89b968tracker mission 028 (code order) 5/5 let: `let` only where a variable is reassigned — 667 never-reassigned lets became plain declarations (project.hl, lib/, components/, tools/, tests/); kept: 264 in loop bodies (a plain declaration there is 'Cannot reassign' on the 2nd pass), 234 reassigned, 27 whose name is also a member/outer/free name (a plain write would rebind it); tools/let-audit.py decides and fixes (README 'Code order'); tests/realdata-m028.{sh,mjs} = the page-output diff on a real copy; gates 342/0, 32/0, 52/0, real-copy pages identicalmre
- 54796ff2tracker mission 028 (code order) 4/5 thin faces + last copies: the show page's check/follow faces call lib/watches.hl toggleWatched / toggleSeasonWatched (seasonAllWatched moved there) and lib/follows.hl toggleFollowed; both logins (header selector face, /login/callback) share lib/users.hl userOfCode; todayStr/listOf copies in components and the export readers copied into tools/migrate.hl + tools/old-short-ids.hl now once (lib/util.hl, lib/export.hl); gates 342/0, 32/0, 52/0; old-short-ids output byte-identical, migrate output identicalmre
- 06b078e3tracker mission 028 (code order) 3/5 project.hl is the map: config, routes, wiring and a feature → file index (914 → 258 lines); the background jobs (daily sync run, backfills, details repair, credits job, merge, short ids, collection seed) moved unchanged into lib/jobs.hl (a class: their state is reassigned every step, a static cannot be; one instance made after the server), the login callback into lib/users.hl, poster/photo serving into lib/images.hl, the /shows/<slug> rule into lib/shows.hl showsMovedPath; route handlers are thin wrappers; gates 342/0, 32/0, 52/0, real-copy pages identicalmre
- 94716fd2tracker mission 028 (code order) 2/5 util + topics: lib/util.hl holds envOr, storageDir, postersDir, profilesDir, newId, hexDigits, todayStr, dateOr, textOr, hasId, listOr, firstOf, sortDesc once (were copied into up to 5 files); tmdbsync.hl split into tmdb.hl (TMDB/TVmaze requests), sync.hl (one title's sync), sync-helpers.hl, backfill.hl; details.hl split into details.hl, credits.hl, credits-helpers.hl (isIncomplete to shows.hl); search-helpers.hl (words, query, ranking, slugs); collections.hl (the TMDB collection seed, out of franchises.hl); deltasync.hl renamed dailysync.hl; no behaviour change: gates 342/0, 32/0, 52/0, real-copy pages identicalmre
- 186079b0tracker mission 028 (code order) 1/5 move: every root .hl except project.hl into lib/ (styles.hl into components/), import paths only; gates 342/0, 32/0, 52/0; real-copy pages identicalmre
- 4f47f181tracker: report 027mre
- dc1d4be4tracker mission 027: Hybriel master 06617221 vendored (plugin allocator fixes 3a781359 + 413f60e4); real copy RSS through first-start jobs + 400 loads flat ~2.55 GB (190aa11d 2.3 -> 5.6 GB), page times <= 1.1x; gates 342/0, 32/0, 52/0mre
- 84e1b3e1tracker: reports 025 + 026mre
- dc40d859tracker#31 (mission 026): duplicate titles merged — the 68 type+tmdbId pairs held by 157 records were the old tracker's (all migrated); merge.hl repair job (own clock, before the TMDB jobs) keeps one keeper per title (follows/watches > old short id > oldest), moves follows, watches, seasons, cast, credits, timelines, tombstones the rest (mergedInto, never deleted), slugs + short ids 301 to the keeper; stray seasons merged into their listed twin (Reacher S3 watches) or linked when watched; search import re-checks before its put; deploy.sh waits up to 90 s for 200; real copy 68 -> 0 dup ids, az5b2 follows/watches equal; gates 342/0, 32/0, 52/0mre