Skip to content
Open
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
4 changes: 3 additions & 1 deletion docs/src/app/terminal/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ The full app lives in [`examples/terminal`](https://github.com/native-sdk/native
<tbody>
<tr>
<td><code>ptySpawn</code></td>
<td>Open a pty, fork <code>argv</code> onto it as its controlling terminal, and stream output back through <code>on_event</code>. Initial <code>cols</code>/<code>rows</code> and an optional <code>term</code> (default <code>xterm-256color</code>).</td>
<td>Open a pty, fork <code>argv</code> onto it as its controlling terminal, and stream output back through <code>on_event</code>. Initial <code>cols</code>/<code>rows</code>, an optional <code>term</code> (default <code>xterm-256color</code>), and an optional <code>cwd</code> (default: inherit the app's).</td>
</tr>
<tr>
<td><code>ptyWrite</code></td>
Expand All @@ -188,6 +188,8 @@ The full app lives in [`examples/terminal`](https://github.com/native-sdk/native
</tbody>
</table>

`cwd` is entered in the CHILD, immediately before exec — so a directory that cannot be entered fails that spawn through its own `.exit` terminal (`code != 0`), and no other pty's directory moves. `argv[0]` still resolves against PATH from the app's own directory. Without it, per-directory terminals have to smuggle the change into the command line (`sh -c "cd '<path>' && exec <shell> -i"`), which turns a path into a shell word and breaks on a quote.

A pty is **a spawn with a different transport**, so it rides the same seams as `Cmd.spawn`: the same `command` permission in [`app.zon`](/app-zon), the same environment policy (the child inherits the host environment the app bound, plus `TERM`), the same argv budgets, and the same one key space as spawns, fetches, and channels — a pty key is occupied from spawn until its exit Msg delivers.

## Output is coalesced, never per-read
Expand Down
42 changes: 42 additions & 0 deletions src/runtime/effects.zig
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,9 @@ pub const max_effect_pty_outbound_bytes: usize = 64 * 1024;
pub const max_effect_pty_write_records: usize = 256;
/// Longest TERM value `ptySpawn` accepts.
pub const max_effect_pty_term_bytes: usize = 32;
/// Longest `cwd` path `ptySpawn` accepts. The slot stores the request
/// inline like argv and TERM, so this is a fixed cost per pty slot.
pub const max_effect_pty_cwd_bytes: usize = 1024;
/// Bytes of `ptyWrite` input a FAKE pty retains for test assertions
/// (`ptyWrittenBytes`) — the scriptable pty's inspection window, not a
/// delivery bound: writes beyond it drop oldest-first, exactly what a
Expand Down Expand Up @@ -2899,6 +2902,16 @@ pub fn Effects(comptime Msg: type) type {
/// Initial grid size the child observes via TIOCGWINSZ.
cols: u16 = 80,
rows: u16 = 24,
/// The child's initial working directory, at most
/// `max_effect_pty_cwd_bytes`. Null (the default) inherits
/// the app process's, which is what a pty child got before
/// this existed. Entered in the CHILD immediately before
/// exec, so a directory that cannot be entered fails the
/// spawn through the same `.exit` terminal a failed exec
/// reports through — never a child silently running
/// somewhere else. `argv[0]` still resolves against PATH
/// from the app's own directory.
cwd: ?[]const u8 = null,
/// The TERM the child starts with. The rest of the child's
/// environment is the spawn policy verbatim: the bound host
/// environ (`bindEnviron`), nothing else — a pty is a spawn
Expand All @@ -2920,6 +2933,9 @@ pub fn Effects(comptime Msg: type) type {
cols: u16,
rows: u16,
term: []const u8,
/// The requested working directory, or null where the
/// child inherits the app's.
cwd: ?[]const u8 = null,
};

/// How one pty table slot advances: `.running` from the
Expand Down Expand Up @@ -2969,6 +2985,10 @@ pub fn Effects(comptime Msg: type) type {
argv_count: usize = 0,
term_storage: [max_effect_pty_term_bytes]u8 = undefined,
term_len: usize = 0,
cwd_storage: [max_effect_pty_cwd_bytes]u8 = undefined,
/// 0 means "no cwd requested" — an empty path is refused at
/// the seam, so the length carries the optionality.
cwd_len: usize = 0,
/// Session replay only: the pending-order reservation this
/// parked spawn holds (see `ParkOrderState` — the channel
/// park dance, pty-shaped: a fed start-failure terminal
Expand All @@ -2985,6 +3005,11 @@ pub fn Effects(comptime Msg: type) type {
fn requestTerm(slot: *const PtySlot) []const u8 {
return slot.term_storage[0..slot.term_len];
}

fn requestCwd(slot: *const PtySlot) ?[]const u8 {
if (slot.cwd_len == 0) return null;
return slot.cwd_storage[0..slot.cwd_len];
}
};

/// How one channel table slot advances: `.open` accepts posts
Expand Down Expand Up @@ -6797,6 +6822,7 @@ pub fn Effects(comptime Msg: type) type {
.cols = slot.cols,
.rows = slot.rows,
.term = slot.requestTerm(),
.cwd = slot.requestCwd(),
};
}
seen += 1;
Expand Down Expand Up @@ -6864,6 +6890,17 @@ pub fn Effects(comptime Msg: type) type {
// truncate it at the C boundary, so a spawn that "succeeded"
// would hand the child a different TERM than requested.
if (std.mem.indexOfScalar(u8, options.term, 0) != null) return self.rejectPty(options.key, options.on_event, true);
// The cwd rides the same regenerating-validation rule: an
// empty path is not a directory, an over-bound one does not
// fit the slot, and an embedded NUL would enter a DIFFERENT
// directory than the caller named. All three refuse here so
// the fake executor and replay refuse identically.
if (options.cwd) |dir| {
if (dir.len == 0 or dir.len > max_effect_pty_cwd_bytes) {
return self.rejectPty(options.key, options.on_event, true);
}
if (std.mem.indexOfScalar(u8, dir, 0) != null) return self.rejectPty(options.key, options.on_event, true);
}
if (self.keyOccupiedUntilDelivery(options.key)) return self.rejectPty(options.key, options.on_event, true);
const slot_index = self.findIdlePtySlot() orelse return self.rejectPty(options.key, options.on_event, true);
// Table capacity obeys the replay-hold invariant, the
Expand Down Expand Up @@ -6911,6 +6948,10 @@ pub fn Effects(comptime Msg: type) type {
}
@memcpy(slot.term_storage[0..options.term.len], options.term);
slot.term_len = options.term.len;
if (options.cwd) |dir| {
@memcpy(slot.cwd_storage[0..dir.len], dir);
slot.cwd_len = dir.len;
} else slot.cwd_len = 0;

if (slot.fake) {
// Session replay: PARK — the fake-slot discipline. The
Expand Down Expand Up @@ -11064,6 +11105,7 @@ pub fn Effects(comptime Msg: type) type {
.argv = slot.requestArgv(),
.env = env,
.term = slot.requestTerm(),
.cwd = slot.requestCwd(),
.cols = slot.cols,
.rows = slot.rows,
}) catch {
Expand Down
73 changes: 73 additions & 0 deletions src/runtime/effects_pty_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,18 @@ test "pty admission: every refused spawn delivers exactly one rejected exit" {
fx.ptySpawn(.{ .key = 5, .argv = &.{"sh"}, .term = long_term, .on_event = DirectFx.ptyMsg(.pty) });
_ = try expectExit(&fx, 5, .rejected);

// A cwd that is empty, over its bound, or carries a NUL — the same
// three refusals argv and TERM take, for the same reason: a path
// truncated at a NUL would enter a DIFFERENT directory than the
// caller named, silently.
fx.ptySpawn(.{ .key = 51, .argv = &.{"sh"}, .cwd = "", .on_event = DirectFx.ptyMsg(.pty) });
_ = try expectExit(&fx, 51, .rejected);
const long_cwd = "/d" ** effects_mod.max_effect_pty_cwd_bytes;
fx.ptySpawn(.{ .key = 52, .argv = &.{"sh"}, .cwd = long_cwd, .on_event = DirectFx.ptyMsg(.pty) });
_ = try expectExit(&fx, 52, .rejected);
fx.ptySpawn(.{ .key = 53, .argv = &.{"sh"}, .cwd = "/tmp\x00/etc", .on_event = DirectFx.ptyMsg(.pty) });
_ = try expectExit(&fx, 53, .rejected);

// A duplicate active key.
fx.ptySpawn(.{ .key = 6, .argv = &.{"sh"}, .on_event = DirectFx.ptyMsg(.pty) });
fx.ptySpawn(.{ .key = 6, .argv = &.{"sh"}, .on_event = DirectFx.ptyMsg(.pty) });
Expand Down Expand Up @@ -676,6 +688,67 @@ test "live pty end to end: output, coalescing, and the exit code" {
try testing.expectEqual(effects_mod.EffectExitReason.exited, result.exit.reason);
}

test "live pty cwd: the child starts in the requested directory" {
if (comptime !live_posix) return;
var fx = DirectFx.init(testing.allocator);
defer fx.deinit();

// `/` is the one directory every posix host has, and it is not the
// test process's own — so a child that ignored `cwd` prints
// something else and the assertion fails rather than passing by
// coincidence.
fx.ptySpawn(.{
.key = 61,
.argv = &.{ "/bin/sh", "-c", "pwd" },
.cwd = "/",
.on_event = DirectFx.ptyMsg(.pty),
});
var result = try drainUntilExit(&fx, 5_000);
defer result.output.deinit(testing.allocator);
try testing.expectEqual(@as(i32, 0), result.exit.code);
try testing.expect(std.mem.startsWith(u8, result.output.items, "/\r\n"));
}

test "live pty cwd: an unenterable directory fails the spawn, never a child elsewhere" {
if (comptime !live_posix) return;
var fx = DirectFx.init(testing.allocator);
defer fx.deinit();

// The whole point of entering the directory in the CHILD: the
// failure has to reach the app as this spawn's terminal. A parent
// that could not chdir would either move every other spawn's
// directory too or silently start the child in the wrong place.
fx.ptySpawn(.{
.key = 62,
.argv = &.{ "/bin/sh", "-c", "pwd" },
.cwd = "/no/such/directory/native-sdk-cwd-test",
.on_event = DirectFx.ptyMsg(.pty),
});
var result = try drainUntilExit(&fx, 5_000);
defer result.output.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 0), result.output.items.len);
try testing.expect(result.exit.code != 0);
}

test "a fake pty mirrors the requested cwd, and null means inherit" {
var fx = DirectFx.init(testing.allocator);
defer fx.deinit();
fx.executor = .fake;

fx.ptySpawn(.{ .key = 71, .argv = &.{"sh"}, .cwd = "/tmp", .on_event = DirectFx.ptyMsg(.pty) });
fx.ptySpawn(.{ .key = 72, .argv = &.{"sh"}, .on_event = DirectFx.ptyMsg(.pty) });

const with_cwd = fx.pendingPtyAt(0) orelse return error.TestExpectedRequest;
try testing.expectEqual(@as(u64, 71), with_cwd.key);
try testing.expectEqualStrings("/tmp", with_cwd.cwd orelse return error.TestExpectedRequest);
// Null is not the empty string: "inherit the app's directory" and
// "start in the filesystem root of a relative path" are different
// requests, and a test that pinned "" could not tell them apart.
const without_cwd = fx.pendingPtyAt(1) orelse return error.TestExpectedRequest;
try testing.expectEqual(@as(u64, 72), without_cwd.key);
try testing.expect(without_cwd.cwd == null);
}

test "live pty back-pressure is lossless past the staging ring" {
if (comptime !live_posix) return;
var fx = DirectFx.init(testing.allocator);
Expand Down
32 changes: 31 additions & 1 deletion src/runtime/pty.zig
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,15 @@ pub const Exit = struct {
/// child does nothing but exec.
pub const SpawnOptions = struct {
argv: []const []const u8,
/// The child's initial working directory. Null inherits the
/// parent's, which is what a pty child got before this existed.
/// Applied in the child with `chdir` immediately before `execve`
/// (async-signal-safe, like every other post-fork call here), so a
/// directory that cannot be entered fails the spawn through the
/// exec self-pipe rather than starting the child somewhere else.
/// Note that `argv[0]` still resolves against PATH in the PARENT —
/// a relative command is resolved before this `chdir`.
cwd: ?[]const u8 = null,
/// The child's environment. When null the child inherits nothing but
/// `TERM` (a clean environment, like the fallback spawn environ).
env: ?[]const EnvVar = null,
Expand Down Expand Up @@ -435,6 +444,12 @@ pub fn spawn(gpa: std.mem.Allocator, options: SpawnOptions) Error!Pty {
for (options.argv) |arg| {
if (std.mem.indexOfScalar(u8, arg, 0) != null) return error.PtyArgvInvalid;
}
// Same rule for the cwd, and for the same reason: `chdir` reads to
// the first NUL, so an embedded one would silently enter a
// DIFFERENT directory rather than fail.
if (options.cwd) |dir| {
if (dir.len == 0 or std.mem.indexOfScalar(u8, dir, 0) != null) return error.PtyArgvInvalid;
}

// Resolve argv[0] to an absolute path in the PARENT so the child's
// only post-fork calls are login_tty/execve/_exit — execve needs a
Expand All @@ -451,14 +466,18 @@ pub fn spawn(gpa: std.mem.Allocator, options: SpawnOptions) Error!Pty {
const resolved_z = arena.dupeZ(u8, resolved) catch return error.PtyEnvironTooLarge;
const argv_z = buildArgvZ(arena, options.argv) catch return error.PtyEnvironTooLarge;
const envp_z = buildEnvpZ(arena, options.env, options.term) catch return error.PtyEnvironTooLarge;
const cwd_z: ?[:0]const u8 = if (options.cwd) |dir|
arena.dupeZ(u8, dir) catch return error.PtyEnvironTooLarge
else
null;

const ws: Winsize = .{
.row = if (options.rows == 0) 24 else options.rows,
.col = if (options.cols == 0) 80 else options.cols,
.xpixel = 0,
.ypixel = 0,
};
const pair = try spawnPair(resolved_z, argv_z, envp_z, ws);
const pair = try spawnPair(resolved_z, argv_z, envp_z, cwd_z, ws);
// A failure byte, EOF, or the timeout resolves the probe. A real
// exec failure writes its byte into the pipe buffer INSTANTLY (the
// child's write lands before its _exit), so a byte always means
Expand Down Expand Up @@ -513,6 +532,7 @@ fn spawnPair(
resolved_z: [:0]const u8,
argv_z: [:null]const ?[*:0]const u8,
envp_z: [:null]const ?[*:0]const u8,
cwd_z: ?[:0]const u8,
ws: Winsize,
) Error!SpawnedPair {
pty_spawn_mutex.lock();
Expand Down Expand Up @@ -651,6 +671,15 @@ fn spawnPair(
_ = c.close(parent);
_ = c.close(exec_pipe[0]);
if (c.login_tty(child_fd) != 0) reportExecFailure(exec_pipe[1]);
// The requested cwd, entered here and not in the parent: the
// parent's directory is shared by every other spawn on the
// process, and a `chdir` there would be a data race with them.
// A directory that cannot be entered reports through the SAME
// self-pipe an exec failure uses — "the pty forked but the
// program could not start" is exactly this case.
if (cwd_z) |dir| {
if (c.chdir(dir.ptr) != 0) reportExecFailure(exec_pipe[1]);
}
_ = c.execve(resolved_z.ptr, argv_z.ptr, envp_z.ptr);
reportExecFailure(exec_pipe[1]);
}
Expand Down Expand Up @@ -949,6 +978,7 @@ const c = struct {
extern "c" fn write(fd: c_int, buf: [*]const u8, len: usize) isize;
extern "c" fn close(fd: c_int) c_int;
extern "c" fn access(path: [*:0]const u8, mode: c_int) c_int;
extern "c" fn chdir(path: [*:0]const u8) c_int;
extern "c" fn _exit(code: c_int) noreturn;
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;
Expand Down
12 changes: 11 additions & 1 deletion src/runtime/pty_windows.zig
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,16 @@ pub fn spawn(gpa: std.mem.Allocator, options: SpawnOptions) Error!Pty {
error.InvalidWtf8 => return error.PtyEnvironTooLarge,
error.OutOfMemory => return error.PtyEnvironTooLarge,
};
// `lpCurrentDirectory` is the whole posix `chdir` on this side —
// CreateProcess sets the child's directory itself, so nothing here
// touches the parent's. Null keeps the inherit-the-parent default.
const cwd_w: ?[*:0]const u16 = if (options.cwd) |dir| cwd: {
if (dir.len == 0 or std.mem.indexOfScalar(u8, dir, 0) != null) return error.PtyArgvInvalid;
const len = std.unicode.calcWtf16LeLen(dir) catch return error.PtyArgvInvalid;
const wide = arena.allocSentinel(u16, len, 0) catch return error.PtyEnvironTooLarge;
_ = std.unicode.wtf8ToWtf16Le(wide, dir) catch return error.PtyArgvInvalid;
break :cwd wide.ptr;
} else null;

// The pipe pair. Parent ends are overlapped named-pipe servers (the
// one Windows pipe flavor that can join an event wait); the conhost
Expand Down Expand Up @@ -811,7 +821,7 @@ pub fn spawn(gpa: std.mem.Allocator, options: SpawnOptions) Error!Pty {
0,
win.EXTENDED_STARTUPINFO_PRESENT | win.CREATE_UNICODE_ENVIRONMENT,
env_block_w.ptr,
null,
cwd_w,
&siex.StartupInfo,
&pi,
) == 0) {
Expand Down