antcolony
All repositories: gitoria
23.3 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_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;}const c = struct {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 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 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 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, // ownedstream: []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 = "",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 child_ = 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 = allocator.dupe(u8, bufs[i].items) catch null;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)];var start: usize = 0;for (chunk, 0..) |ch2, k| {if (ch2 != '\n') continue;bufs[i].appendSlice(allocator, chunk[start..k]) catch {};const line = allocator.dupe(u8, bufs[i].items) catch null;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);// 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("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;}// ── exports ──────────────────────────────────────────────────────────────────/// hl_proc_spawn(argvList) → { pid, events }export fn hl_proc_spawn(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {// 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 spawn: 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 spawn: 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 spawn: 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 spawn: pass a command string or an argv list");}const n_args: usize = parts.items.len;if (n_args == 0) return api.makeError("hl:proc spawn: empty command");// NUL-terminated argv for exec, before the fork (no allocation after fork)var cargv = allocator.alloc(?[*:0]const u8, n_args + 1) catch return api.makeError("hl:proc: out of memory");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;}cargv[n_args] = null;var out_pipe: [2]c_int = undefined;var err_pipe: [2]c_int = undefined;var exec_pipe: [2]c_int = undefined; // the exec-failure channel (CLOEXEC)if (c.pipe(&out_pipe) != 0 or c.pipe(&err_pipe) != 0 or c.pipe(&exec_pipe) != 0) {return api.makeError("hl:proc spawn: pipe() failed");}_ = c.fcntl(exec_pipe[1], 2, 1); // F_SETFD FD_CLOEXEC: closes ITSELF on a successful execconst pid = c.fork();if (pid < 0) return api.makeError("hl:proc spawn: fork() failed");if (pid == 0) {// 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(out_pipe[1], 1);_ = c.dup2(err_pipe[1], 2);_ = c.close(out_pipe[0]);_ = c.close(out_pipe[1]);_ = c.close(err_pipe[0]);_ = c.close(err_pipe[1]);_ = c.close(exec_pipe[0]);_ = c.execvp(cargv[0].?, @ptrCast(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(exec_pipe[1], @ptrCast(&e), 4);c._exit(127);}_ = c.close(out_pipe[1]);_ = c.close(err_pipe[1]);_ = c.close(exec_pipe[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 = out_pipe[0],.err_fd = err_pipe[0],.exec_fd = exec_pipe[0],.argv0 = allocator.dupe(u8, std.mem.span(cargv[0].?)) catch "",};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_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 = allocator.dupe(u8, buf.items) catch null;if (line) |l| s.post(l);buf.clearRetainingCapacity();start = k + 1;}buf.appendSlice(allocator, chunk[start..]) catch {};}if (buf.items.len > 0) {const line = allocator.dupe(u8, buf.items) catch null;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
- 3a4d0324antcolony#37: a too-long report gets up to 3 fix tries, finished work is never thrown away for lengthmre
- a6af7883tracker: worker box sees calendar.worldapi.org (login to copy)mre
- c613d26btemplates: bridges to external components (login.js for ident's selector) are allowed (creator 2026-09-27)mre
- 9062978ctracker: worker box sees /media/STORAGE/projects/old-tracker read-only (tracker#2 source data)mre
- 7f9660eeState of 2026-09-27, before the move to gitoriamre