Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/reference/cli/native.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ The CLI uses `HOLLOW_COMMAND_ADDR` when available. Otherwise it discovers the
running host through `%LOCALAPPDATA%\hollow\command-ipc-address`, so a Windows
CLI invoked from an unrelated shell or from WSL works without inherited state.

The command server binds only to loopback addresses. It handles up to eight
connections concurrently, while application mutations remain serialized on the
frame thread. Incomplete frames are disconnected after a total five-second
operation deadline, including on Windows. Native client timeouts also cover
connection establishment. Loopback binding does not authenticate local users;
protect access to the local session accordingly.

## Running it

```bash
Expand Down
104 changes: 55 additions & 49 deletions docs/reference/lua/process.md
Original file line number Diff line number Diff line change
@@ -1,69 +1,75 @@
# `hollow.process`

Run child processes from Lua.
The simple tuple helpers are the recommended path today;
`spawn` and `exec` are placeholders for a future richer API.
Run host processes from Lua. Prefer `spawn` or `exec` in interactive callbacks;
the older `run` and `run_child_process` helpers wait synchronously.

## Functions
## Asynchronous processes

```lua
hollow.process.run_child_process(args, opts?) -- returns (ok, stdout, stderr)
hollow.process.run(cmd, args?) -- returns { code, stdout, stderr }
hollow.term.run_domain_process(args, domain?, opts?) -- runs through a domain shell
```
local job = hollow.process.spawn({
cmd = { "git", "status", "--short" },
cwd = "/path/to/project",
env = { GIT_OPTIONAL_LOCKS = "0" },
timeout_ms = 30000,
output_limit = 1024 * 1024,
on_complete = function(result)
print(result.code, result.stdout, result.stderr, result.error)
end,
})

`run_child_process` is the WezTerm-style tuple helper.
`run` returns a structured table with `code`, `stdout`, `stderr`.
`run_domain_process` resolves the configured domain shell and runs
the argv through it; if `domain` is omitted it uses the current
pane's domain.
job:status() -- "running" or "finished" (completion delivered)
job:result() -- nil until completion, then the result table
job:cancel() -- request cancellation; job:kill() is an alias
job:next(function(result) print(result.code) end)
```

## `opts`
`cmd` is an argv array, or a single executable name. Strings are not parsed as
shell commands; pass a shell explicitly when required. `env` overrides inherited
variables. `cwd` defaults to the application's working directory.

```lua
{
hide_window = true, -- default true; suppresses a console window on Windows
}
```
Up to 16 jobs may be outstanding per Lua runtime. Output is collected separately
for stdout and stderr, with a default limit of 1 MiB each and maximum of 16 MiB.
Exceeding a limit terminates the job with an error. The default timeout is 30 seconds,
with a maximum of 24 hours. Both limits must be positive integers.

## Examples
Completion is polled by a deferred callback every 10 ms; delivery depends on the
application tick. `on_complete` runs on the Lua callback thread, never in a worker.
Config reload and runtime shutdown cancel outstanding jobs and release their resources.
Cancellation applies to the direct child; it does not promise process-tree termination.

```lua
local ok, out, err = hollow.process.run_child_process({
"git", "rev-parse", "--show-toplevel",
})
Results contain `code`, `stdout`, `stderr`, optional `error`, and `canceled`.
A nonzero process exit is a normal result. Spawn failures, timeouts, output-limit
errors, and cancellation use `code = -1` and an `error` string. Output may be absent
on these failures. This API collects output at completion; it does not expose
streaming readers, stdin writers, or a process PID.

if ok then
print("top-level:", out)
else
print("git failed:", err)
end
```
## Promises and coroutines

Run a process through the active pane's domain:
`exec(opts)` returns a promise. It resolves with the result even for a nonzero
exit code; infrastructure failures reject with a result table (or a validation
error string). `spawn` supports cancellation through its returned handle.

```lua
local ok, out, err = hollow.term.run_domain_process({
"ls", "-la",
})
hollow.process.exec({ cmd = { "git", "branch", "--show-current" } })
:next(function(result) print(result.stdout) end)
:catch(function(err) print("process failed", err) end)

hollow.async.run(function()
local job = hollow.process.spawn({ cmd = { "git", "status", "--short" } })
local result = job:wait() -- yields the coroutine
print(result.stdout)
end)
```

Run a process through a specific domain:
## Synchronous compatibility helpers

```lua
local ok, out, err = hollow.term.run_domain_process({
"uname", "-a",
}, "UbuntuWSL")
hollow.process.run_child_process(args, opts?) -- (ok, stdout, stderr)
hollow.process.run(cmd, args?) -- { code, stdout, stderr }
hollow.term.run_domain_process(args, domain?, opts?) -- through a domain shell
```

## Placeholders

`hollow.process.spawn(opts)` and `hollow.process.exec(opts)` are
declared in the API surface but are not implemented yet.
Use the helpers above for now.

## See also

- [`hollow.term.run_domain_process`](term.md#run-a-process-in-a-domain)
- [`hollow.fs`](fs.md) — filesystem helpers
- [Plugins](../../plugins.md) — uses `hollow.process.run` to clone repos
`opts.hide_window` defaults to true on Windows. These helpers retain their existing
50 KiB per-stream output limits. `run_domain_process` uses the active pane's domain
when none is supplied. Asynchronous jobs currently run on the host; pass an explicit
WSL or SSH executable when required.
69 changes: 36 additions & 33 deletions src/app.zig
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const quick_select = @import("app/quick_select.zig");
const htp = @import("app/htp.zig");
const HtpCodec = @import("htp/codec.zig").Codec;
const input = @import("app/input.zig");
const PtyBudget = @import("app/pty_budget.zig").Budget;
const ActionQueue = @import("app/action_queue.zig").ActionQueue;
const Lifecycle = @import("app/lifecycle.zig").Lifecycle;
const hyperlinks = @import("app/hyperlinks.zig");
Expand Down Expand Up @@ -387,6 +388,7 @@ pub const App = struct {
command_ready: std.Io.Condition = .init,
command_done: std.Io.Condition = .init,
pending_command: ?*cmd_ipc.PendingCommandRequest = null,
command_shutting_down: bool = false,
automation_mutex: std.Io.Mutex = .init,
automation_changed: std.Io.Condition = .init,
automation_revision: u64 = 1,
Expand Down Expand Up @@ -934,6 +936,7 @@ pub const App = struct {
self.automation_changed.broadcast(io.get());
self.automation_mutex.unlock(io.get());

cmd_ipc.shutdownPendingCommands(self);
if (self.command_ipc_server) |*server| {
server.deinit();
self.command_ipc_server = null;
Expand Down Expand Up @@ -2228,19 +2231,25 @@ pub const App = struct {
var next_idle_render_poll_ns: i128 = 0;
if (self.mux) |*mux| {
const active_pane = mux.activePane();
var inactive_panes_remaining: usize = 0;
var visible_buf: [MAX_LAYOUT_LEAVES]LayoutLeaf = undefined;
const visible = self.computeActiveLayout(&visible_buf);
const now = io.nanoTimestamp();
const interacting = (self.last_input_activity_ns != 0 and now - self.last_input_activity_ns < PTY_RECENT_ACTIVITY_NS) or
(self.last_resize_activity_ns != 0 and now - self.last_resize_activity_ns < PTY_RECENT_ACTIVITY_NS) or self.previous_frame_slow;
var visible_budget = PtyBudget{ .bytes = 256 * 1024, .nanoseconds = if (interacting) 500_000 else 2_000_000, .panes = 0 };
var hidden_budget = PtyBudget{
.bytes = if (interacting) (if (self.frame_count % 2 == 0) @as(usize, 16 * 1024) else 0) else 256 * 1024,
.nanoseconds = if (interacting) 500_000 else 1_000_000,
.panes = 0,
};
var count_panes = mux.paneIterator();
while (count_panes.next()) |pane| {
if (pane != active_pane) inactive_panes_remaining += 1;
if (pane == active_pane) continue;
const is_visible = for (visible) |leaf| {
if (leaf.pane == pane) break true;
} else false;
if (is_visible) visible_budget.panes += 1 else hidden_budget.panes += 1;
}
// Heavy background output otherwise rebuilds and redraws terminal
// state every frame. Leave alternating frames free for input and
// active-pane rendering.
var inactive_pty_budget: usize = if (self.frame_count % 2 == 0) 16 * 1024 else 0;
const inactive_deadline_ns: i128 = if (inactive_pty_budget > 0)
io.nanoTimestamp() + PTY_INTERACTIVE_BUDGET_NS
else
0;
var panes = mux.paneIteratorActiveFirst();
var pane_idx: usize = 0;
var total_pty_read_ns: i128 = 0;
Expand All @@ -2258,18 +2267,12 @@ pub const App = struct {
while (panes.next()) |pane| {
const pane_is_active = active_pane == pane;
const active_screen_before = pane.active_screen;
const pty_read_loops: usize = if (pane_is_active)
(pane_mod.PTY_HARD_BYTE_LIMIT + pane_mod.PTY_PARSE_CHUNK_BYTES - 1) / pane_mod.PTY_PARSE_CHUNK_BYTES
else
2;
// Inactive panes share one frame budget so work remains bounded
// regardless of pane count. Idle panes donate quota to later panes.
const pty_read_bytes: usize = if (pane_is_active)
pane_mod.PTY_HARD_BYTE_LIMIT
else if (inactive_panes_remaining > 0 and inactive_pty_budget > 0)
(inactive_pty_budget + inactive_panes_remaining - 1) / inactive_panes_remaining
else
0;
const pane_is_visible = for (visible) |leaf| {
if (leaf.pane == pane) break true;
} else false;
const background_budget = if (pane_is_visible) &visible_budget else &hidden_budget;
const pty_read_loops: usize = (pane_mod.PTY_HARD_BYTE_LIMIT + pane_mod.PTY_PARSE_CHUNK_BYTES - 1) / pane_mod.PTY_PARSE_CHUNK_BYTES;
const pty_read_bytes: usize = if (pane_is_active) pane_mod.PTY_HARD_BYTE_LIMIT else background_budget.byteQuota();
const pending_output_bytes: usize = if (pane_is_active) blk: {
const deferred_output_bytes = pane.boot_output.items.len +| pane.pending_terminal_inject.items.len;
break :blk pane.pendingPtyOutputBytes() +| deferred_output_bytes;
Expand All @@ -2286,17 +2289,17 @@ pub const App = struct {
pane_mod.PTY_PARSE_CHUNK_BYTES;
const pty_budget_ns: i128 = if (pane_is_active)
selectPtyBudgetNs(pty_now_ns, self.last_input_activity_ns, self.last_resize_activity_ns, pane.last_pty_output_ns, self.previous_frame_slow, pending_output_bytes)
else if (inactive_deadline_ns != 0 and io.nanoTimestamp() < inactive_deadline_ns)
inactive_deadline_ns - io.nanoTimestamp()
else if (pty_read_bytes > 0)
background_budget.timeQuota()
else
0;
const poll_start_ns = io.nanoTimestamp();
const pty_bytes_read = pane.pollPty(runtime, pty_read_loops, pty_read_bytes, pty_budget_ns, pty_parse_chunk_bytes, self.config.debug_overlay) catch |err| result: {
std.log.err("pane pollPty error: {s}", .{@errorName(err)});
break :result 0;
};
if (!pane_is_active) {
inactive_pty_budget -|= pty_bytes_read;
inactive_panes_remaining -= 1;
background_budget.consume(pty_bytes_read, io.nanoTimestamp() - poll_start_ns);
}
const sync_now_ns = io.nanoTimestamp();
const sync_mode_active = self.config.synchronized_output and
Expand Down Expand Up @@ -2780,13 +2783,13 @@ test "PTY budget adapts to interaction, backlog, pressure, and slow frames" {

test "jsonObjectIndex accepts non-negative integers and whole floats" {
var object = try std.json.ObjectMap.init(std.testing.allocator, &.{}, &.{});
defer object.deinit();
defer object.deinit(std.testing.allocator);

try object.put("int", .{ .integer = 7 });
try object.put("float", .{ .float = 3.0 });
try object.put("negative", .{ .integer = -1 });
try object.put("fraction", .{ .float = 2.5 });
try object.put("text", .{ .string = "4" });
try object.put(std.testing.allocator, "int", .{ .integer = 7 });
try object.put(std.testing.allocator, "float", .{ .float = 3.0 });
try object.put(std.testing.allocator, "negative", .{ .integer = -1 });
try object.put(std.testing.allocator, "fraction", .{ .float = 2.5 });
try object.put(std.testing.allocator, "text", .{ .string = "4" });

try std.testing.expectEqual(@as(?usize, 7), jsonObjectIndex(object, "int"));
try std.testing.expectEqual(@as(?usize, 3), jsonObjectIndex(object, "float"));
Expand All @@ -2806,7 +2809,7 @@ test "cloneJsonValue deep copies nested JSON values" {
var nested = std.json.Array.init(std.testing.allocator);
try nested.append(.{ .string = try std.testing.allocator.dupe(u8, "alpha") });
try nested.append(.{ .integer = 9 });
try source.put(try std.testing.allocator.dupe(u8, "list"), .{ .array = nested });
try source.put(std.testing.allocator, try std.testing.allocator.dupe(u8, "list"), .{ .array = nested });

const clone = try cloneJsonValue(std.testing.allocator, .{ .object = source });
defer deinitJsonValue(std.testing.allocator, clone);
Expand Down
46 changes: 44 additions & 2 deletions src/app/command_dispatcher.zig
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ pub fn drainPendingCommand(self: *App) void {
std.log.info("command-ipc: dispatch_ms={d:.3} kind={s}", .{ elapsedMs(start_ns), @tagName(pending.request.kind) });
}
pending.done = true;
self.command_done.signal(io.get());
self.command_done.broadcast(io.get());
}

pub fn runCommandSync(self: *App, request: command_mod.Request) command_mod.Response {
Expand All @@ -163,10 +163,12 @@ pub fn runCommandSync(self: *App, request: command_mod.Request) command_mod.Resp
self.command_mutex.lockUncancelable(io.get());
defer self.command_mutex.unlock(io.get());

while (self.pending_command != null) {
while (self.pending_command != null and !self.command_shutting_down) {
self.command_done.waitUncancelable(io.get(), &self.command_mutex);
}

if (self.command_shutting_down) return command_mod.Response.fail("shutting_down", "Hollow is shutting down");

self.pending_command = &pending;
self.signalWake();
self.command_ready.signal(io.get());
Expand Down Expand Up @@ -978,3 +980,43 @@ fn execEmit(self: *App, request: command_mod.Request) command_mod.Response {
if (!result.success) return command_mod.Response.fail("error", result.error_message orelse "htp emit failed");
return okNull();
}

/// Wake every connection waiting for the frame thread before joining workers.
pub fn shutdownPendingCommands(self: *App) void {
self.command_mutex.lockUncancelable(io.get());
defer self.command_mutex.unlock(io.get());
self.command_shutting_down = true;
if (self.pending_command) |pending| {
if (!pending.done) {
pending.response = command_mod.Response.fail("shutting_down", "Hollow is shutting down");
pending.done = true;
}
}
self.command_done.broadcast(io.get());
}

test "shutdown releases commands waiting for the frame thread" {
const app = try std.testing.allocator.create(App);
defer std.testing.allocator.destroy(app);
app.* = App.init(std.testing.allocator);
defer app.deinit();
const Waiter = struct {
fn run(target: *App) void {
var response = runCommandSync(target, .{ .kind = .get_revision });
defer response.deinit(target.allocator);
std.debug.assert(!response.success);
std.debug.assert(std.mem.eql(u8, response.status, "shutting_down"));
}
};
const worker = try std.Thread.spawn(.{}, Waiter.run, .{app});
defer worker.join();
defer shutdownPendingCommands(app);
{
app.command_mutex.lockUncancelable(io.get());
defer app.command_mutex.unlock(io.get());
while (app.pending_command == null) {
try io.waitTimeout(&app.command_ready, &app.command_mutex, std.time.ns_per_s);
}
}
// The defer order cancels the pending request before joining the worker.
}
4 changes: 2 additions & 2 deletions src/app/copy_mode.zig
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,6 @@ test "copy mode regex finder supports simple regexp operators" {
try std.testing.expectEqual(@as(usize, 0), anchored_both.start);
try std.testing.expectEqual(@as(usize, 3), anchored_both.end);

try std.testing.expectEqual(@as(?struct { start: usize, end: usize }, null), copyModeRegexFind("^foo", "xxfoo", 0));
try std.testing.expectEqual(@as(?struct { start: usize, end: usize }, null), copyModeRegexFind("foo$", "foobar", 0));
try std.testing.expect(copyModeRegexFind("^foo", "xxfoo", 0) == null);
try std.testing.expect(copyModeRegexFind("foo$", "foobar", 0) == null);
}
40 changes: 40 additions & 0 deletions src/app/pty_budget.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const std = @import("std");

/// Each class gets its own parsing budget. Time is charged only while polling
/// that class, so active-pane work cannot spend a hidden pane's allowance.
pub const Budget = struct {
bytes: usize,
nanoseconds: i128,
panes: usize,

pub fn byteQuota(self: Budget) usize {
if (self.panes == 0) return 0;
return std.math.divCeil(usize, self.bytes, self.panes) catch 0;
}

pub fn timeQuota(self: Budget) i128 {
if (self.panes == 0) return 0;
return @divFloor(self.nanoseconds, @as(i128, @intCast(self.panes)));
}

pub fn consume(self: *Budget, bytes: usize, elapsed_ns: i128) void {
self.bytes -|= bytes;
self.nanoseconds = @max(0, self.nanoseconds - @max(0, elapsed_ns));
self.panes -|= 1;
}
};

test "busy panes share time and idle panes donate unused quota" {
var budget = Budget{ .bytes = 300, .nanoseconds = 3000, .panes = 3 };
try std.testing.expectEqual(@as(usize, 100), budget.byteQuota());
try std.testing.expectEqual(@as(i128, 1000), budget.timeQuota());
budget.consume(0, 0);
try std.testing.expectEqual(@as(usize, 150), budget.byteQuota());
try std.testing.expectEqual(@as(i128, 1500), budget.timeQuota());
budget.consume(150, 1500);
try std.testing.expectEqual(@as(usize, 150), budget.byteQuota());
try std.testing.expectEqual(@as(i128, 1500), budget.timeQuota());
budget.consume(200, 2000);
try std.testing.expectEqual(@as(usize, 0), budget.byteQuota());
try std.testing.expectEqual(@as(i128, 0), budget.timeQuota());
}
Loading
Loading