gitoriaLog in with ident

gitoria

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit68dcb60368dcb603deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre68dcb603/plugins/time/time.zig

16.2 KB

  1. // hl:time plugin — native shared library (libtime.so, dlopen'd by the runtime)
  2. //
  3. // The clock (D22):
  4. // hl_time_now() → epoch milliseconds, UTC wall clock (Number)
  5. // hl_time_timestamp(ms?) → ISO-8601 UTC "YYYY-MM-DDTHH:MM:SS.mmmZ" (String)
  6. // hl_time_monotonic() → nanoseconds off a monotonic counter (Number)
  7. //
  8. // …and TIME AS AN EVENT SOURCE (mission 254):
  9. // hl_time_timer(secs, repeat) → { id, events } — `events` is a LOOP SOURCE
  10. // whose wake fd IS a timerfd, so the interpreter
  11. // loop's epoll (mission 256) blocks on the
  12. // kernel timer itself instead of polling
  13. // hl_time_timer_stop(id) → disarm, close, retire
  14. // hl_time_sleep(secs) → BLOCKING nanosleep (the creator's ruling: a
  15. // basic tool for tests and debugging)
  16. //
  17. // The clock calls allocate nothing and hold no state; `timestamp` renders into a
  18. // module-level buffer: the runtime dupes returned strings into its own tracker
  19. // the instant the call returns (plugin_loader.zig hlToValue .hl_string) and a
  20. // plugin call never yields, so the buffer cannot be observed stale or torn.
  21. //
  22. // Number is f64. Epoch ms (~1.8e12) is exact well past the year 200000, and
  23. // monotonic ns stays exact for the first ~104 days of counter uptime (2^53 ns);
  24. // beyond that the granularity coarsens above 1 ns, which is irrelevant for the
  25. // difference-measurement the call exists for.
  26. const std = @import("std");
  27. const linux = std.os.linux;
  28. const api = @import("plugin_api");
  29. const HlValue = api.HlValue;
  30. const HlObject = api.HlObject;
  31. const HlField = api.HlField;
  32. const HlIterator = api.HlIterator;
  33. const HlString = api.HlString;
  34. var gpa = std.heap.DebugAllocator(.{ .stack_trace_frames = 0 }){};
  35. const allocator = gpa.allocator();
  36. fn hlStr(s: []const u8) HlString {
  37. return .{ .ptr = s.ptr, .len = s.len };
  38. }
  39. fn nowMs() i64 {
  40. var ts: linux.timespec = undefined;
  41. _ = linux.clock_gettime(linux.CLOCK.REALTIME, &ts);
  42. return @as(i64, ts.sec) * 1000 + @divTrunc(@as(i64, ts.nsec), 1_000_000);
  43. }
  44. export fn hl_time_now(_: u32, _: [*]const HlValue) callconv(.c) HlValue {
  45. return api.makeNumber(@floatFromInt(nowMs()));
  46. }
  47. export fn hl_time_monotonic(_: u32, _: [*]const HlValue) callconv(.c) HlValue {
  48. var ts: linux.timespec = undefined;
  49. _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts);
  50. const ns: i64 = @as(i64, ts.sec) * 1_000_000_000 + @as(i64, ts.nsec);
  51. return api.makeNumber(@floatFromInt(ns));
  52. }
  53. // "YYYY-MM-DDTHH:MM:SS.mmmZ" is 24 bytes; the year field widens for absurd
  54. // inputs, so give it room rather than truncating.
  55. var iso_buf: [40]u8 = undefined;
  56. export fn hl_time_timestamp(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  57. // Argument is optional: absent, null, or non-numeric all mean "now". The
  58. // .hl wrapper declares `timestamp(ms)`, so a no-arg call arrives as one
  59. // hl_null — the same thing.
  60. var ms: i64 = if (argc > 0 and argv[0].type == .hl_number)
  61. @intFromFloat(argv[0].data.number)
  62. else
  63. nowMs();
  64. // Pre-epoch instants: floor the division so the millisecond part stays in
  65. // [0, 999] and the second walks backwards (std.time.epoch is u64-only).
  66. var secs: i64 = @divFloor(ms, 1000);
  67. var millis: i64 = ms - secs * 1000;
  68. if (secs < 0) {
  69. // Dates before 1970 have no u64 epoch representation; rather than
  70. // render garbage, clamp to the epoch itself and say so via the value.
  71. secs = 0;
  72. millis = 0;
  73. ms = 0;
  74. }
  75. const es = std.time.epoch.EpochSeconds{ .secs = @intCast(secs) };
  76. const yd = es.getEpochDay().calculateYearDay();
  77. const md = yd.calculateMonthDay();
  78. const ds = es.getDaySeconds();
  79. 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", .{
  80. yd.year,
  81. md.month.numeric(),
  82. @as(u32, md.day_index) + 1,
  83. ds.getHoursIntoDay(),
  84. ds.getMinutesIntoHour(),
  85. ds.getSecondsIntoMinute(),
  86. @as(u32, @intCast(millis)),
  87. }) catch return api.makeError("hl:time timestamp() could not format the instant");
  88. return api.makeString(out);
  89. }
  90. // ═══════════════════════════════════════════════════════════════════════════
  91. // TIME AS AN EVENT SOURCE (mission 254)
  92. // ═══════════════════════════════════════════════════════════════════════════
  93. //
  94. // `every(n)` / `after(n)` / `until(t)` are ONE primitive: a timerfd registered
  95. // with the interpreter's event loop as an ordinary source, exactly the shape
  96. // `spawn()` hands back from hl:proc. Two properties are the whole reason it is a
  97. // timerfd rather than a thread or a deadline the loop checks:
  98. //
  99. // THE WAKE FD IS THE TIMER ITSELF. Mission 256 taught the loop to block in one
  100. // `epoll_wait` over its sources' `HlIterator.wake_fd`s. A timerfd IS such an
  101. // fd, so a timer costs one kernel wakeup at the instant it is due — not a 1ms
  102. // poll that could never resolve a 1ms interval in the first place. An idle
  103. // process with no timers is untouched (`tools/idle-cpu-gate.sh`).
  104. //
  105. // THE KERNEL OWNS THE SCHEDULE, so `every` cannot drift. `timerfd_settime` is
  106. // given an ABSOLUTE deadline on CLOCK_MONOTONIC plus an interval, and the
  107. // kernel re-arms from the previous EXPIRY, never from when anyone read the fd.
  108. // The handler's own runtime is therefore not added to the next round — which
  109. // is exactly the defect a `sleep(interval)` loop has.
  110. //
  111. // WHY `tryNext` READS THE CLOCK AND NOT THE FD. `loop_wait.Waiter` DRAINS every
  112. // fd it was woken by (`read(fd, &sink, 8)`) before the loop's poll round — that
  113. // is the level-triggered race argument in loop_wait.zig's header, and it means
  114. // the timerfd's expiration COUNT is already gone by the time this poll runs. So
  115. // the count is not the signal; the deadline is. Both sides read the same
  116. // CLOCK_MONOTONIC and the kernel expires at-or-after the absolute deadline it
  117. // was handed, so `now >= deadline` is guaranteed true when the bell rings. A
  118. // dropped or duplicated wake is harmless in both directions: a duplicate polls
  119. // to `null`, and a missed one is picked up by the loop's 250ms safety timeout.
  120. // RAW SYSCALLS, NO LIBC. This .so has never linked libc (`timestamp` renders
  121. // with std.fmt and the clock reads go through `linux.clock_gettime`), and the
  122. // timer half keeps it that way — `std.os.linux` has timerfd and nanosleep, so
  123. // nothing here needs `build.zig` to gain a `link_libc` it did not have.
  124. fn timerfdCreate() i32 {
  125. const rc = linux.timerfd_create(.MONOTONIC, .{ .NONBLOCK = true, .CLOEXEC = true });
  126. const fd: isize = @bitCast(rc);
  127. return if (fd < 0) -1 else @intCast(fd);
  128. }
  129. fn timerfdSetAbs(fd: i32, spec: *const linux.itimerspec) bool {
  130. const rc = linux.timerfd_settime(fd, .{ .ABSTIME = true }, spec, null);
  131. return @as(isize, @bitCast(rc)) == 0;
  132. }
  133. fn timerfdDisarm(fd: i32) void {
  134. const zero = linux.itimerspec{
  135. .it_interval = .{ .sec = 0, .nsec = 0 },
  136. .it_value = .{ .sec = 0, .nsec = 0 },
  137. };
  138. _ = linux.timerfd_settime(fd, .{}, &zero, null);
  139. }
  140. fn monoNs() i64 {
  141. var ts: linux.timespec = undefined;
  142. _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts);
  143. return @as(i64, ts.sec) * 1_000_000_000 + @as(i64, ts.nsec);
  144. }
  145. /// THE FLOOR, and it is the SAME NUMBER IN BOTH REALMS (mission 254).
  146. ///
  147. /// The creator confirmed fractional seconds — "a millisecond is just 0.001 then
  148. /// thats okay" — and server-side that is not the limit: a timerfd carries
  149. /// nanoseconds and the loop blocks on it. THE BROWSER IS THE LIMIT, and it was
  150. /// MEASURED rather than quoted (tests/browser/tests/66-timers.mjs prints the
  151. /// number it measured on every run). HTML's timer nesting rule clamps a chained
  152. /// `setTimeout(…, 0)` to 4ms from the fifth nesting level on, and a `setInterval`
  153. /// asked for 1ms delivers ~4ms periods.
  154. ///
  155. /// So `every(0.001)` would mean two different things in the two realms, and the
  156. /// SAME `on t.tick()` handler is meant to run in both. The brief's choice was
  157. /// "document the divergence with the measured number, or clamp both realms to
  158. /// the same honest minimum" — CLAMPED, because a silent target divergence is
  159. /// already a named defect class in this tree and because a number the author
  160. /// writes should mean one thing everywhere. Anything below the floor is raised
  161. /// to it, in both realms (`server.js` carries the same constant), and
  162. /// `plugins/time/server.hl` says so where an author will read it.
  163. const FLOOR_NS: i64 = 4_000_000;
  164. /// ONE ARMED TIMER. Never freed: the struct is ~48 bytes, it is referenced by an
  165. /// iterator the runtime owns, and freeing it would need the loop's poll round and
  166. /// the GC sweep to agree on an order they have no reason to. `stop()` closes the
  167. /// fd (which is the only scarce resource) and marks it dead; the poll then answers
  168. /// `null` forever, which is what a retired source must do.
  169. const Timer = struct {
  170. fd: i32 = -1,
  171. /// 0 = ONE-SHOT. `after()` and `until()` are `every()` that fires once.
  172. interval_ns: i64 = 0,
  173. /// The next fire's ABSOLUTE CLOCK_MONOTONIC instant — the same value the
  174. /// kernel holds, which is what makes `now >= deadline` exact rather than
  175. /// approximate.
  176. deadline_ns: i64 = 0,
  177. count: f64 = 0,
  178. stopped: bool = false,
  179. };
  180. /// id → Timer, 1-based; `0` is "no timer" so a default-initialised member is
  181. /// never mistaken for one. Ids are never reused: `stop()` leaves the slot dead
  182. /// rather than freeing it, so a stale id can only ever answer "already stopped".
  183. var timers = std.ArrayListUnmanaged(*Timer).empty;
  184. fn timerById(id: f64) ?*Timer {
  185. if (id < 1) return null;
  186. const idx: usize = @intFromFloat(id - 1);
  187. if (idx >= timers.items.len) return null;
  188. return timers.items[idx];
  189. }
  190. fn timerTryNext(ctx: ?*anyopaque) callconv(.c) HlValue {
  191. const t: *Timer = @ptrCast(@alignCast(ctx orelse return api.makeNull()));
  192. if (t.stopped) return api.makeNull();
  193. const now = monoNs();
  194. if (now < t.deadline_ns) return api.makeNull();
  195. t.count += 1;
  196. if (t.interval_ns == 0) {
  197. // A ONE-SHOT IS SPENT. The .hl half retires the source and closes the fd
  198. // (mission 249: a source that will never speak again must not keep the
  199. // program alive); this flag makes the polls between here and there
  200. // answer null rather than fire a second time.
  201. t.stopped = true;
  202. } else {
  203. // SCHEDULED FROM THE ORIGIN, and CAUGHT UP BY SKIPPING. Advancing by one
  204. // interval keeps the phase the first fire established, so the handler's
  205. // own runtime never accumulates. If the loop was busy for longer than an
  206. // interval the missed rounds are DROPPED rather than delivered as a
  207. // burst — a periodic timer that owes you a backlog is a stampede, and
  208. // the kernel's own timerfd overrun count is discarded for the same
  209. // reason (see the header: the Waiter has already drained it).
  210. t.deadline_ns += t.interval_ns;
  211. while (t.deadline_ns <= now) t.deadline_ns += t.interval_ns;
  212. }
  213. const fields = allocator.alloc(HlField, 2) catch return api.makeNull();
  214. fields[0] = .{ .key = hlStr("count"), .value = api.makeNumber(t.count) };
  215. fields[1] = .{ .key = hlStr("at"), .value = api.makeNumber(@floatFromInt(nowMs())) };
  216. const obj = allocator.create(HlObject) catch return api.makeNull();
  217. obj.* = .{ .fields = fields.ptr, .field_count = 2, .deinit_fn = null };
  218. return api.makeObject(obj);
  219. }
  220. fn timerDeinit(ctx: ?*anyopaque) callconv(.c) void {
  221. const t: *Timer = @ptrCast(@alignCast(ctx orelse return));
  222. t.stopped = true;
  223. }
  224. /// hl_time_timer(seconds, repeat) → { id, events }
  225. export fn hl_time_timer(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  226. if (argc < 1 or argv[0].type != .hl_number) {
  227. return api.makeError("hl:time timer: pass the interval in SECONDS (fractions allowed)");
  228. }
  229. const secs = argv[0].data.number;
  230. if (!(secs == secs) or secs < 0) { // NaN or negative
  231. return api.makeError("hl:time timer: the interval must be a positive number of seconds");
  232. }
  233. const repeat = argc >= 2 and argv[1].type == .hl_bool and argv[1].data.boolean;
  234. var ns: i64 = @intFromFloat(secs * 1_000_000_000.0);
  235. if (ns < FLOOR_NS) ns = FLOOR_NS;
  236. const fd = timerfdCreate();
  237. if (fd < 0) return api.makeError("hl:time timer: timerfd_create failed");
  238. const t = allocator.create(Timer) catch return api.makeError("hl:time: out of memory");
  239. t.* = .{ .fd = fd, .interval_ns = if (repeat) ns else 0, .deadline_ns = monoNs() + ns };
  240. // ABSOLUTE, so the kernel and this plugin hold the SAME instant — the whole
  241. // exactness argument in the header. The interval is the kernel's own re-arm,
  242. // which is what makes `every` drift-free rather than nearly drift-free.
  243. const spec = linux.itimerspec{
  244. .it_interval = .{
  245. .sec = if (repeat) @intCast(@divTrunc(ns, 1_000_000_000)) else 0,
  246. .nsec = if (repeat) @intCast(@mod(ns, 1_000_000_000)) else 0,
  247. },
  248. .it_value = .{
  249. .sec = @intCast(@divTrunc(t.deadline_ns, 1_000_000_000)),
  250. .nsec = @intCast(@mod(t.deadline_ns, 1_000_000_000)),
  251. },
  252. };
  253. if (!timerfdSetAbs(fd, &spec)) {
  254. _ = linux.close(fd);
  255. return api.makeError("hl:time timer: timerfd_settime failed");
  256. }
  257. timers.append(allocator, t) catch return api.makeError("hl:time: out of memory");
  258. const id: f64 = @floatFromInt(timers.items.len);
  259. const iter = allocator.create(HlIterator) catch return api.makeError("hl:time: out of memory");
  260. iter.* = .{
  261. .context = @ptrCast(t),
  262. .next_fn = &timerTryNext,
  263. .deinit_fn = &timerDeinit,
  264. .try_next_fn = &timerTryNext,
  265. .wake_fd = fd,
  266. };
  267. const fields = allocator.alloc(HlField, 2) catch return api.makeNull();
  268. fields[0] = .{ .key = hlStr("id"), .value = api.makeNumber(id) };
  269. fields[1] = .{ .key = hlStr("events"), .value = api.makeIterator(iter) };
  270. const obj = allocator.create(HlObject) catch return api.makeNull();
  271. obj.* = .{ .fields = fields.ptr, .field_count = 2, .deinit_fn = null };
  272. return api.makeObject(obj);
  273. }
  274. /// hl_time_timer_stop(id) — disarm and close. TRUE when this call was the one
  275. /// that stopped it. The caller retires the event source FIRST (Timer.hl), so the
  276. /// loop has already dropped this fd from its epoll set by the time it is closed.
  277. export fn hl_time_timer_stop(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  278. if (argc < 1 or argv[0].type != .hl_number) {
  279. return api.makeError("hl:time timer stop: pass the id timer() returned");
  280. }
  281. const t = timerById(argv[0].data.number) orelse return api.makeBool(false);
  282. const was_open = t.fd >= 0;
  283. t.stopped = true;
  284. if (t.fd >= 0) {
  285. timerfdDisarm(t.fd);
  286. _ = linux.close(t.fd);
  287. t.fd = -1;
  288. }
  289. return api.makeBool(was_open);
  290. }
  291. /// hl_time_sleep(seconds) — BLOCKING, and that is the creator's ruling, not an
  292. /// omission: *"sleep you need mostly for testing or debugging stuff, its what i
  293. /// consider basic tools"*. It stops this thread, which inside a request handler
  294. /// means the loop is stopped too; `after()` is the thing to use there, and
  295. /// server.hl says so where an author will read it.
  296. export fn hl_time_sleep(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  297. if (argc < 1 or argv[0].type != .hl_number) {
  298. return api.makeError("hl:time sleep: pass the duration in SECONDS (fractions allowed)");
  299. }
  300. const secs = argv[0].data.number;
  301. if (!(secs == secs) or secs <= 0) return api.makeBool(false);
  302. const ns: i64 = @intFromFloat(secs * 1_000_000_000.0);
  303. // EINTR leaves the remainder; a signal must not silently shorten the sleep.
  304. var req = linux.timespec{ .sec = @intCast(@divTrunc(ns, 1_000_000_000)), .nsec = @intCast(@mod(ns, 1_000_000_000)) };
  305. var rem = linux.timespec{ .sec = 0, .nsec = 0 };
  306. while (@as(isize, @bitCast(linux.nanosleep(&req, &rem))) != 0) {
  307. if (rem.sec == 0 and rem.nsec == 0) break;
  308. req = rem;
  309. rem = .{ .sec = 0, .nsec = 0 };
  310. }
  311. return api.makeBool(true);
  312. }

Branches

Latest commits

  • 68dcb603deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • e2deed6dgitoria#20: "Add code" only on the Code page of an empty repository, no collapsiblemre
  • 8bb97ffddeploy.sh: never send .git or .gitignore to Byrodinmre
  • fd981932State of 2026-09-27; bin/ no longer tracked (Hybriel commit is in README)mre
  • 4a2d7125initial commitmre