gitoriaLog in with ident

gitoria

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Branchmain4f47843egate: ticket links use the tickets short URL (/<slug>/<n>, tickets#25)mremain/plugins/proc/proc.zig

50.1 KB

  1. // hl:proc — CHILD PROCESSES as instance events (creator API ruling 2026-08-17:
  2. // no eventloop plumbing in app code — `spawn()` hands back a Process the app
  3. // subscribes with `on process.line` / `on process.exit`, and `process.kill()`
  4. // is the stop button).
  5. //
  6. // Exports:
  7. // hl_proc_spawn(argv_list) → { pid, events } — events is a LOOP SOURCE
  8. // (wake_fd bell, the fetch-completions pattern)
  9. // delivering { line, stream } per output line and
  10. // a final { exit } when the child is gone
  11. // hl_proc_run(argv_list, o) → { exit, out, err } — BLOCKING: the whole output
  12. // once the child is gone (ticket #80)
  13. // hl_proc_kill(pid) → SIGTERM the child; the exit event still arrives
  14. //
  15. // One worker thread per child reads BOTH pipes with poll(), line-buffers, and
  16. // posts events into the spawn's OWN queue — nothing global, nothing shared
  17. // between children. No shell anywhere: argv is exec'd verbatim.
  18. const std = @import("std");
  19. const api = @import("plugin_api");
  20. const linux = std.os.linux;
  21. const libc = std.c;
  22. const PthreadMutex = libc.pthread_mutex_t;
  23. fn mutexLock(m: *PthreadMutex) void {
  24. _ = libc.pthread_mutex_lock(m);
  25. }
  26. fn mutexUnlock(m: *PthreadMutex) void {
  27. _ = libc.pthread_mutex_unlock(m);
  28. }
  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. // the plugins' allocator (plugin_api.zig)
  35. const allocator = api.allocator;
  36. // SCRIPT-RELATIVE program paths (the Hybriel path rule — imports, hl:fs and
  37. // now spawn all resolve against the SCRIPT, not the process cwd): the loader
  38. // installs the script dir right after dlopen.
  39. var script_dir: ?[]u8 = null;
  40. export fn hl_proc_set_script_dir(ptr: [*]const u8, len: usize) callconv(.c) void {
  41. if (script_dir) |old| allocator.free(old);
  42. script_dir = allocator.dupe(u8, ptr[0..len]) catch null;
  43. }
  44. // THE PROGRAM'S OWN DIRECTORY (ticket #37), absolute: the entry script's
  45. // directory on the interpreter, the executable's for a compiled binary. The
  46. // runtime hands it over right after dlopen; realpath makes it absolute.
  47. var program_dir: ?[]u8 = null;
  48. export fn hl_proc_set_program_dir(ptr: [*]const u8, len: usize) callconv(.c) void {
  49. const z = allocator.dupeZ(u8, ptr[0..len]) catch return;
  50. defer allocator.free(z);
  51. var buf: [4096]u8 = undefined;
  52. const r = c.realpath(z.ptr, &buf) orelse return;
  53. if (program_dir) |old| allocator.free(old);
  54. program_dir = allocator.dupe(u8, std.mem.span(r)) catch null;
  55. }
  56. /// hl_proc_dir() → the program's own directory, absolute.
  57. export fn hl_proc_dir(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  58. _ = argc;
  59. _ = argv;
  60. const d = program_dir orelse return api.makeError("hl:proc dir: the runtime did not say where this program is");
  61. const copy = allocator.dupe(u8, d) catch return api.makeNull();
  62. return api.makeString(copy);
  63. }
  64. const c = struct {
  65. extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8;
  66. extern "c" fn fork() c_int;
  67. extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
  68. extern "c" fn execvpe(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8, envp: [*:null]const ?[*:0]const u8) c_int;
  69. extern "c" fn chdir(path: [*:0]const u8) c_int;
  70. extern "c" fn open(path: [*:0]const u8, flags: c_int, ...) c_int;
  71. extern "c" var environ: [*:null]?[*:0]const u8;
  72. extern "c" fn pipe(fds: *[2]c_int) c_int;
  73. extern "c" fn close(fd: c_int) c_int;
  74. extern "c" fn dup2(old: c_int, new: c_int) c_int;
  75. extern "c" fn kill(pid: c_int, sig: c_int) c_int;
  76. extern "c" fn waitpid(pid: c_int, status: ?*c_int, options: c_int) c_int;
  77. extern "c" fn _exit(code: c_int) noreturn;
  78. extern "c" fn eventfd(initval: c_uint, flags: c_int) c_int;
  79. extern "c" fn fcntl(fd: c_int, cmd: c_int, arg: c_int) c_int;
  80. extern "c" fn strerror(errnum: c_int) [*:0]const u8;
  81. extern "c" fn signal(sig: c_int, handler: ?*const anyopaque) ?*const anyopaque;
  82. extern "c" fn write(fd: c_int, buf: [*]const u8, n: usize) isize;
  83. extern "c" fn read(fd: c_int, buf: [*]u8, n: usize) isize;
  84. extern "c" fn poll(fds: [*]PollFd, n: c_ulong, timeout: c_int) c_int;
  85. const PollFd = extern struct { fd: c_int, events: c_short, revents: c_short };
  86. };
  87. const POLLIN: c_short = 0x001;
  88. const POLLOUT: c_short = 0x004;
  89. const O_NONBLOCK: c_int = 0o4000;
  90. const EFD_NONBLOCK: c_int = 0o4000;
  91. fn hlStr(s: []const u8) HlString {
  92. return .{ .ptr = s.ptr, .len = s.len };
  93. }
  94. // ── one spawned child ────────────────────────────────────────────────────────
  95. const Event = struct {
  96. line: ?[]u8 = null, // owned
  97. /// `line` is a raw CHUNK of a binary child, not a line (ticket #42)
  98. chunk: bool = false,
  99. stream: []const u8 = "", // "out" | "err"
  100. exit_code: ?i32 = null,
  101. /// exec NEVER HAPPENED: the errno text. The child has no exit — this is
  102. /// the ONLY event, delivered as an hl_error so the loop routes it through
  103. /// `on x.Error` → `on Error` → located crash (creator ruling 2026-08-17).
  104. spawn_err: ?[]u8 = null,
  105. };
  106. const Child = struct {
  107. mutex: PthreadMutex = libc.PTHREAD_MUTEX_INITIALIZER,
  108. queue: std.ArrayListUnmanaged(Event) = .empty,
  109. wake_fd: i32 = -1,
  110. pid: i32 = 0,
  111. out_fd: i32 = -1,
  112. err_fd: i32 = -1,
  113. exec_fd: i32 = -1,
  114. argv0: []const u8 = "",
  115. /// `binary = true` (ticket #42): output goes out as raw chunks, never
  116. /// split into lines
  117. binary: bool = false,
  118. thread: ?std.Thread = null,
  119. /// the iterator was closed by the loop — the worker frees the Child when done
  120. done: bool = false,
  121. fn post(self: *Child, ev: Event) void {
  122. mutexLock(&self.mutex);
  123. self.queue.append(allocator, ev) catch {};
  124. mutexUnlock(&self.mutex);
  125. const one: u64 = 1;
  126. _ = c.write(self.wake_fd, @ptrCast(&one), 8);
  127. }
  128. };
  129. fn workerMain(ch: *Child) void {
  130. // the exec verdict first: BYTES on the CLOEXEC pipe = exec never happened
  131. {
  132. var eno: i32 = 0;
  133. const got = c.read(ch.exec_fd, @ptrCast(&eno), 4);
  134. _ = c.close(ch.exec_fd);
  135. if (got == 4) {
  136. var status: c_int = 0;
  137. _ = c.waitpid(ch.pid, &status, 0); // reap the stillborn child
  138. stdinClose(ch.pid);
  139. _ = c.close(ch.out_fd);
  140. _ = c.close(ch.err_fd);
  141. const msg = std.fmt.allocPrint(allocator, "hl:proc spawn: cannot start '{s}': {s}", .{ ch.argv0, std.mem.span(c.strerror(eno)) }) catch null;
  142. ch.post(.{ .spawn_err = msg orelse @constCast("hl:proc spawn failed") });
  143. return;
  144. }
  145. }
  146. var bufs = [2]std.ArrayListUnmanaged(u8){ .empty, .empty };
  147. defer for (&bufs) |*b| b.deinit(allocator);
  148. const names = [2][]const u8{ "stdout", "stderr" };
  149. var fds = [2]i32{ ch.out_fd, ch.err_fd };
  150. var open_count: usize = 2;
  151. var rd: [4096]u8 = undefined;
  152. while (open_count > 0) {
  153. var pfds: [2]c.PollFd = undefined;
  154. var map: [2]usize = undefined;
  155. var n: usize = 0;
  156. for (fds, 0..) |fd, i| {
  157. if (fd < 0) continue;
  158. pfds[n] = .{ .fd = fd, .events = POLLIN, .revents = 0 };
  159. map[n] = i;
  160. n += 1;
  161. }
  162. const pr = c.poll(&pfds, n, -1);
  163. if (pr <= 0) continue;
  164. for (pfds[0..n], 0..) |pfd, pi| {
  165. if (pfd.revents == 0) continue;
  166. const i = map[pi];
  167. const got = c.read(pfd.fd, &rd, rd.len);
  168. if (got <= 0) {
  169. // EOF on this stream: flush a trailing unterminated line
  170. if (bufs[i].items.len > 0) {
  171. const line = textLine(bufs[i].items);
  172. if (line) |l| ch.post(.{ .line = l, .stream = names[i] });
  173. bufs[i].clearRetainingCapacity();
  174. }
  175. _ = c.close(pfd.fd);
  176. fds[i] = -1;
  177. open_count -= 1;
  178. continue;
  179. }
  180. const chunk = rd[0..@intCast(got)];
  181. if (ch.binary) {
  182. const copy = allocator.dupe(u8, chunk) catch null;
  183. if (copy) |b| ch.post(.{ .line = b, .stream = names[i], .chunk = true });
  184. continue;
  185. }
  186. var start: usize = 0;
  187. for (chunk, 0..) |ch2, k| {
  188. if (ch2 != '\n') continue;
  189. bufs[i].appendSlice(allocator, chunk[start..k]) catch {};
  190. const line = textLine(bufs[i].items);
  191. if (line) |l| ch.post(.{ .line = l, .stream = names[i] });
  192. bufs[i].clearRetainingCapacity();
  193. start = k + 1;
  194. }
  195. bufs[i].appendSlice(allocator, chunk[start..]) catch {};
  196. }
  197. }
  198. var status: c_int = 0;
  199. _ = c.waitpid(ch.pid, &status, 0);
  200. stdinClose(ch.pid);
  201. // POSIX status decode: exited → code, signalled → 128+sig (the shell rule)
  202. const code: i32 = if ((status & 0x7f) == 0) @intCast((status >> 8) & 0xff) else 128 + @as(i32, @intCast(status & 0x7f));
  203. ch.post(.{ .exit_code = code });
  204. }
  205. // ── the loop source ──────────────────────────────────────────────────────────
  206. fn eventsTryNext(ctx: ?*anyopaque) callconv(.c) HlValue {
  207. const ch: *Child = @ptrCast(@alignCast(ctx orelse return api.makeNull()));
  208. // drain the bell (read may fail with EAGAIN — fine)
  209. var eat: [8]u8 = undefined;
  210. _ = c.read(ch.wake_fd, &eat, 8);
  211. mutexLock(&ch.mutex);
  212. if (ch.queue.items.len == 0) {
  213. mutexUnlock(&ch.mutex);
  214. return api.makeNull();
  215. }
  216. const ev = ch.queue.orderedRemove(0);
  217. mutexUnlock(&ch.mutex);
  218. if (ev.spawn_err) |msg| {
  219. return api.makeError(msg);
  220. }
  221. if (ev.exit_code) |code| {
  222. const fields = allocator.alloc(HlField, 2) catch return api.makeNull();
  223. fields[0] = .{ .key = hlStr("exit"), .value = api.makeNumber(@floatFromInt(code)) };
  224. fields[1] = .{ .key = hlStr("pid"), .value = api.makeNumber(@floatFromInt(ch.pid)) };
  225. const obj = allocator.create(HlObject) catch return api.makeNull();
  226. obj.* = .{ .fields = fields.ptr, .field_count = 2, .deinit_fn = null };
  227. return api.makeObject(obj);
  228. }
  229. const fields = allocator.alloc(HlField, 2) catch return api.makeNull();
  230. fields[0] = .{ .key = hlStr(if (ev.chunk) "chunk" else "line"), .value = api.makeString(ev.line orelse "") };
  231. fields[1] = .{ .key = hlStr("stream"), .value = api.makeString(ev.stream) };
  232. const obj = allocator.create(HlObject) catch return api.makeNull();
  233. obj.* = .{ .fields = fields.ptr, .field_count = 2, .deinit_fn = null };
  234. return api.makeObject(obj);
  235. }
  236. fn eventsDeinit(ctx: ?*anyopaque) callconv(.c) void {
  237. const ch: *Child = @ptrCast(@alignCast(ctx orelse return));
  238. ch.done = true;
  239. }
  240. /// The child's environment: this process's own, with `overrides` applied —
  241. /// a String value sets NAME, a null removes it.
  242. fn buildEnv(overrides: *const HlObject) error{ NotAString, OutOfMemory }![:null]?[*:0]const u8 {
  243. var list = std.ArrayListUnmanaged(?[*:0]const u8).empty;
  244. var i: usize = 0;
  245. outer: while (c.environ[i]) |entry| : (i += 1) {
  246. const text = std.mem.span(entry);
  247. const eq = std.mem.indexOfScalar(u8, text, '=') orelse text.len;
  248. for (overrides.fields[0..overrides.field_count]) |f| {
  249. if (std.mem.eql(u8, f.key.ptr[0..f.key.len], text[0..eq])) continue :outer;
  250. }
  251. try list.append(allocator, entry);
  252. }
  253. for (overrides.fields[0..overrides.field_count]) |f| {
  254. if (f.value.type == .hl_null) continue;
  255. if (f.value.type != .hl_string) return error.NotAString;
  256. const v = f.value.data.string;
  257. const kv = try std.fmt.allocPrintSentinel(allocator, "{s}={s}", .{ f.key.ptr[0..f.key.len], v.ptr[0..v.len] }, 0);
  258. try list.append(allocator, kv.ptr);
  259. }
  260. return try list.toOwnedSliceSentinel(allocator, null);
  261. }
  262. // ── the children's stdin (ruling on ticket #42) ───────────────────────────────
  263. //
  264. // pid → our write end of that child's stdin pipe. Closed by end(), or when
  265. // the child exits (the worker reaps it).
  266. var stdin_mutex: PthreadMutex = libc.PTHREAD_MUTEX_INITIALIZER;
  267. var stdin_fds: std.AutoHashMapUnmanaged(i32, c_int) = .empty;
  268. var sigpipe_ignored = false;
  269. fn stdinRegister(pid: i32, fd: c_int) void {
  270. mutexLock(&stdin_mutex);
  271. defer mutexUnlock(&stdin_mutex);
  272. stdin_fds.put(allocator, pid, fd) catch {
  273. _ = c.close(fd);
  274. };
  275. }
  276. fn stdinClose(pid: i32) void {
  277. mutexLock(&stdin_mutex);
  278. defer mutexUnlock(&stdin_mutex);
  279. if (stdin_fds.fetchRemove(pid)) |kv| _ = c.close(kv.value);
  280. }
  281. fn stdinWrite(pid: i32, bytes: []const u8) HlValue {
  282. mutexLock(&stdin_mutex);
  283. const fd = stdin_fds.get(pid);
  284. if (!sigpipe_ignored) {
  285. _ = c.signal(13, @ptrFromInt(1)); // SIG_IGN: a closed pipe is EPIPE, not death
  286. sigpipe_ignored = true;
  287. }
  288. mutexUnlock(&stdin_mutex);
  289. const w = fd orelse return api.makeError("hl:proc write: this child's stdin is not open — spawn it with { stdin = 'pipe' }; end() or its exit closes it");
  290. var off: usize = 0;
  291. while (off < bytes.len) {
  292. const r = c.write(w, bytes[off..].ptr, bytes.len - off);
  293. if (r < 0) {
  294. const e: i32 = std.c._errno().*;
  295. if (e == 4) continue; // EINTR
  296. var buf: [160]u8 = undefined;
  297. const m = std.fmt.bufPrint(&buf, "hl:proc write: the child's stdin refused the bytes: {s}", .{std.mem.span(c.strerror(e))}) catch "hl:proc write failed";
  298. return api.makeError(m);
  299. }
  300. off += @intCast(r);
  301. }
  302. return api.makeNumber(@floatFromInt(bytes.len));
  303. }
  304. fn pidArg(argc: u32, argv: [*]const HlValue) ?i32 {
  305. if (argc < 1 or argv[0].type != .hl_number) return null;
  306. return @intFromFloat(argv[0].data.number);
  307. }
  308. /// hl_proc_write(pid, text) → bytes written; the String's bytes go as they are.
  309. export fn hl_proc_write(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  310. const pid = pidArg(argc, argv) orelse return api.makeError("hl:proc write: pass the pid spawn() returned");
  311. if (argc < 2 or argv[1].type != .hl_string) return api.makeError("hl:proc write: pass a String or a Bytes");
  312. const sv = argv[1].data.string;
  313. return stdinWrite(pid, sv.ptr[0..sv.len]);
  314. }
  315. /// A LINE IS TEXT, AND TEXT IS UTF-8 (ticket #47): a child printing latin-1 or
  316. /// binary bytes in text mode got them into a String unchecked, and the first
  317. /// such String that reached a page broke its WebSocket ("Could not decode a
  318. /// text frame as UTF-8"). Every byte that does not begin a valid sequence is
  319. /// replaced by U+FFFD here, where the bytes become text; `binary = true` is
  320. /// the way to the bytes themselves. Answers an owned copy, null on OOM.
  321. fn textLine(bytes: []const u8) ?[]u8 {
  322. if (std.unicode.utf8ValidateSlice(bytes)) return allocator.dupe(u8, bytes) catch null;
  323. var out = std.ArrayListUnmanaged(u8).empty;
  324. var k: usize = 0;
  325. while (k < bytes.len) {
  326. const n = std.unicode.utf8ByteSequenceLength(bytes[k]) catch 0;
  327. if (n > 0 and k + n <= bytes.len) {
  328. if (std.unicode.utf8Decode(bytes[k .. k + n])) |_| {
  329. out.appendSlice(allocator, bytes[k .. k + n]) catch return null;
  330. k += n;
  331. continue;
  332. } else |_| {}
  333. }
  334. out.appendSlice(allocator, "\xEF\xBF\xBD") catch return null;
  335. k += 1;
  336. }
  337. return out.toOwnedSlice(allocator) catch null;
  338. }
  339. /// hl_proc_write_hex(pid, hex) — a Bytes crosses the plugin boundary as its
  340. /// hex text (the ABI has no Bytes); decoded here, written raw.
  341. export fn hl_proc_write_hex(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  342. const pid = pidArg(argc, argv) orelse return api.makeError("hl:proc write: pass the pid spawn() returned");
  343. if (argc < 2 or argv[1].type != .hl_string) return api.makeError("hl:proc write: pass a String or a Bytes");
  344. const hex = argv[1].data.string.ptr[0..argv[1].data.string.len];
  345. const raw = allocator.alloc(u8, hex.len / 2) catch return api.makeError("hl:proc: out of memory");
  346. defer allocator.free(raw);
  347. _ = std.fmt.hexToBytes(raw, hex) catch return api.makeError("hl:proc write: not a Bytes");
  348. return stdinWrite(pid, raw);
  349. }
  350. /// This program's OWN stdout (fd 1) or stderr (fd 2), raw (ruling on ticket
  351. /// #42): the bytes go out as they are — no newline, no text form — so a
  352. /// program can speak a binary protocol on its stdout (git's stateless-rpc, a
  353. /// pipe to another tool) and report on stderr without mixing into it.
  354. fn fdWrite(fd: c_int, comptime name: []const u8, bytes: []const u8) HlValue {
  355. var off: usize = 0;
  356. while (off < bytes.len) {
  357. const r = c.write(fd, bytes[off..].ptr, bytes.len - off);
  358. if (r < 0) {
  359. const e: i32 = std.c._errno().*;
  360. if (e == 4) continue; // EINTR
  361. var buf: [160]u8 = undefined;
  362. const m = std.fmt.bufPrint(&buf, "hl:proc " ++ name ++ ": the stream refused the bytes: {s}", .{std.mem.span(c.strerror(e))}) catch "hl:proc " ++ name ++ " failed";
  363. return api.makeError(m);
  364. }
  365. off += @intCast(r);
  366. }
  367. return api.makeNumber(@floatFromInt(bytes.len));
  368. }
  369. fn textWrite(fd: c_int, comptime name: []const u8, argc: u32, argv: [*]const HlValue) HlValue {
  370. if (argc < 1 or argv[0].type != .hl_string) return api.makeError("hl:proc " ++ name ++ ": pass a String or a Bytes");
  371. const sv = argv[0].data.string;
  372. return fdWrite(fd, name, sv.ptr[0..sv.len]);
  373. }
  374. fn hexWrite(fd: c_int, comptime name: []const u8, argc: u32, argv: [*]const HlValue) HlValue {
  375. if (argc < 1 or argv[0].type != .hl_string) return api.makeError("hl:proc " ++ name ++ ": pass a String or a Bytes");
  376. const hex = argv[0].data.string.ptr[0..argv[0].data.string.len];
  377. const raw = allocator.alloc(u8, hex.len / 2) catch return api.makeError("hl:proc: out of memory");
  378. defer allocator.free(raw);
  379. _ = std.fmt.hexToBytes(raw, hex) catch return api.makeError("hl:proc " ++ name ++ ": not a Bytes");
  380. return fdWrite(fd, name, raw);
  381. }
  382. /// hl_proc_write_stdout(text) / hl_proc_write_stdout_hex(hex) — see fdWrite.
  383. export fn hl_proc_write_stdout(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  384. return textWrite(1, "writeStdout", argc, argv);
  385. }
  386. export fn hl_proc_write_stdout_hex(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  387. return hexWrite(1, "writeStdout", argc, argv);
  388. }
  389. /// hl_proc_write_stderr(text) / hl_proc_write_stderr_hex(hex) — the same on fd 2.
  390. export fn hl_proc_write_stderr(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  391. return textWrite(2, "writeStderr", argc, argv);
  392. }
  393. export fn hl_proc_write_stderr_hex(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  394. return hexWrite(2, "writeStderr", argc, argv);
  395. }
  396. /// hl_proc_end(pid) — close the child's stdin: it reads EOF.
  397. export fn hl_proc_end(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  398. const pid = pidArg(argc, argv) orelse return api.makeError("hl:proc end: pass the pid spawn() returned");
  399. stdinClose(pid);
  400. return api.makeNull();
  401. }
  402. // ── exports ──────────────────────────────────────────────────────────────────
  403. /// A command, parsed and resolved, ready to fork: what `spawn` and `run` share.
  404. /// Everything is allocated BEFORE the fork; the child only calls chdir and exec.
  405. const Prepared = struct {
  406. cargv: []?[*:0]const u8 = &.{},
  407. cwd_z: ?[:0]u8 = null,
  408. envp: ?[:null]?[*:0]const u8 = null,
  409. binary: bool = false,
  410. stdin_pipe: bool = false,
  411. /// `run` only: milliseconds, or -1 for no limit
  412. timeout_ms: i64 = -1,
  413. /// `run` only (ticket #91): the bytes its stdin reads, then EOF; null is /dev/null
  414. stdin_data: ?[]u8 = null,
  415. fn deinit(self: *Prepared) void {
  416. for (self.cargv) |a| if (a) |z| allocator.free(std.mem.span(z));
  417. if (self.cargv.len > 0) allocator.free(self.cargv);
  418. if (self.cwd_z) |d| allocator.free(d);
  419. if (self.envp) |ev| allocator.free(ev);
  420. if (self.stdin_data) |d| allocator.free(d);
  421. }
  422. };
  423. /// Parse the command (a STRING or a LIST) and the options for `verb`
  424. /// ("spawn" or "run"). Answers null when `out` is ready, or the Error to hand
  425. /// back. `spawn` takes `stdin` ('pipe' or 'inherit'), `run` takes `timeout`
  426. /// and `stdin` (its bytes — server.hl hands them as argv[2], hex); both take
  427. /// cwd, env and binary.
  428. fn prepare(comptime verb: []const u8, argc: u32, argv: [*]const HlValue, out: *Prepared) ?HlValue {
  429. const is_run = comptime std.mem.eql(u8, verb, "run");
  430. // Accepts BOTH forms: a STRING (split on ANY whitespace run — spaces,
  431. // tabs, NEWLINES: a long command written across lines, the multi-line-
  432. // string way, is one command) or a LIST (an object with numeric keys —
  433. // the loader's valueArrayToHl contract) for arguments that contain spaces.
  434. if (argc < 1) return api.makeError("hl:proc " ++ verb ++ ": pass a command string or an argv list");
  435. var parts = std.ArrayListUnmanaged([]const u8).empty;
  436. defer parts.deinit(allocator);
  437. if (argv[0].type == .hl_string) {
  438. // whitespace-run tokens, with QUOTE GROUPING: a token opening with
  439. // ' or " runs (whitespace included) to the matching close, quotes
  440. // stripped — one argument. NOT a shell: no expansion, no nesting,
  441. // no substitution; just grouping, the way a command line reads.
  442. const sv0 = argv[0].data.string;
  443. const text0 = sv0.ptr[0..sv0.len];
  444. var pos: usize = 0;
  445. while (pos < text0.len) {
  446. const chx = text0[pos];
  447. if (chx == ' ' or chx == '\t' or chx == '\r' or chx == '\n') {
  448. pos += 1;
  449. continue;
  450. }
  451. if (chx == '\'' or chx == '"') {
  452. const close = std.mem.indexOfScalarPos(u8, text0, pos + 1, chx) orelse
  453. return api.makeError("hl:proc " ++ verb ++ ": unclosed quote in the command string");
  454. parts.append(allocator, text0[pos + 1 .. close]) catch return api.makeError("hl:proc: out of memory");
  455. pos = close + 1;
  456. continue;
  457. }
  458. var end = pos;
  459. while (end < text0.len and text0[end] != ' ' and text0[end] != '\t' and text0[end] != '\r' and text0[end] != '\n') end += 1;
  460. parts.append(allocator, text0[pos..end]) catch return api.makeError("hl:proc: out of memory");
  461. pos = end;
  462. }
  463. } else if (argv[0].type == .hl_object) {
  464. const obj_in = argv[0].data.object;
  465. for (obj_in.fields[0..obj_in.field_count]) |field| {
  466. if (field.value.type != .hl_string) return api.makeError("hl:proc " ++ verb ++ ": argv entries must be strings");
  467. const sv = field.value.data.string;
  468. parts.append(allocator, sv.ptr[0..sv.len]) catch return api.makeError("hl:proc: out of memory");
  469. }
  470. } else {
  471. return api.makeError("hl:proc " ++ verb ++ ": pass a command string or an argv list");
  472. }
  473. const n_args: usize = parts.items.len;
  474. if (n_args == 0) return api.makeError("hl:proc " ++ verb ++ ": empty command");
  475. // NUL-terminated argv for exec, before the fork (no allocation after fork)
  476. const cargv = allocator.alloc(?[*:0]const u8, n_args + 1) catch return api.makeError("hl:proc: out of memory");
  477. @memset(cargv, null);
  478. out.cargv = cargv;
  479. for (parts.items, 0..) |part, i| {
  480. var text: []const u8 = part;
  481. // THE PROGRAM (argv[0]): a path containing '/' that is not absolute
  482. // resolves against the SCRIPT's directory — the same rule every other
  483. // Hybriel path follows. A bare name searches PATH (exec semantics).
  484. var resolved: ?[]u8 = null;
  485. defer if (resolved) |r| allocator.free(r);
  486. if (i == 0 and text.len > 0 and text[0] != '/' and std.mem.indexOfScalar(u8, text, '/') != null) {
  487. if (script_dir) |sd| {
  488. resolved = std.fmt.allocPrint(allocator, "{s}/{s}", .{ sd, text }) catch null;
  489. if (resolved) |r| text = r;
  490. }
  491. }
  492. const z = allocator.dupeZ(u8, text) catch return api.makeError("hl:proc: out of memory");
  493. cargv[i] = z.ptr;
  494. }
  495. // THE OPTIONS (ruling on ticket #37): `{ cwd, env }`, both optional.
  496. // `cwd` is where the child starts — a relative one resolves against the
  497. // SCRIPT's directory, like the program path. `env` is added to the
  498. // environment the child inherits: a String sets the variable, null
  499. // removes it.
  500. if (argc >= 2 and argv[1].type == .hl_object) {
  501. const opts = argv[1].data.object;
  502. for (opts.fields[0..opts.field_count]) |field| {
  503. const key = field.key.ptr[0..field.key.len];
  504. if (!is_run and std.mem.eql(u8, key, "stdin")) {
  505. if (field.value.type == .hl_null) continue;
  506. const v = if (field.value.type == .hl_string) field.value.data.string.ptr[0..field.value.data.string.len] else "";
  507. if (std.mem.eql(u8, v, "pipe")) {
  508. out.stdin_pipe = true;
  509. } else if (!std.mem.eql(u8, v, "inherit")) {
  510. return api.makeError("hl:proc spawn: options.stdin is 'pipe' (write() and end() feed it) or 'inherit' (the default)");
  511. }
  512. } else if (is_run and std.mem.eql(u8, key, "stdin")) {
  513. // server.hl moved a String or a Bytes out to argv[2]; anything left here is neither
  514. if (field.value.type == .hl_null) continue;
  515. return api.makeError("hl:proc run: options.stdin is a String or a Bytes — what the program reads before EOF");
  516. } else if (is_run and std.mem.eql(u8, key, "timeout")) {
  517. if (field.value.type == .hl_null) continue;
  518. if (field.value.type != .hl_number or !(field.value.data.number > 0)) return api.makeError("hl:proc run: options.timeout is a Number of seconds greater than 0");
  519. out.timeout_ms = @intFromFloat(@ceil(field.value.data.number * 1000));
  520. } else if (std.mem.eql(u8, key, "binary")) {
  521. if (field.value.type == .hl_null) continue;
  522. if (field.value.type != .hl_bool) return api.makeError("hl:proc " ++ verb ++ ": options.binary must be true or false");
  523. out.binary = field.value.data.boolean;
  524. } else if (std.mem.eql(u8, key, "cwd")) {
  525. if (field.value.type == .hl_null) continue;
  526. if (field.value.type != .hl_string) return api.makeError("hl:proc " ++ verb ++ ": options.cwd must be a String");
  527. const d = field.value.data.string.ptr[0..field.value.data.string.len];
  528. out.cwd_z = if (d.len > 0 and d[0] != '/' and script_dir != null)
  529. std.fmt.allocPrintSentinel(allocator, "{s}/{s}", .{ script_dir.?, d }, 0) catch return api.makeError("hl:proc: out of memory")
  530. else
  531. allocator.dupeZ(u8, d) catch return api.makeError("hl:proc: out of memory");
  532. } else if (std.mem.eql(u8, key, "env")) {
  533. if (field.value.type == .hl_null) continue;
  534. if (field.value.type != .hl_object) return api.makeError("hl:proc " ++ verb ++ ": options.env must be a hybrid of NAME = value");
  535. out.envp = buildEnv(field.value.data.object) catch |e| return api.makeError(switch (e) {
  536. error.NotAString => "hl:proc " ++ verb ++ ": an options.env value must be a String (or null to remove the variable)",
  537. else => "hl:proc: out of memory",
  538. });
  539. } else {
  540. const known = if (is_run) "cwd, env, binary, stdin and timeout" else "cwd, env, stdin and binary";
  541. // allocated, not on this stack: the runtime reads the message after we return
  542. const m = std.fmt.allocPrint(allocator, "hl:proc " ++ verb ++ ": unknown option '{s}' — the options are {s}", .{ key, known }) catch "hl:proc " ++ verb ++ ": unknown option";
  543. return api.makeError(m);
  544. }
  545. }
  546. }
  547. // run's stdin (ticket #91): a String's or a Bytes' bytes, crossing as hex
  548. if (is_run and argc >= 3 and argv[2].type == .hl_string) {
  549. const hex = argv[2].data.string.ptr[0..argv[2].data.string.len];
  550. const raw = allocator.alloc(u8, hex.len / 2) catch return api.makeError("hl:proc: out of memory");
  551. out.stdin_data = raw;
  552. _ = std.fmt.hexToBytes(raw, hex) catch return api.makeError("hl:proc run: options.stdin is a String or a Bytes");
  553. }
  554. return null;
  555. }
  556. fn monoMs() i64 {
  557. var ts: linux.timespec = undefined;
  558. _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts);
  559. return @as(i64, ts.sec) * 1000 + @divTrunc(@as(i64, ts.nsec), 1_000_000);
  560. }
  561. /// What the forked child does with its stdin: the parent's (spawn's default),
  562. /// a pipe the program writes (spawn's `stdin = 'pipe'`, run's `stdin = data`),
  563. /// or nothing at all — `run` without `stdin` gives /dev/null, because a child
  564. /// that waited for input would hold the caller for as long as nobody typed.
  565. const StdinMode = enum { inherit, pipe, devnull };
  566. /// The pipes of one fork: our ends are out[0], err[0], exec[0] and in[1].
  567. const Pipes = struct {
  568. out: [2]c_int = .{ -1, -1 },
  569. err: [2]c_int = .{ -1, -1 },
  570. exec: [2]c_int = .{ -1, -1 }, // the exec-failure channel (CLOEXEC)
  571. in: [2]c_int = .{ -1, -1 },
  572. };
  573. /// fork + exec the prepared command. Answers the pid (< 0: fork failed) with
  574. /// the parent's ends of `pipes` open and the child's closed.
  575. fn forkExec(p: *const Prepared, mode: StdinMode, pipes: *Pipes) c_int {
  576. const pid = c.fork();
  577. if (pid != 0) return pid;
  578. // DIE WITH THE PARENT (2026-08-17): a supervisor that crashes or is
  579. // Ctrl-C'd must not leave orphaned llama-servers squatting on ports —
  580. // the kernel sends the child SIGTERM when hybriel exits, any exit.
  581. _ = linux.prctl(1, 15, 0, 0, 0); // PR_SET_PDEATHSIG = 1, SIGTERM = 15
  582. // the CHILD: pipes onto stdout/stderr, exec verbatim — no shell
  583. _ = c.dup2(pipes.out[1], 1);
  584. _ = c.dup2(pipes.err[1], 2);
  585. switch (mode) {
  586. .inherit => {},
  587. .pipe => {
  588. _ = c.dup2(pipes.in[0], 0);
  589. _ = c.close(pipes.in[0]);
  590. _ = c.close(pipes.in[1]);
  591. },
  592. .devnull => {
  593. const fd = c.open("/dev/null", 0);
  594. if (fd >= 0) {
  595. _ = c.dup2(fd, 0);
  596. _ = c.close(fd);
  597. }
  598. },
  599. }
  600. // this process ignores SIGPIPE once it has written to a child (a
  601. // child that stopped reading must not kill it); an ignored signal
  602. // survives exec, so the child gets the default back.
  603. _ = c.signal(13, null);
  604. _ = c.close(pipes.out[0]);
  605. _ = c.close(pipes.out[1]);
  606. _ = c.close(pipes.err[0]);
  607. _ = c.close(pipes.err[1]);
  608. _ = c.close(pipes.exec[0]);
  609. if (p.cwd_z) |d| {
  610. if (c.chdir(d.ptr) != 0) {
  611. const e: i32 = std.c._errno().*;
  612. _ = c.write(pipes.exec[1], @ptrCast(&e), 4);
  613. c._exit(127);
  614. }
  615. }
  616. if (p.envp) |ev| {
  617. _ = c.execvpe(p.cargv[0].?, @ptrCast(p.cargv.ptr), @ptrCast(ev.ptr));
  618. } else {
  619. _ = c.execvp(p.cargv[0].?, @ptrCast(p.cargv.ptr));
  620. }
  621. // exec failed: write errno through the CLOEXEC pipe — a successful
  622. // exec closed it, so the parent reading BYTES means "never started"
  623. const e: i32 = std.c._errno().*;
  624. _ = c.write(pipes.exec[1], @ptrCast(&e), 4);
  625. c._exit(127);
  626. }
  627. /// hl_proc_spawn(argvList) → { pid, events }
  628. export fn hl_proc_spawn(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  629. // the child never returns here (exec or _exit), and the argv strings stay
  630. // with the spawn for its lifetime; the options' allocations are the parent's
  631. var p = Prepared{};
  632. if (prepare("spawn", argc, argv, &p)) |err| return err;
  633. defer if (p.cwd_z) |d| allocator.free(d);
  634. defer if (p.envp) |ev| allocator.free(ev);
  635. var pipes = Pipes{};
  636. // the child's stdin (ticket #42): INHERITED unless `stdin = 'pipe'`, so a
  637. // child that reads until EOF never waits on a pipe nobody asked for
  638. if (c.pipe(&pipes.out) != 0 or c.pipe(&pipes.err) != 0 or c.pipe(&pipes.exec) != 0 or (p.stdin_pipe and c.pipe(&pipes.in) != 0)) {
  639. return api.makeError("hl:proc spawn: pipe() failed");
  640. }
  641. _ = c.fcntl(pipes.exec[1], 2, 1); // F_SETFD FD_CLOEXEC: closes ITSELF on a successful exec
  642. // OUR end of the child's stdin must not leak into LATER children, or the
  643. // child never sees EOF while a sibling still holds a copy of it.
  644. if (p.stdin_pipe) _ = c.fcntl(pipes.in[1], 2, 1);
  645. const pid = forkExec(&p, if (p.stdin_pipe) .pipe else .inherit, &pipes);
  646. if (pid < 0) return api.makeError("hl:proc spawn: fork() failed");
  647. _ = c.close(pipes.out[1]);
  648. _ = c.close(pipes.err[1]);
  649. _ = c.close(pipes.exec[1]);
  650. if (p.stdin_pipe) {
  651. _ = c.close(pipes.in[0]);
  652. stdinRegister(pid, pipes.in[1]);
  653. }
  654. const ch = allocator.create(Child) catch return api.makeError("hl:proc: out of memory");
  655. ch.* = .{
  656. .wake_fd = c.eventfd(0, EFD_NONBLOCK),
  657. .pid = pid,
  658. .out_fd = pipes.out[0],
  659. .err_fd = pipes.err[0],
  660. .exec_fd = pipes.exec[0],
  661. .argv0 = allocator.dupe(u8, std.mem.span(p.cargv[0].?)) catch "",
  662. .binary = p.binary,
  663. };
  664. ch.thread = std.Thread.spawn(.{}, workerMain, .{ch}) catch {
  665. return api.makeError("hl:proc spawn: worker thread failed");
  666. };
  667. if (ch.thread) |t| t.detach();
  668. const iter = allocator.create(HlIterator) catch return api.makeError("hl:proc: out of memory");
  669. iter.* = .{
  670. .context = @ptrCast(ch),
  671. .next_fn = &eventsTryNext,
  672. .deinit_fn = &eventsDeinit,
  673. .try_next_fn = &eventsTryNext,
  674. .wake_fd = ch.wake_fd,
  675. };
  676. const fields = allocator.alloc(HlField, 2) catch return api.makeNull();
  677. fields[0] = .{ .key = hlStr("pid"), .value = api.makeNumber(@floatFromInt(pid)) };
  678. fields[1] = .{ .key = hlStr("events"), .value = api.makeIterator(iter) };
  679. const obj = allocator.create(HlObject) catch return api.makeNull();
  680. obj.* = .{ .fields = fields.ptr, .field_count = 2, .deinit_fn = null };
  681. return api.makeObject(obj);
  682. }
  683. /// hl_proc_run(argvList, options) → { exit, out, err } — THE BLOCKING RUN
  684. /// (ticket #80). Starts the command like spawn, reads BOTH pipes to their end
  685. /// on the calling thread, reaps the child and answers everything at once:
  686. /// `out` and `err` are the whole streams as they were written (server.hl
  687. /// splits them into lines, or hands `out` over as Bytes). The wait is a wait
  688. /// like fetch's `result()`: it holds the fiber that asked, which is what lets
  689. /// a page's root read a program's output while it is being constructed.
  690. ///
  691. /// `timeout` (seconds) bounds the whole run: past it the child is killed
  692. /// (SIGKILL — a program that ignores SIGTERM must not hold the caller) and the
  693. /// call is an Error naming the program and the limit.
  694. ///
  695. /// `stdin` (ticket #91): the bytes are written to the child's stdin in the
  696. /// same poll loop that drains its output — a program that answers as it reads
  697. /// (cat, git upload-pack) fills its stdout pipe long before it has read a big
  698. /// input, and a write-everything-first would wait on it forever — and the pipe
  699. /// is closed once they are all written, so the program reads EOF. A program
  700. /// that exits or closes its stdin without reading them all is not an error:
  701. /// the rest is dropped and its `exit` says what happened.
  702. export fn hl_proc_run(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  703. var p = Prepared{};
  704. defer p.deinit();
  705. if (prepare("run", argc, argv, &p)) |err| return err;
  706. var pipes = Pipes{};
  707. const feed = p.stdin_data != null;
  708. if (c.pipe(&pipes.out) != 0 or c.pipe(&pipes.err) != 0 or c.pipe(&pipes.exec) != 0 or (feed and c.pipe(&pipes.in) != 0)) {
  709. return api.makeError("hl:proc run: pipe() failed");
  710. }
  711. // ALL of our ends are close-on-exec: a child that a spawn() starts while
  712. // this run is reading must not hold our pipes open past our child's exit
  713. _ = c.fcntl(pipes.exec[1], 2, 1);
  714. _ = c.fcntl(pipes.out[0], 2, 1);
  715. _ = c.fcntl(pipes.err[0], 2, 1);
  716. _ = c.fcntl(pipes.exec[0], 2, 1);
  717. if (feed) {
  718. _ = c.fcntl(pipes.in[1], 2, 1);
  719. // a child that stops reading must not kill this process (EPIPE instead)
  720. mutexLock(&stdin_mutex);
  721. if (!sigpipe_ignored) {
  722. _ = c.signal(13, @ptrFromInt(1)); // SIG_IGN
  723. sigpipe_ignored = true;
  724. }
  725. mutexUnlock(&stdin_mutex);
  726. }
  727. const pid = forkExec(&p, if (feed) .pipe else .devnull, &pipes);
  728. if (pid < 0) return api.makeError("hl:proc run: fork() failed");
  729. _ = c.close(pipes.out[1]);
  730. _ = c.close(pipes.err[1]);
  731. _ = c.close(pipes.exec[1]);
  732. var in_fd: c_int = -1;
  733. if (feed) {
  734. _ = c.close(pipes.in[0]);
  735. in_fd = pipes.in[1];
  736. _ = c.fcntl(in_fd, 4, O_NONBLOCK); // F_SETFL: written as the pipe takes it
  737. }
  738. defer if (in_fd >= 0) {
  739. _ = c.close(in_fd);
  740. };
  741. const in_data: []const u8 = p.stdin_data orelse "";
  742. var in_off: usize = 0;
  743. if (feed and in_data.len == 0) {
  744. _ = c.close(in_fd);
  745. in_fd = -1;
  746. }
  747. const argv0 = std.mem.span(p.cargv[0].?);
  748. // the exec verdict first: BYTES on the CLOEXEC pipe = exec never happened
  749. {
  750. var eno: i32 = 0;
  751. const got = c.read(pipes.exec[0], @ptrCast(&eno), 4);
  752. _ = c.close(pipes.exec[0]);
  753. if (got == 4) {
  754. var status: c_int = 0;
  755. _ = c.waitpid(pid, &status, 0);
  756. _ = c.close(pipes.out[0]);
  757. _ = c.close(pipes.err[0]);
  758. if (in_fd >= 0) _ = c.close(in_fd);
  759. in_fd = -1;
  760. const m = std.fmt.allocPrint(allocator, "hl:proc run: cannot start '{s}': {s}", .{ argv0, std.mem.span(c.strerror(eno)) }) catch "hl:proc run: cannot start the program";
  761. return api.makeError(m);
  762. }
  763. }
  764. var bufs = [2]std.ArrayListUnmanaged(u8){ .empty, .empty };
  765. defer for (&bufs) |*b| b.deinit(allocator);
  766. var fds = [2]i32{ pipes.out[0], pipes.err[0] };
  767. var open_count: usize = 2;
  768. const started = monoMs();
  769. var timed_out = false;
  770. var rd: [8192]u8 = undefined;
  771. // a child may close its output and still be reading: feeding ends it, not the pipes
  772. while (open_count > 0 or in_fd >= 0) {
  773. var wait_ms: c_int = -1;
  774. if (p.timeout_ms >= 0) {
  775. const left = p.timeout_ms - (monoMs() - started);
  776. if (left <= 0) {
  777. timed_out = true;
  778. break;
  779. }
  780. wait_ms = @intCast(@min(left, std.math.maxInt(c_int)));
  781. }
  782. var pfds: [3]c.PollFd = undefined;
  783. var map: [3]usize = undefined;
  784. var n: usize = 0;
  785. for (fds, 0..) |fd, i| {
  786. if (fd < 0) continue;
  787. pfds[n] = .{ .fd = fd, .events = POLLIN, .revents = 0 };
  788. map[n] = i;
  789. n += 1;
  790. }
  791. if (in_fd >= 0) {
  792. pfds[n] = .{ .fd = in_fd, .events = POLLOUT, .revents = 0 };
  793. map[n] = 2;
  794. n += 1;
  795. }
  796. const pr = c.poll(&pfds, n, wait_ms);
  797. if (pr <= 0) continue; // a timeout is decided at the top; EINTR retries
  798. for (pfds[0..n], 0..) |pfd, pi| {
  799. if (pfd.revents == 0) continue;
  800. const i = map[pi];
  801. if (i == 2) {
  802. // the child's stdin has room (or is gone: the write says EPIPE)
  803. const w = c.write(in_fd, in_data[in_off..].ptr, in_data.len - in_off);
  804. if (w > 0) in_off += @intCast(w);
  805. const e: i32 = if (w < 0) std.c._errno().* else 0;
  806. if (in_off >= in_data.len or (w < 0 and e != 4 and e != 11)) { // done, or not EINTR/EAGAIN
  807. _ = c.close(in_fd);
  808. in_fd = -1;
  809. }
  810. continue;
  811. }
  812. const got = c.read(pfd.fd, &rd, rd.len);
  813. if (got <= 0) {
  814. _ = c.close(pfd.fd);
  815. fds[i] = -1;
  816. open_count -= 1;
  817. continue;
  818. }
  819. bufs[i].appendSlice(allocator, rd[0..@intCast(got)]) catch return api.makeError("hl:proc: out of memory");
  820. }
  821. }
  822. for (fds) |fd| if (fd >= 0) {
  823. _ = c.close(fd);
  824. };
  825. if (in_fd >= 0) {
  826. _ = c.close(in_fd);
  827. in_fd = -1;
  828. }
  829. if (timed_out) _ = c.kill(pid, 9);
  830. var status: c_int = 0;
  831. _ = c.waitpid(pid, &status, 0);
  832. if (timed_out) {
  833. const secs = @as(f64, @floatFromInt(p.timeout_ms)) / 1000.0;
  834. const m = std.fmt.allocPrint(allocator, "hl:proc run: '{s}' did not finish within {d} s (options.timeout) — it was killed", .{ argv0, secs }) catch "hl:proc run: the program did not finish within its timeout";
  835. return api.makeError(m);
  836. }
  837. // POSIX status decode: exited → code, signalled → 128+sig (the shell rule)
  838. const code: i32 = if ((status & 0x7f) == 0) @intCast((status >> 8) & 0xff) else 128 + @as(i32, @intCast(status & 0x7f));
  839. const fields = allocator.alloc(HlField, 3) catch return api.makeError("hl:proc: out of memory");
  840. fields[0] = .{ .key = hlStr("exit"), .value = api.makeNumber(@floatFromInt(code)) };
  841. fields[1] = .{ .key = hlStr("out"), .value = api.makeString(bufs[0].toOwnedSlice(allocator) catch "") };
  842. fields[2] = .{ .key = hlStr("err"), .value = api.makeString(bufs[1].toOwnedSlice(allocator) catch "") };
  843. const obj = allocator.create(HlObject) catch return api.makeError("hl:proc: out of memory");
  844. obj.* = .{ .fields = fields.ptr, .field_count = 3, .deinit_fn = null };
  845. return api.makeObject(obj);
  846. }
  847. /// hl_proc_exit(code) — end this program now with that exit status (ruling
  848. /// on ticket #36). libc's exit, so buffered C streams are flushed; nothing
  849. /// the program would have done later runs.
  850. export fn hl_proc_exit(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  851. var code: c_int = 0;
  852. if (argc >= 1) {
  853. if (argv[0].type != .hl_number) return api.makeError("hl:proc exit: the status must be a Number (0-255)");
  854. const n = argv[0].data.number;
  855. if (n != @floor(n) or n < 0 or n > 255) return api.makeError("hl:proc exit: the status must be a whole Number from 0 to 255");
  856. code = @intFromFloat(n);
  857. }
  858. std.c.exit(code);
  859. }
  860. /// hl_proc_kill(pid, signal?) — SIGTERM by default; the exit event still arrives
  861. export fn hl_proc_kill(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  862. if (argc < 1 or argv[0].type != .hl_number) {
  863. return api.makeError("hl:proc kill: pass the pid spawn() returned");
  864. }
  865. const pid: c_int = @intFromFloat(argv[0].data.number);
  866. var sig: c_int = 15; // SIGTERM
  867. if (argc >= 2 and argv[1].type == .hl_number) sig = @intFromFloat(argv[1].data.number);
  868. if (pid <= 1) return api.makeError("hl:proc kill: refusing pid <= 1");
  869. const r = c.kill(pid, sig);
  870. return api.makeBool(r == 0);
  871. }
  872. /// hl_proc_env(name) — one environment variable, null when unset. The
  873. /// environment is PROCESS surface, which is why it lives in hl:proc.
  874. export fn hl_proc_env(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  875. if (argc < 1 or argv[0].type != .hl_string) {
  876. return api.makeError("hl:proc env: pass the variable name");
  877. }
  878. const name = argv[0].data.string;
  879. const z = allocator.dupeZ(u8, name.ptr[0..name.len]) catch return api.makeNull();
  880. defer allocator.free(z);
  881. const v = std.c.getenv(z.ptr) orelse return api.makeNull();
  882. const copy = allocator.dupe(u8, std.mem.span(v)) catch return api.makeNull();
  883. return api.makeString(copy);
  884. }
  885. // ── the program's own arguments (mission 317) ────────────────────────────────
  886. // The host installs them right after dlopen, the same way it installs the
  887. // script directory: one NUL-separated blob and a count. WHAT they are is the
  888. // host's ruling, not this plugin's — the interpreter hands over the positionals
  889. // that follow a FILE entry, and a compiled binary hands over its own argv minus
  890. // argv[0]. A program started with none simply gets none.
  891. var arg_blob: ?[]u8 = null;
  892. var arg_count: usize = 0;
  893. export fn hl_proc_set_args(ptr: [*]const u8, len: usize, count: usize) callconv(.c) void {
  894. if (arg_blob) |old| allocator.free(old);
  895. arg_blob = allocator.dupe(u8, ptr[0..len]) catch null;
  896. arg_count = if (arg_blob == null) 0 else count;
  897. }
  898. /// hl_proc_argc() — how many arguments the program was given.
  899. export fn hl_proc_argc(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  900. _ = argc;
  901. _ = argv;
  902. return api.makeNumber(@floatFromInt(arg_count));
  903. }
  904. /// hl_proc_arg(i) — the i-th argument, null when there is none. `args()` in
  905. /// server.hl walks these into the list the language hands back; the pair exists
  906. /// because a plugin's return value is one value, and a LIST is what the caller
  907. /// wants.
  908. export fn hl_proc_arg(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  909. if (argc < 1 or argv[0].type != .hl_number) {
  910. return api.makeError("hl:proc arg: pass the index");
  911. }
  912. const n = argv[0].data.number;
  913. if (n < 0 or n != @floor(n)) return api.makeNull();
  914. const want: usize = @intFromFloat(n);
  915. if (want >= arg_count) return api.makeNull();
  916. const blob = arg_blob orelse return api.makeNull();
  917. var seen: usize = 0;
  918. var start: usize = 0;
  919. for (blob, 0..) |ch, i| {
  920. if (ch != 0) continue;
  921. if (seen == want) {
  922. const copy = allocator.dupe(u8, blob[start..i]) catch return api.makeNull();
  923. return api.makeString(copy);
  924. }
  925. seen += 1;
  926. start = i + 1;
  927. }
  928. return api.makeNull();
  929. }
  930. /// hl_proc_cwd() — the process's current working directory, absolute. NOTE the
  931. /// language's paths stay SCRIPT-relative; this is for tools that act where the
  932. /// USER is standing (a CLI operating on "here"), not for resolving your own files.
  933. export fn hl_proc_cwd(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  934. _ = argc;
  935. _ = argv;
  936. var buf: [4096]u8 = undefined;
  937. const p = std.c.getcwd(&buf, buf.len) orelse return api.makeError("hl:proc cwd: getcwd failed");
  938. const copy = allocator.dupe(u8, std.mem.sliceTo(p, 0)) catch return api.makeNull();
  939. return api.makeString(copy);
  940. }
  941. // ── stdin as a LOOP SOURCE (realms, 2026-09-01) ──────────────────────────────
  942. // hl_proc_stdin() → loop source of { line, eof }: one reader thread on fd 0,
  943. // line-buffered, posting under a mutex with an eventfd bell — the same shape
  944. // as a child's pipes above. A joined terminal realm reads its keyboard here.
  945. const StdinSrc = struct {
  946. mutex: PthreadMutex = libc.PTHREAD_MUTEX_INITIALIZER,
  947. queue: std.ArrayListUnmanaged([]u8) = .empty,
  948. wake_fd: i32 = -1,
  949. eof: bool = false,
  950. eof_reported: bool = false,
  951. fn post(self: *StdinSrc, line: ?[]u8) void {
  952. mutexLock(&self.mutex);
  953. if (line) |l| {
  954. self.queue.append(allocator, l) catch {};
  955. } else {
  956. self.eof = true;
  957. }
  958. mutexUnlock(&self.mutex);
  959. const one: u64 = 1;
  960. _ = stdin_c.write(self.wake_fd, @ptrCast(&one), 8);
  961. }
  962. };
  963. const stdin_c = struct {
  964. extern "c" fn read(fd: c_int, buf: [*]u8, n: usize) isize;
  965. extern "c" fn write(fd: c_int, buf: [*]const u8, n: usize) isize;
  966. extern "c" fn eventfd(initval: c_uint, flags: c_int) c_int;
  967. };
  968. var stdin_src: ?*StdinSrc = null;
  969. fn stdinReaderMain(s: *StdinSrc) void {
  970. var buf = std.ArrayListUnmanaged(u8).empty;
  971. defer buf.deinit(allocator);
  972. var rd: [4096]u8 = undefined;
  973. while (true) {
  974. const got = stdin_c.read(0, &rd, rd.len);
  975. if (got <= 0) break;
  976. const chunk = rd[0..@intCast(got)];
  977. var start: usize = 0;
  978. for (chunk, 0..) |ch, k| {
  979. if (ch != '\n') continue;
  980. buf.appendSlice(allocator, chunk[start..k]) catch {};
  981. const line = textLine(buf.items);
  982. if (line) |l| s.post(l);
  983. buf.clearRetainingCapacity();
  984. start = k + 1;
  985. }
  986. buf.appendSlice(allocator, chunk[start..]) catch {};
  987. }
  988. if (buf.items.len > 0) {
  989. const line = textLine(buf.items);
  990. if (line) |l| s.post(l);
  991. }
  992. s.post(null);
  993. }
  994. fn stdinTryNext(ctx: ?*anyopaque) callconv(.c) HlValue {
  995. const s: *StdinSrc = @ptrCast(@alignCast(ctx orelse return api.makeNull()));
  996. var eat: [8]u8 = undefined;
  997. _ = stdin_c.read(s.wake_fd, &eat, 8);
  998. mutexLock(&s.mutex);
  999. var line: ?[]u8 = null;
  1000. var eof_now = false;
  1001. if (s.queue.items.len > 0) {
  1002. line = s.queue.orderedRemove(0);
  1003. } else if (s.eof and !s.eof_reported) {
  1004. s.eof_reported = true;
  1005. eof_now = true;
  1006. }
  1007. mutexUnlock(&s.mutex);
  1008. if (line == null and !eof_now) return api.makeNull();
  1009. const fields = allocator.alloc(HlField, 2) catch return api.makeNull();
  1010. fields[0] = .{ .key = .{ .ptr = "line".ptr, .len = 4 }, .value = if (line) |l| api.makeString(l) else api.makeNull() };
  1011. fields[1] = .{ .key = .{ .ptr = "eof".ptr, .len = 3 }, .value = api.makeBool(eof_now) };
  1012. const obj = allocator.create(HlObject) catch return api.makeNull();
  1013. obj.* = .{ .fields = fields.ptr, .field_count = 2, .deinit_fn = null };
  1014. return api.makeObject(obj);
  1015. }
  1016. fn stdinDeinit(ctx: ?*anyopaque) callconv(.c) void {
  1017. _ = ctx;
  1018. }
  1019. /// hl_proc_stdin() → loop source
  1020. export fn hl_proc_stdin(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  1021. _ = argc;
  1022. _ = argv;
  1023. if (stdin_src != null) return api.makeError("hl:proc stdin: this process already reads its terminal");
  1024. const s = allocator.create(StdinSrc) catch return api.makeError("hl:proc: out of memory");
  1025. s.* = .{ .wake_fd = stdin_c.eventfd(0, 0o4000) };
  1026. const t = std.Thread.spawn(.{}, stdinReaderMain, .{s}) catch {
  1027. allocator.destroy(s);
  1028. return api.makeError("hl:proc stdin: cannot start the reader thread");
  1029. };
  1030. t.detach();
  1031. stdin_src = s;
  1032. const iter = allocator.create(HlIterator) catch return api.makeError("hl:proc: out of memory");
  1033. iter.* = .{
  1034. .context = @ptrCast(s),
  1035. .next_fn = &stdinTryNext,
  1036. .deinit_fn = &stdinDeinit,
  1037. .try_next_fn = &stdinTryNext,
  1038. .wake_fd = s.wake_fd,
  1039. };
  1040. return api.makeIterator(iter);
  1041. }

Branches

  • mainmain branch

Latest commits

  • 4f47843egate: ticket links use the tickets short URL (/<slug>/<n>, tickets#25)mre
  • 86605446mission 002 (code order) 4/4: README file map + import order + 'Same output' test + gate run with a tickets HEAD copy, STATUS (entry, lessons), LOG, report; tests/realdata-baseline.mjs + realdata-compare.py (a cleanup answers the same on live data: pages, modules, API, git over HTTPS and SSH, faces), tests/letcount.pymre
  • cc7bf7bamission 002 (code order) 3/4: let only where a variable is reassigned or re-bound in a loop body (289 lets → plain declarations; 213 left: 125 reassigned, 88 loop-bound; no member/import/param clash). gates 200/0, 46/0, 44/0; real-data reads + writes identical (browser modules: var → const only)mre
  • 090a20c6mission 002 (code order) 2/4: one lib/ file per topic — git.hl split into git (calls, branches, init, temp folder) / homepage / code / pulls / releases (+ git-helpers: paths, ids, |||PR/|||RL markers); repos-helpers, tickets-helpers, transport-helpers; util.hl = localtime + env, storage dir, addresses, lists, text checks, one newest-first sort (was 3 copies); the function routes out of project.hl into lib/api.hl (thin; plumbing in api-helpers.hl), sshgate.hl folded into api.hl + sshkeys.hl keyLine + repos.hl mayPush; 'Make main' and the merge answer out of the faces (code.hl makeMain, pulls.hl pullsView), one login helper (users.hl userOfLoginCode); project.hl is the map. Session-writing routes get &req + &server.sessions. gates 200/0, 46/0, 44/0; real-data identical except /login/failed now shows the parked reason for a browser that already had a session (the old copy-of-req lost it)mre
  • 110c2799mission 002 (code order) 1/4: .hl files out of the root — lib/ (api, git, localtime, markdown, repos, sshgate, sshkeys, tickets, tokens, transport, users), components/styles.hl; jsoncheck.hl removed (imported nowhere); import paths only. gates 200/0, 46/0, 44/0; real-data reads + writes identicalmre
  • fdfb4b1bgitoria: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); gates 200/0, 46/0, 44/0mre
  • 5b46ac84antcolony#40: LOG.md — missions 069/072 are antcolony missions (report paths on Byrodin)mre
  • 5602ff41gitoria: Hybriel master 190aa11d (fc838894 GC correctness, #127 mountKids by reference, #126, #48) — tracker README flat; gates 200/0, 46/0, 44/0mre
  • e85eaf01gitoria: 069 round 2 — hybriel 1a096ad3 not adopted (Markdown SSR still grows); browser gate waits for the server-side logout before restartmre
  • 09ce4f3fgitoria: mission 069 re-vendor hybriel 8efba065 stopped (big SSR pages grow + slow down); lambda audit clean; old vendor keptmre
  • 3dc43108antcolony#40: mission references point to the moved missionsmre
  • 8d9450fdantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 205d5fe4gitoria: Hybriel master ff51cf46; ssh keys/tokens no double rows (session sync); gates follow #20mre
  • 9b27cb26gitoria#21: installable app (manifest, service worker, offline start page), own iconmre
  • 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