components
All repositories: gitoria
50.1 KB
// hl:proc — CHILD PROCESSES as instance events (creator API ruling 2026-08-17:// no eventloop plumbing in app code — `spawn()` hands back a Process the app// subscribes with `on process.line` / `on process.exit`, and `process.kill()`// is the stop button).//// Exports:// hl_proc_spawn(argv_list) → { pid, events } — events is a LOOP SOURCE// (wake_fd bell, the fetch-completions pattern)// delivering { line, stream } per output line and// a final { exit } when the child is gone// hl_proc_run(argv_list, o) → { exit, out, err } — BLOCKING: the whole output// once the child is gone (ticket #80)// hl_proc_kill(pid) → SIGTERM the child; the exit event still arrives//// One worker thread per child reads BOTH pipes with poll(), line-buffers, and// posts events into the spawn's OWN queue — nothing global, nothing shared// between children. No shell anywhere: argv is exec'd verbatim.const std = @import("std");const api = @import("plugin_api");const linux = std.os.linux;const libc = std.c;const PthreadMutex = libc.pthread_mutex_t;fn mutexLock(m: *PthreadMutex) void {_ = libc.pthread_mutex_lock(m);}fn mutexUnlock(m: *PthreadMutex) void {_ = libc.pthread_mutex_unlock(m);}const HlValue = api.HlValue;const HlObject = api.HlObject;const HlField = api.HlField;const HlIterator = api.HlIterator;const HlString = api.HlString;var gpa = std.heap.DebugAllocator(.{ .stack_trace_frames = 0 }){};const allocator = gpa.allocator();// SCRIPT-RELATIVE program paths (the Hybriel path rule — imports, hl:fs and// now spawn all resolve against the SCRIPT, not the process cwd): the loader// installs the script dir right after dlopen.var script_dir: ?[]u8 = null;export fn hl_proc_set_script_dir(ptr: [*]const u8, len: usize) callconv(.c) void {if (script_dir) |old| allocator.free(old);script_dir = allocator.dupe(u8, ptr[0..len]) catch null;}// THE PROGRAM'S OWN DIRECTORY (ticket #37), absolute: the entry script's// directory on the interpreter, the executable's for a compiled binary. The// runtime hands it over right after dlopen; realpath makes it absolute.var program_dir: ?[]u8 = null;export fn hl_proc_set_program_dir(ptr: [*]const u8, len: usize) callconv(.c) void {const z = allocator.dupeZ(u8, ptr[0..len]) catch return;defer allocator.free(z);var buf: [4096]u8 = undefined;const r = c.realpath(z.ptr, &buf) orelse return;if (program_dir) |old| allocator.free(old);program_dir = allocator.dupe(u8, std.mem.span(r)) catch null;}/// hl_proc_dir() → the program's own directory, absolute.export fn hl_proc_dir(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {_ = argc;_ = argv;const d = program_dir orelse return api.makeError("hl:proc dir: the runtime did not say where this program is");const copy = allocator.dupe(u8, d) catch return api.makeNull();return api.makeString(copy);}const c = struct {extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8;extern "c" fn fork() c_int;extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;extern "c" fn execvpe(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8, envp: [*:null]const ?[*:0]const u8) c_int;extern "c" fn chdir(path: [*:0]const u8) c_int;extern "c" fn open(path: [*:0]const u8, flags: c_int, ...) c_int;extern "c" var environ: [*:null]?[*:0]const u8;extern "c" fn pipe(fds: *[2]c_int) c_int;extern "c" fn close(fd: c_int) c_int;extern "c" fn dup2(old: c_int, new: c_int) c_int;extern "c" fn kill(pid: c_int, sig: c_int) c_int;extern "c" fn waitpid(pid: c_int, status: ?*c_int, options: c_int) c_int;extern "c" fn _exit(code: c_int) noreturn;extern "c" fn eventfd(initval: c_uint, flags: c_int) c_int;extern "c" fn fcntl(fd: c_int, cmd: c_int, arg: c_int) c_int;extern "c" fn strerror(errnum: c_int) [*:0]const u8;extern "c" fn signal(sig: c_int, handler: ?*const anyopaque) ?*const anyopaque;extern "c" fn write(fd: c_int, buf: [*]const u8, n: usize) isize;extern "c" fn read(fd: c_int, buf: [*]u8, n: usize) isize;extern "c" fn poll(fds: [*]PollFd, n: c_ulong, timeout: c_int) c_int;const PollFd = extern struct { fd: c_int, events: c_short, revents: c_short };};const POLLIN: c_short = 0x001;const POLLOUT: c_short = 0x004;const O_NONBLOCK: c_int = 0o4000;const EFD_NONBLOCK: c_int = 0o4000;fn hlStr(s: []const u8) HlString {return .{ .ptr = s.ptr, .len = s.len };}// ── one spawned child ────────────────────────────────────────────────────────const Event = struct {line: ?[]u8 = null, // owned/// `line` is a raw CHUNK of a binary child, not a line (ticket #42)chunk: bool = false,stream: []const u8 = "", // "out" | "err"exit_code: ?i32 = null,/// exec NEVER HAPPENED: the errno text. The child has no exit — this is/// the ONLY event, delivered as an hl_error so the loop routes it through/// `on x.Error` → `on Error` → located crash (creator ruling 2026-08-17).spawn_err: ?[]u8 = null,};const Child = struct {mutex: PthreadMutex = libc.PTHREAD_MUTEX_INITIALIZER,queue: std.ArrayListUnmanaged(Event) = .empty,wake_fd: i32 = -1,pid: i32 = 0,out_fd: i32 = -1,err_fd: i32 = -1,exec_fd: i32 = -1,argv0: []const u8 = "",/// `binary = true` (ticket #42): output goes out as raw chunks, never/// split into linesbinary: bool = false,thread: ?std.Thread = null,/// the iterator was closed by the loop — the worker frees the Child when donedone: bool = false,fn post(self: *Child, ev: Event) void {mutexLock(&self.mutex);self.queue.append(allocator, ev) catch {};mutexUnlock(&self.mutex);const one: u64 = 1;_ = c.write(self.wake_fd, @ptrCast(&one), 8);}};fn workerMain(ch: *Child) void {// the exec verdict first: BYTES on the CLOEXEC pipe = exec never happened{var eno: i32 = 0;const got = c.read(ch.exec_fd, @ptrCast(&eno), 4);_ = c.close(ch.exec_fd);if (got == 4) {var status: c_int = 0;_ = c.waitpid(ch.pid, &status, 0); // reap the stillborn childstdinClose(ch.pid);_ = c.close(ch.out_fd);_ = c.close(ch.err_fd);const msg = std.fmt.allocPrint(allocator, "hl:proc spawn: cannot start '{s}': {s}", .{ ch.argv0, std.mem.span(c.strerror(eno)) }) catch null;ch.post(.{ .spawn_err = msg orelse @constCast("hl:proc spawn failed") });return;}}var bufs = [2]std.ArrayListUnmanaged(u8){ .empty, .empty };defer for (&bufs) |*b| b.deinit(allocator);const names = [2][]const u8{ "stdout", "stderr" };var fds = [2]i32{ ch.out_fd, ch.err_fd };var open_count: usize = 2;var rd: [4096]u8 = undefined;while (open_count > 0) {var pfds: [2]c.PollFd = undefined;var map: [2]usize = undefined;var n: usize = 0;for (fds, 0..) |fd, i| {if (fd < 0) continue;pfds[n] = .{ .fd = fd, .events = POLLIN, .revents = 0 };map[n] = i;n += 1;}const pr = c.poll(&pfds, n, -1);if (pr <= 0) continue;for (pfds[0..n], 0..) |pfd, pi| {if (pfd.revents == 0) continue;const i = map[pi];const got = c.read(pfd.fd, &rd, rd.len);if (got <= 0) {// EOF on this stream: flush a trailing unterminated lineif (bufs[i].items.len > 0) {const line = textLine(bufs[i].items);if (line) |l| ch.post(.{ .line = l, .stream = names[i] });bufs[i].clearRetainingCapacity();}_ = c.close(pfd.fd);fds[i] = -1;open_count -= 1;continue;}const chunk = rd[0..@intCast(got)];if (ch.binary) {const copy = allocator.dupe(u8, chunk) catch null;if (copy) |b| ch.post(.{ .line = b, .stream = names[i], .chunk = true });continue;}var start: usize = 0;for (chunk, 0..) |ch2, k| {if (ch2 != '\n') continue;bufs[i].appendSlice(allocator, chunk[start..k]) catch {};const line = textLine(bufs[i].items);if (line) |l| ch.post(.{ .line = l, .stream = names[i] });bufs[i].clearRetainingCapacity();start = k + 1;}bufs[i].appendSlice(allocator, chunk[start..]) catch {};}}var status: c_int = 0;_ = c.waitpid(ch.pid, &status, 0);stdinClose(ch.pid);// POSIX status decode: exited → code, signalled → 128+sig (the shell rule)const code: i32 = if ((status & 0x7f) == 0) @intCast((status >> 8) & 0xff) else 128 + @as(i32, @intCast(status & 0x7f));ch.post(.{ .exit_code = code });}// ── the loop source ──────────────────────────────────────────────────────────fn eventsTryNext(ctx: ?*anyopaque) callconv(.c) HlValue {const ch: *Child = @ptrCast(@alignCast(ctx orelse return api.makeNull()));// drain the bell (read may fail with EAGAIN — fine)var eat: [8]u8 = undefined;_ = c.read(ch.wake_fd, &eat, 8);mutexLock(&ch.mutex);if (ch.queue.items.len == 0) {mutexUnlock(&ch.mutex);return api.makeNull();}const ev = ch.queue.orderedRemove(0);mutexUnlock(&ch.mutex);if (ev.spawn_err) |msg| {return api.makeError(msg);}if (ev.exit_code) |code| {const fields = allocator.alloc(HlField, 2) catch return api.makeNull();fields[0] = .{ .key = hlStr("exit"), .value = api.makeNumber(@floatFromInt(code)) };fields[1] = .{ .key = hlStr("pid"), .value = api.makeNumber(@floatFromInt(ch.pid)) };const obj = allocator.create(HlObject) catch return api.makeNull();obj.* = .{ .fields = fields.ptr, .field_count = 2, .deinit_fn = null };return api.makeObject(obj);}const fields = allocator.alloc(HlField, 2) catch return api.makeNull();fields[0] = .{ .key = hlStr(if (ev.chunk) "chunk" else "line"), .value = api.makeString(ev.line orelse "") };fields[1] = .{ .key = hlStr("stream"), .value = api.makeString(ev.stream) };const obj = allocator.create(HlObject) catch return api.makeNull();obj.* = .{ .fields = fields.ptr, .field_count = 2, .deinit_fn = null };return api.makeObject(obj);}fn eventsDeinit(ctx: ?*anyopaque) callconv(.c) void {const ch: *Child = @ptrCast(@alignCast(ctx orelse return));ch.done = true;}/// The child's environment: this process's own, with `overrides` applied —/// a String value sets NAME, a null removes it.fn buildEnv(overrides: *const HlObject) error{ NotAString, OutOfMemory }![:null]?[*:0]const u8 {var list = std.ArrayListUnmanaged(?[*:0]const u8).empty;var i: usize = 0;outer: while (c.environ[i]) |entry| : (i += 1) {const text = std.mem.span(entry);const eq = std.mem.indexOfScalar(u8, text, '=') orelse text.len;for (overrides.fields[0..overrides.field_count]) |f| {if (std.mem.eql(u8, f.key.ptr[0..f.key.len], text[0..eq])) continue :outer;}try list.append(allocator, entry);}for (overrides.fields[0..overrides.field_count]) |f| {if (f.value.type == .hl_null) continue;if (f.value.type != .hl_string) return error.NotAString;const v = f.value.data.string;const kv = try std.fmt.allocPrintSentinel(allocator, "{s}={s}", .{ f.key.ptr[0..f.key.len], v.ptr[0..v.len] }, 0);try list.append(allocator, kv.ptr);}return try list.toOwnedSliceSentinel(allocator, null);}// ── the children's stdin (ruling on ticket #42) ───────────────────────────────//// pid → our write end of that child's stdin pipe. Closed by end(), or when// the child exits (the worker reaps it).var stdin_mutex: PthreadMutex = libc.PTHREAD_MUTEX_INITIALIZER;var stdin_fds: std.AutoHashMapUnmanaged(i32, c_int) = .empty;var sigpipe_ignored = false;fn stdinRegister(pid: i32, fd: c_int) void {mutexLock(&stdin_mutex);defer mutexUnlock(&stdin_mutex);stdin_fds.put(allocator, pid, fd) catch {_ = c.close(fd);};}fn stdinClose(pid: i32) void {mutexLock(&stdin_mutex);defer mutexUnlock(&stdin_mutex);if (stdin_fds.fetchRemove(pid)) |kv| _ = c.close(kv.value);}fn stdinWrite(pid: i32, bytes: []const u8) HlValue {mutexLock(&stdin_mutex);const fd = stdin_fds.get(pid);if (!sigpipe_ignored) {_ = c.signal(13, @ptrFromInt(1)); // SIG_IGN: a closed pipe is EPIPE, not deathsigpipe_ignored = true;}mutexUnlock(&stdin_mutex);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");var off: usize = 0;while (off < bytes.len) {const r = c.write(w, bytes[off..].ptr, bytes.len - off);if (r < 0) {const e: i32 = std.c._errno().*;if (e == 4) continue; // EINTRvar buf: [160]u8 = undefined;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";return api.makeError(m);}off += @intCast(r);}return api.makeNumber(@floatFromInt(bytes.len));}fn pidArg(argc: u32, argv: [*]const HlValue) ?i32 {if (argc < 1 or argv[0].type != .hl_number) return null;return @intFromFloat(argv[0].data.number);}/// hl_proc_write(pid, text) → bytes written; the String's bytes go as they are.export fn hl_proc_write(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {const pid = pidArg(argc, argv) orelse return api.makeError("hl:proc write: pass the pid spawn() returned");if (argc < 2 or argv[1].type != .hl_string) return api.makeError("hl:proc write: pass a String or a Bytes");const sv = argv[1].data.string;return stdinWrite(pid, sv.ptr[0..sv.len]);}/// A LINE IS TEXT, AND TEXT IS UTF-8 (ticket #47): a child printing latin-1 or/// binary bytes in text mode got them into a String unchecked, and the first/// such String that reached a page broke its WebSocket ("Could not decode a/// text frame as UTF-8"). Every byte that does not begin a valid sequence is/// replaced by U+FFFD here, where the bytes become text; `binary = true` is/// the way to the bytes themselves. Answers an owned copy, null on OOM.fn textLine(bytes: []const u8) ?[]u8 {if (std.unicode.utf8ValidateSlice(bytes)) return allocator.dupe(u8, bytes) catch null;var out = std.ArrayListUnmanaged(u8).empty;var k: usize = 0;while (k < bytes.len) {const n = std.unicode.utf8ByteSequenceLength(bytes[k]) catch 0;if (n > 0 and k + n <= bytes.len) {if (std.unicode.utf8Decode(bytes[k .. k + n])) |_| {out.appendSlice(allocator, bytes[k .. k + n]) catch return null;k += n;continue;} else |_| {}}out.appendSlice(allocator, "\xEF\xBF\xBD") catch return null;k += 1;}return out.toOwnedSlice(allocator) catch null;}/// hl_proc_write_hex(pid, hex) — a Bytes crosses the plugin boundary as its/// hex text (the ABI has no Bytes); decoded here, written raw.export fn hl_proc_write_hex(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {const pid = pidArg(argc, argv) orelse return api.makeError("hl:proc write: pass the pid spawn() returned");if (argc < 2 or argv[1].type != .hl_string) return api.makeError("hl:proc write: pass a String or a Bytes");const hex = argv[1].data.string.ptr[0..argv[1].data.string.len];const raw = allocator.alloc(u8, hex.len / 2) catch return api.makeError("hl:proc: out of memory");defer allocator.free(raw);_ = std.fmt.hexToBytes(raw, hex) catch return api.makeError("hl:proc write: not a Bytes");return stdinWrite(pid, raw);}/// This program's OWN stdout (fd 1) or stderr (fd 2), raw (ruling on ticket/// #42): the bytes go out as they are — no newline, no text form — so a/// program can speak a binary protocol on its stdout (git's stateless-rpc, a/// pipe to another tool) and report on stderr without mixing into it.fn fdWrite(fd: c_int, comptime name: []const u8, bytes: []const u8) HlValue {var off: usize = 0;while (off < bytes.len) {const r = c.write(fd, bytes[off..].ptr, bytes.len - off);if (r < 0) {const e: i32 = std.c._errno().*;if (e == 4) continue; // EINTRvar buf: [160]u8 = undefined;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";return api.makeError(m);}off += @intCast(r);}return api.makeNumber(@floatFromInt(bytes.len));}fn textWrite(fd: c_int, comptime name: []const u8, argc: u32, argv: [*]const HlValue) HlValue {if (argc < 1 or argv[0].type != .hl_string) return api.makeError("hl:proc " ++ name ++ ": pass a String or a Bytes");const sv = argv[0].data.string;return fdWrite(fd, name, sv.ptr[0..sv.len]);}fn hexWrite(fd: c_int, comptime name: []const u8, argc: u32, argv: [*]const HlValue) HlValue {if (argc < 1 or argv[0].type != .hl_string) return api.makeError("hl:proc " ++ name ++ ": pass a String or a Bytes");const hex = argv[0].data.string.ptr[0..argv[0].data.string.len];const raw = allocator.alloc(u8, hex.len / 2) catch return api.makeError("hl:proc: out of memory");defer allocator.free(raw);_ = std.fmt.hexToBytes(raw, hex) catch return api.makeError("hl:proc " ++ name ++ ": not a Bytes");return fdWrite(fd, name, raw);}/// hl_proc_write_stdout(text) / hl_proc_write_stdout_hex(hex) — see fdWrite.export fn hl_proc_write_stdout(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {return textWrite(1, "writeStdout", argc, argv);}export fn hl_proc_write_stdout_hex(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {return hexWrite(1, "writeStdout", argc, argv);}/// hl_proc_write_stderr(text) / hl_proc_write_stderr_hex(hex) — the same on fd 2.export fn hl_proc_write_stderr(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {return textWrite(2, "writeStderr", argc, argv);}export fn hl_proc_write_stderr_hex(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {return hexWrite(2, "writeStderr", argc, argv);}/// hl_proc_end(pid) — close the child's stdin: it reads EOF.export fn hl_proc_end(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {const pid = pidArg(argc, argv) orelse return api.makeError("hl:proc end: pass the pid spawn() returned");stdinClose(pid);return api.makeNull();}// ── exports ──────────────────────────────────────────────────────────────────/// A command, parsed and resolved, ready to fork: what `spawn` and `run` share./// Everything is allocated BEFORE the fork; the child only calls chdir and exec.const Prepared = struct {cargv: []?[*:0]const u8 = &.{},cwd_z: ?[:0]u8 = null,envp: ?[:null]?[*:0]const u8 = null,binary: bool = false,stdin_pipe: bool = false,/// `run` only: milliseconds, or -1 for no limittimeout_ms: i64 = -1,/// `run` only (ticket #91): the bytes its stdin reads, then EOF; null is /dev/nullstdin_data: ?[]u8 = null,fn deinit(self: *Prepared) void {for (self.cargv) |a| if (a) |z| allocator.free(std.mem.span(z));if (self.cargv.len > 0) allocator.free(self.cargv);if (self.cwd_z) |d| allocator.free(d);if (self.envp) |ev| allocator.free(ev);if (self.stdin_data) |d| allocator.free(d);}};/// Parse the command (a STRING or a LIST) and the options for `verb`/// ("spawn" or "run"). Answers null when `out` is ready, or the Error to hand/// back. `spawn` takes `stdin` ('pipe' or 'inherit'), `run` takes `timeout`/// and `stdin` (its bytes — server.hl hands them as argv[2], hex); both take/// cwd, env and binary.fn prepare(comptime verb: []const u8, argc: u32, argv: [*]const HlValue, out: *Prepared) ?HlValue {const is_run = comptime std.mem.eql(u8, verb, "run");// Accepts BOTH forms: a STRING (split on ANY whitespace run — spaces,// tabs, NEWLINES: a long command written across lines, the multi-line-// string way, is one command) or a LIST (an object with numeric keys —// the loader's valueArrayToHl contract) for arguments that contain spaces.if (argc < 1) return api.makeError("hl:proc " ++ verb ++ ": pass a command string or an argv list");var parts = std.ArrayListUnmanaged([]const u8).empty;defer parts.deinit(allocator);if (argv[0].type == .hl_string) {// whitespace-run tokens, with QUOTE GROUPING: a token opening with// ' or " runs (whitespace included) to the matching close, quotes// stripped — one argument. NOT a shell: no expansion, no nesting,// no substitution; just grouping, the way a command line reads.const sv0 = argv[0].data.string;const text0 = sv0.ptr[0..sv0.len];var pos: usize = 0;while (pos < text0.len) {const chx = text0[pos];if (chx == ' ' or chx == '\t' or chx == '\r' or chx == '\n') {pos += 1;continue;}if (chx == '\'' or chx == '"') {const close = std.mem.indexOfScalarPos(u8, text0, pos + 1, chx) orelsereturn api.makeError("hl:proc " ++ verb ++ ": unclosed quote in the command string");parts.append(allocator, text0[pos + 1 .. close]) catch return api.makeError("hl:proc: out of memory");pos = close + 1;continue;}var end = pos;while (end < text0.len and text0[end] != ' ' and text0[end] != '\t' and text0[end] != '\r' and text0[end] != '\n') end += 1;parts.append(allocator, text0[pos..end]) catch return api.makeError("hl:proc: out of memory");pos = end;}} else if (argv[0].type == .hl_object) {const obj_in = argv[0].data.object;for (obj_in.fields[0..obj_in.field_count]) |field| {if (field.value.type != .hl_string) return api.makeError("hl:proc " ++ verb ++ ": argv entries must be strings");const sv = field.value.data.string;parts.append(allocator, sv.ptr[0..sv.len]) catch return api.makeError("hl:proc: out of memory");}} else {return api.makeError("hl:proc " ++ verb ++ ": pass a command string or an argv list");}const n_args: usize = parts.items.len;if (n_args == 0) return api.makeError("hl:proc " ++ verb ++ ": empty command");// NUL-terminated argv for exec, before the fork (no allocation after fork)const cargv = allocator.alloc(?[*:0]const u8, n_args + 1) catch return api.makeError("hl:proc: out of memory");@memset(cargv, null);out.cargv = cargv;for (parts.items, 0..) |part, i| {var text: []const u8 = part;// THE PROGRAM (argv[0]): a path containing '/' that is not absolute// resolves against the SCRIPT's directory — the same rule every other// Hybriel path follows. A bare name searches PATH (exec semantics).var resolved: ?[]u8 = null;defer if (resolved) |r| allocator.free(r);if (i == 0 and text.len > 0 and text[0] != '/' and std.mem.indexOfScalar(u8, text, '/') != null) {if (script_dir) |sd| {resolved = std.fmt.allocPrint(allocator, "{s}/{s}", .{ sd, text }) catch null;if (resolved) |r| text = r;}}const z = allocator.dupeZ(u8, text) catch return api.makeError("hl:proc: out of memory");cargv[i] = z.ptr;}// THE OPTIONS (ruling on ticket #37): `{ cwd, env }`, both optional.// `cwd` is where the child starts — a relative one resolves against the// SCRIPT's directory, like the program path. `env` is added to the// environment the child inherits: a String sets the variable, null// removes it.if (argc >= 2 and argv[1].type == .hl_object) {const opts = argv[1].data.object;for (opts.fields[0..opts.field_count]) |field| {const key = field.key.ptr[0..field.key.len];if (!is_run and std.mem.eql(u8, key, "stdin")) {if (field.value.type == .hl_null) continue;const v = if (field.value.type == .hl_string) field.value.data.string.ptr[0..field.value.data.string.len] else "";if (std.mem.eql(u8, v, "pipe")) {out.stdin_pipe = true;} else if (!std.mem.eql(u8, v, "inherit")) {return api.makeError("hl:proc spawn: options.stdin is 'pipe' (write() and end() feed it) or 'inherit' (the default)");}} else if (is_run and std.mem.eql(u8, key, "stdin")) {// server.hl moved a String or a Bytes out to argv[2]; anything left here is neitherif (field.value.type == .hl_null) continue;return api.makeError("hl:proc run: options.stdin is a String or a Bytes — what the program reads before EOF");} else if (is_run and std.mem.eql(u8, key, "timeout")) {if (field.value.type == .hl_null) continue;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");out.timeout_ms = @intFromFloat(@ceil(field.value.data.number * 1000));} else if (std.mem.eql(u8, key, "binary")) {if (field.value.type == .hl_null) continue;if (field.value.type != .hl_bool) return api.makeError("hl:proc " ++ verb ++ ": options.binary must be true or false");out.binary = field.value.data.boolean;} else if (std.mem.eql(u8, key, "cwd")) {if (field.value.type == .hl_null) continue;if (field.value.type != .hl_string) return api.makeError("hl:proc " ++ verb ++ ": options.cwd must be a String");const d = field.value.data.string.ptr[0..field.value.data.string.len];out.cwd_z = if (d.len > 0 and d[0] != '/' and script_dir != null)std.fmt.allocPrintSentinel(allocator, "{s}/{s}", .{ script_dir.?, d }, 0) catch return api.makeError("hl:proc: out of memory")elseallocator.dupeZ(u8, d) catch return api.makeError("hl:proc: out of memory");} else if (std.mem.eql(u8, key, "env")) {if (field.value.type == .hl_null) continue;if (field.value.type != .hl_object) return api.makeError("hl:proc " ++ verb ++ ": options.env must be a hybrid of NAME = value");out.envp = buildEnv(field.value.data.object) catch |e| return api.makeError(switch (e) {error.NotAString => "hl:proc " ++ verb ++ ": an options.env value must be a String (or null to remove the variable)",else => "hl:proc: out of memory",});} else {const known = if (is_run) "cwd, env, binary, stdin and timeout" else "cwd, env, stdin and binary";// allocated, not on this stack: the runtime reads the message after we returnconst m = std.fmt.allocPrint(allocator, "hl:proc " ++ verb ++ ": unknown option '{s}' — the options are {s}", .{ key, known }) catch "hl:proc " ++ verb ++ ": unknown option";return api.makeError(m);}}}// run's stdin (ticket #91): a String's or a Bytes' bytes, crossing as hexif (is_run and argc >= 3 and argv[2].type == .hl_string) {const hex = argv[2].data.string.ptr[0..argv[2].data.string.len];const raw = allocator.alloc(u8, hex.len / 2) catch return api.makeError("hl:proc: out of memory");out.stdin_data = raw;_ = std.fmt.hexToBytes(raw, hex) catch return api.makeError("hl:proc run: options.stdin is a String or a Bytes");}return null;}fn monoMs() i64 {var ts: linux.timespec = undefined;_ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts);return @as(i64, ts.sec) * 1000 + @divTrunc(@as(i64, ts.nsec), 1_000_000);}/// What the forked child does with its stdin: the parent's (spawn's default),/// a pipe the program writes (spawn's `stdin = 'pipe'`, run's `stdin = data`),/// or nothing at all — `run` without `stdin` gives /dev/null, because a child/// that waited for input would hold the caller for as long as nobody typed.const StdinMode = enum { inherit, pipe, devnull };/// The pipes of one fork: our ends are out[0], err[0], exec[0] and in[1].const Pipes = struct {out: [2]c_int = .{ -1, -1 },err: [2]c_int = .{ -1, -1 },exec: [2]c_int = .{ -1, -1 }, // the exec-failure channel (CLOEXEC)in: [2]c_int = .{ -1, -1 },};/// fork + exec the prepared command. Answers the pid (< 0: fork failed) with/// the parent's ends of `pipes` open and the child's closed.fn forkExec(p: *const Prepared, mode: StdinMode, pipes: *Pipes) c_int {const pid = c.fork();if (pid != 0) return pid;// DIE WITH THE PARENT (2026-08-17): a supervisor that crashes or is// Ctrl-C'd must not leave orphaned llama-servers squatting on ports —// the kernel sends the child SIGTERM when hybriel exits, any exit._ = linux.prctl(1, 15, 0, 0, 0); // PR_SET_PDEATHSIG = 1, SIGTERM = 15// the CHILD: pipes onto stdout/stderr, exec verbatim — no shell_ = c.dup2(pipes.out[1], 1);_ = c.dup2(pipes.err[1], 2);switch (mode) {.inherit => {},.pipe => {_ = c.dup2(pipes.in[0], 0);_ = c.close(pipes.in[0]);_ = c.close(pipes.in[1]);},.devnull => {const fd = c.open("/dev/null", 0);if (fd >= 0) {_ = c.dup2(fd, 0);_ = c.close(fd);}},}// this process ignores SIGPIPE once it has written to a child (a// child that stopped reading must not kill it); an ignored signal// survives exec, so the child gets the default back._ = c.signal(13, null);_ = c.close(pipes.out[0]);_ = c.close(pipes.out[1]);_ = c.close(pipes.err[0]);_ = c.close(pipes.err[1]);_ = c.close(pipes.exec[0]);if (p.cwd_z) |d| {if (c.chdir(d.ptr) != 0) {const e: i32 = std.c._errno().*;_ = c.write(pipes.exec[1], @ptrCast(&e), 4);c._exit(127);}}if (p.envp) |ev| {_ = c.execvpe(p.cargv[0].?, @ptrCast(p.cargv.ptr), @ptrCast(ev.ptr));} else {_ = c.execvp(p.cargv[0].?, @ptrCast(p.cargv.ptr));}// exec failed: write errno through the CLOEXEC pipe — a successful// exec closed it, so the parent reading BYTES means "never started"const e: i32 = std.c._errno().*;_ = c.write(pipes.exec[1], @ptrCast(&e), 4);c._exit(127);}/// hl_proc_spawn(argvList) → { pid, events }export fn hl_proc_spawn(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {// the child never returns here (exec or _exit), and the argv strings stay// with the spawn for its lifetime; the options' allocations are the parent'svar p = Prepared{};if (prepare("spawn", argc, argv, &p)) |err| return err;defer if (p.cwd_z) |d| allocator.free(d);defer if (p.envp) |ev| allocator.free(ev);var pipes = Pipes{};// the child's stdin (ticket #42): INHERITED unless `stdin = 'pipe'`, so a// child that reads until EOF never waits on a pipe nobody asked forif (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)) {return api.makeError("hl:proc spawn: pipe() failed");}_ = c.fcntl(pipes.exec[1], 2, 1); // F_SETFD FD_CLOEXEC: closes ITSELF on a successful exec// OUR end of the child's stdin must not leak into LATER children, or the// child never sees EOF while a sibling still holds a copy of it.if (p.stdin_pipe) _ = c.fcntl(pipes.in[1], 2, 1);const pid = forkExec(&p, if (p.stdin_pipe) .pipe else .inherit, &pipes);if (pid < 0) return api.makeError("hl:proc spawn: fork() failed");_ = c.close(pipes.out[1]);_ = c.close(pipes.err[1]);_ = c.close(pipes.exec[1]);if (p.stdin_pipe) {_ = c.close(pipes.in[0]);stdinRegister(pid, pipes.in[1]);}const ch = allocator.create(Child) catch return api.makeError("hl:proc: out of memory");ch.* = .{.wake_fd = c.eventfd(0, EFD_NONBLOCK),.pid = pid,.out_fd = pipes.out[0],.err_fd = pipes.err[0],.exec_fd = pipes.exec[0],.argv0 = allocator.dupe(u8, std.mem.span(p.cargv[0].?)) catch "",.binary = p.binary,};ch.thread = std.Thread.spawn(.{}, workerMain, .{ch}) catch {return api.makeError("hl:proc spawn: worker thread failed");};if (ch.thread) |t| t.detach();const iter = allocator.create(HlIterator) catch return api.makeError("hl:proc: out of memory");iter.* = .{.context = @ptrCast(ch),.next_fn = &eventsTryNext,.deinit_fn = &eventsDeinit,.try_next_fn = &eventsTryNext,.wake_fd = ch.wake_fd,};const fields = allocator.alloc(HlField, 2) catch return api.makeNull();fields[0] = .{ .key = hlStr("pid"), .value = api.makeNumber(@floatFromInt(pid)) };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_proc_run(argvList, options) → { exit, out, err } — THE BLOCKING RUN/// (ticket #80). Starts the command like spawn, reads BOTH pipes to their end/// on the calling thread, reaps the child and answers everything at once:/// `out` and `err` are the whole streams as they were written (server.hl/// splits them into lines, or hands `out` over as Bytes). The wait is a wait/// like fetch's `result()`: it holds the fiber that asked, which is what lets/// a page's root read a program's output while it is being constructed.////// `timeout` (seconds) bounds the whole run: past it the child is killed/// (SIGKILL — a program that ignores SIGTERM must not hold the caller) and the/// call is an Error naming the program and the limit.////// `stdin` (ticket #91): the bytes are written to the child's stdin in the/// same poll loop that drains its output — a program that answers as it reads/// (cat, git upload-pack) fills its stdout pipe long before it has read a big/// input, and a write-everything-first would wait on it forever — and the pipe/// is closed once they are all written, so the program reads EOF. A program/// that exits or closes its stdin without reading them all is not an error:/// the rest is dropped and its `exit` says what happened.export fn hl_proc_run(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {var p = Prepared{};defer p.deinit();if (prepare("run", argc, argv, &p)) |err| return err;var pipes = Pipes{};const feed = p.stdin_data != null;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)) {return api.makeError("hl:proc run: pipe() failed");}// ALL of our ends are close-on-exec: a child that a spawn() starts while// this run is reading must not hold our pipes open past our child's exit_ = c.fcntl(pipes.exec[1], 2, 1);_ = c.fcntl(pipes.out[0], 2, 1);_ = c.fcntl(pipes.err[0], 2, 1);_ = c.fcntl(pipes.exec[0], 2, 1);if (feed) {_ = c.fcntl(pipes.in[1], 2, 1);// a child that stops reading must not kill this process (EPIPE instead)mutexLock(&stdin_mutex);if (!sigpipe_ignored) {_ = c.signal(13, @ptrFromInt(1)); // SIG_IGNsigpipe_ignored = true;}mutexUnlock(&stdin_mutex);}const pid = forkExec(&p, if (feed) .pipe else .devnull, &pipes);if (pid < 0) return api.makeError("hl:proc run: fork() failed");_ = c.close(pipes.out[1]);_ = c.close(pipes.err[1]);_ = c.close(pipes.exec[1]);var in_fd: c_int = -1;if (feed) {_ = c.close(pipes.in[0]);in_fd = pipes.in[1];_ = c.fcntl(in_fd, 4, O_NONBLOCK); // F_SETFL: written as the pipe takes it}defer if (in_fd >= 0) {_ = c.close(in_fd);};const in_data: []const u8 = p.stdin_data orelse "";var in_off: usize = 0;if (feed and in_data.len == 0) {_ = c.close(in_fd);in_fd = -1;}const argv0 = std.mem.span(p.cargv[0].?);// the exec verdict first: BYTES on the CLOEXEC pipe = exec never happened{var eno: i32 = 0;const got = c.read(pipes.exec[0], @ptrCast(&eno), 4);_ = c.close(pipes.exec[0]);if (got == 4) {var status: c_int = 0;_ = c.waitpid(pid, &status, 0);_ = c.close(pipes.out[0]);_ = c.close(pipes.err[0]);if (in_fd >= 0) _ = c.close(in_fd);in_fd = -1;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";return api.makeError(m);}}var bufs = [2]std.ArrayListUnmanaged(u8){ .empty, .empty };defer for (&bufs) |*b| b.deinit(allocator);var fds = [2]i32{ pipes.out[0], pipes.err[0] };var open_count: usize = 2;const started = monoMs();var timed_out = false;var rd: [8192]u8 = undefined;// a child may close its output and still be reading: feeding ends it, not the pipeswhile (open_count > 0 or in_fd >= 0) {var wait_ms: c_int = -1;if (p.timeout_ms >= 0) {const left = p.timeout_ms - (monoMs() - started);if (left <= 0) {timed_out = true;break;}wait_ms = @intCast(@min(left, std.math.maxInt(c_int)));}var pfds: [3]c.PollFd = undefined;var map: [3]usize = undefined;var n: usize = 0;for (fds, 0..) |fd, i| {if (fd < 0) continue;pfds[n] = .{ .fd = fd, .events = POLLIN, .revents = 0 };map[n] = i;n += 1;}if (in_fd >= 0) {pfds[n] = .{ .fd = in_fd, .events = POLLOUT, .revents = 0 };map[n] = 2;n += 1;}const pr = c.poll(&pfds, n, wait_ms);if (pr <= 0) continue; // a timeout is decided at the top; EINTR retriesfor (pfds[0..n], 0..) |pfd, pi| {if (pfd.revents == 0) continue;const i = map[pi];if (i == 2) {// the child's stdin has room (or is gone: the write says EPIPE)const w = c.write(in_fd, in_data[in_off..].ptr, in_data.len - in_off);if (w > 0) in_off += @intCast(w);const e: i32 = if (w < 0) std.c._errno().* else 0;if (in_off >= in_data.len or (w < 0 and e != 4 and e != 11)) { // done, or not EINTR/EAGAIN_ = c.close(in_fd);in_fd = -1;}continue;}const got = c.read(pfd.fd, &rd, rd.len);if (got <= 0) {_ = c.close(pfd.fd);fds[i] = -1;open_count -= 1;continue;}bufs[i].appendSlice(allocator, rd[0..@intCast(got)]) catch return api.makeError("hl:proc: out of memory");}}for (fds) |fd| if (fd >= 0) {_ = c.close(fd);};if (in_fd >= 0) {_ = c.close(in_fd);in_fd = -1;}if (timed_out) _ = c.kill(pid, 9);var status: c_int = 0;_ = c.waitpid(pid, &status, 0);if (timed_out) {const secs = @as(f64, @floatFromInt(p.timeout_ms)) / 1000.0;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";return api.makeError(m);}// POSIX status decode: exited → code, signalled → 128+sig (the shell rule)const code: i32 = if ((status & 0x7f) == 0) @intCast((status >> 8) & 0xff) else 128 + @as(i32, @intCast(status & 0x7f));const fields = allocator.alloc(HlField, 3) catch return api.makeError("hl:proc: out of memory");fields[0] = .{ .key = hlStr("exit"), .value = api.makeNumber(@floatFromInt(code)) };fields[1] = .{ .key = hlStr("out"), .value = api.makeString(bufs[0].toOwnedSlice(allocator) catch "") };fields[2] = .{ .key = hlStr("err"), .value = api.makeString(bufs[1].toOwnedSlice(allocator) catch "") };const obj = allocator.create(HlObject) catch return api.makeError("hl:proc: out of memory");obj.* = .{ .fields = fields.ptr, .field_count = 3, .deinit_fn = null };return api.makeObject(obj);}/// hl_proc_exit(code) — end this program now with that exit status (ruling/// on ticket #36). libc's exit, so buffered C streams are flushed; nothing/// the program would have done later runs.export fn hl_proc_exit(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {var code: c_int = 0;if (argc >= 1) {if (argv[0].type != .hl_number) return api.makeError("hl:proc exit: the status must be a Number (0-255)");const n = argv[0].data.number;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");code = @intFromFloat(n);}std.c.exit(code);}/// hl_proc_kill(pid, signal?) — SIGTERM by default; the exit event still arrivesexport fn hl_proc_kill(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_number) {return api.makeError("hl:proc kill: pass the pid spawn() returned");}const pid: c_int = @intFromFloat(argv[0].data.number);var sig: c_int = 15; // SIGTERMif (argc >= 2 and argv[1].type == .hl_number) sig = @intFromFloat(argv[1].data.number);if (pid <= 1) return api.makeError("hl:proc kill: refusing pid <= 1");const r = c.kill(pid, sig);return api.makeBool(r == 0);}/// hl_proc_env(name) — one environment variable, null when unset. The/// environment is PROCESS surface, which is why it lives in hl:proc.export fn hl_proc_env(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_string) {return api.makeError("hl:proc env: pass the variable name");}const name = argv[0].data.string;const z = allocator.dupeZ(u8, name.ptr[0..name.len]) catch return api.makeNull();defer allocator.free(z);const v = std.c.getenv(z.ptr) orelse return api.makeNull();const copy = allocator.dupe(u8, std.mem.span(v)) catch return api.makeNull();return api.makeString(copy);}// ── the program's own arguments (mission 317) ────────────────────────────────// The host installs them right after dlopen, the same way it installs the// script directory: one NUL-separated blob and a count. WHAT they are is the// host's ruling, not this plugin's — the interpreter hands over the positionals// that follow a FILE entry, and a compiled binary hands over its own argv minus// argv[0]. A program started with none simply gets none.var arg_blob: ?[]u8 = null;var arg_count: usize = 0;export fn hl_proc_set_args(ptr: [*]const u8, len: usize, count: usize) callconv(.c) void {if (arg_blob) |old| allocator.free(old);arg_blob = allocator.dupe(u8, ptr[0..len]) catch null;arg_count = if (arg_blob == null) 0 else count;}/// hl_proc_argc() — how many arguments the program was given.export fn hl_proc_argc(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {_ = argc;_ = argv;return api.makeNumber(@floatFromInt(arg_count));}/// hl_proc_arg(i) — the i-th argument, null when there is none. `args()` in/// server.hl walks these into the list the language hands back; the pair exists/// because a plugin's return value is one value, and a LIST is what the caller/// wants.export fn hl_proc_arg(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_number) {return api.makeError("hl:proc arg: pass the index");}const n = argv[0].data.number;if (n < 0 or n != @floor(n)) return api.makeNull();const want: usize = @intFromFloat(n);if (want >= arg_count) return api.makeNull();const blob = arg_blob orelse return api.makeNull();var seen: usize = 0;var start: usize = 0;for (blob, 0..) |ch, i| {if (ch != 0) continue;if (seen == want) {const copy = allocator.dupe(u8, blob[start..i]) catch return api.makeNull();return api.makeString(copy);}seen += 1;start = i + 1;}return api.makeNull();}/// hl_proc_cwd() — the process's current working directory, absolute. NOTE the/// language's paths stay SCRIPT-relative; this is for tools that act where the/// USER is standing (a CLI operating on "here"), not for resolving your own files.export fn hl_proc_cwd(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {_ = argc;_ = argv;var buf: [4096]u8 = undefined;const p = std.c.getcwd(&buf, buf.len) orelse return api.makeError("hl:proc cwd: getcwd failed");const copy = allocator.dupe(u8, std.mem.sliceTo(p, 0)) catch return api.makeNull();return api.makeString(copy);}// ── stdin as a LOOP SOURCE (realms, 2026-09-01) ──────────────────────────────// hl_proc_stdin() → loop source of { line, eof }: one reader thread on fd 0,// line-buffered, posting under a mutex with an eventfd bell — the same shape// as a child's pipes above. A joined terminal realm reads its keyboard here.const StdinSrc = struct {mutex: PthreadMutex = libc.PTHREAD_MUTEX_INITIALIZER,queue: std.ArrayListUnmanaged([]u8) = .empty,wake_fd: i32 = -1,eof: bool = false,eof_reported: bool = false,fn post(self: *StdinSrc, line: ?[]u8) void {mutexLock(&self.mutex);if (line) |l| {self.queue.append(allocator, l) catch {};} else {self.eof = true;}mutexUnlock(&self.mutex);const one: u64 = 1;_ = stdin_c.write(self.wake_fd, @ptrCast(&one), 8);}};const stdin_c = struct {extern "c" fn read(fd: c_int, buf: [*]u8, n: usize) isize;extern "c" fn write(fd: c_int, buf: [*]const u8, n: usize) isize;extern "c" fn eventfd(initval: c_uint, flags: c_int) c_int;};var stdin_src: ?*StdinSrc = null;fn stdinReaderMain(s: *StdinSrc) void {var buf = std.ArrayListUnmanaged(u8).empty;defer buf.deinit(allocator);var rd: [4096]u8 = undefined;while (true) {const got = stdin_c.read(0, &rd, rd.len);if (got <= 0) break;const chunk = rd[0..@intCast(got)];var start: usize = 0;for (chunk, 0..) |ch, k| {if (ch != '\n') continue;buf.appendSlice(allocator, chunk[start..k]) catch {};const line = textLine(buf.items);if (line) |l| s.post(l);buf.clearRetainingCapacity();start = k + 1;}buf.appendSlice(allocator, chunk[start..]) catch {};}if (buf.items.len > 0) {const line = textLine(buf.items);if (line) |l| s.post(l);}s.post(null);}fn stdinTryNext(ctx: ?*anyopaque) callconv(.c) HlValue {const s: *StdinSrc = @ptrCast(@alignCast(ctx orelse return api.makeNull()));var eat: [8]u8 = undefined;_ = stdin_c.read(s.wake_fd, &eat, 8);mutexLock(&s.mutex);var line: ?[]u8 = null;var eof_now = false;if (s.queue.items.len > 0) {line = s.queue.orderedRemove(0);} else if (s.eof and !s.eof_reported) {s.eof_reported = true;eof_now = true;}mutexUnlock(&s.mutex);if (line == null and !eof_now) return api.makeNull();const fields = allocator.alloc(HlField, 2) catch return api.makeNull();fields[0] = .{ .key = .{ .ptr = "line".ptr, .len = 4 }, .value = if (line) |l| api.makeString(l) else api.makeNull() };fields[1] = .{ .key = .{ .ptr = "eof".ptr, .len = 3 }, .value = api.makeBool(eof_now) };const obj = allocator.create(HlObject) catch return api.makeNull();obj.* = .{ .fields = fields.ptr, .field_count = 2, .deinit_fn = null };return api.makeObject(obj);}fn stdinDeinit(ctx: ?*anyopaque) callconv(.c) void {_ = ctx;}/// hl_proc_stdin() → loop sourceexport fn hl_proc_stdin(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {_ = argc;_ = argv;if (stdin_src != null) return api.makeError("hl:proc stdin: this process already reads its terminal");const s = allocator.create(StdinSrc) catch return api.makeError("hl:proc: out of memory");s.* = .{ .wake_fd = stdin_c.eventfd(0, 0o4000) };const t = std.Thread.spawn(.{}, stdinReaderMain, .{s}) catch {allocator.destroy(s);return api.makeError("hl:proc stdin: cannot start the reader thread");};t.detach();stdin_src = s;const iter = allocator.create(HlIterator) catch return api.makeError("hl:proc: out of memory");iter.* = .{.context = @ptrCast(s),.next_fn = &stdinTryNext,.deinit_fn = &stdinDeinit,.try_next_fn = &stdinTryNext,.wake_fd = s.wake_fd,};return api.makeIterator(iter);}
Branches
- mainmain branch
Latest commits
- e4f01768antcolony#40: mission references point to the moved missionsmre
- d32b44e2antcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
- 0fb80b57components: Hybriel master ff51cf46 (re-vendor round)mre
- 57408b6ecomponents#14: installable app (manifest, service worker, offline index), own iconmre
- 317bd09fdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
- becbd59bcomponents#13: modal dialog (<dialog> based, focus wrap, backdrop/Esc/x close with reason, scroll lock, footer buttons)mre
- 367dd19adeploy.sh: never send .git or .gitignore to Byrodinmre
- 4b4dabe5State of 2026-09-27, before the move to gitoriamre