1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
|
//! `pardes --fs`: what a native HOST has to decide to serve acme's control
//! filesystem. `acmefs.zig` owns the semantics and `fuse.zig` owns the kernel;
//! what is left, and lives here, is three decisions — WHERE to mount (derive a
//! per-session point, or take the one the user named), WHEN to drain (one
//! frame's batch, in the order fuse.zig's two queues require), and WHAT A PANE
//! SHELL IS TOLD about it (`PARDES_FS`/`PARDES_PANE`, exported before the
//! fork).
//!
//! It exists because tty.zig and gui.zig would otherwise each carry the same
//! forty lines through two different loops; the only thing that genuinely
//! differs between them is how a background thread wakes the loop, and that is
//! a function pointer. A session without `--fs` allocates nothing here, starts
//! no thread, and costs one null check per frame.
const std = @import("std");
const libc = std.c;
const pardes = @import("pardes.zig");
const fuse = @import("fuse.zig");
/// Diagnostics land on a pane's message row, not on stderr: in the tty shell
/// stderr IS the screen (see main.zig's logFn, which drops every scope for
/// exactly that reason). The log line is the `PARDES_LOG=1` copy, where the
/// mount point and the errno name are worth having.
const log = std.log.scoped(.fs);
// std.c has getenv but neither setter, same as nested.zig.
extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
extern "c" fn unsetenv(name: [*:0]const u8) c_int;
/// How many kernel requests one frame will answer before handing the loop back
/// to the renderer. A `find $PARDES_FS` or a script in a `while true` loop can
/// produce them faster than a frame takes, and an uncapped drain would render
/// only when the script paused. Hitting the cap is not a stall: `drain` says so
/// and the caller wakes its own loop, so the batch continues on the next pass
/// with one frame drawn in between.
const max_batch = 64;
/// Where per-session mounts live: `$XDG_RUNTIME_DIR/pardes` else
/// `~/.local/state/pardes`, and `<that>/<pid>` is this session's mount point.
///
/// NOT `nested.socketDir`, though it answers a related question. That one
/// returns `$XDG_RUNTIME_DIR` itself, because a socket is a FILE whose name
/// (`pardes-<pid>.sock`) already namespaces it. A mount point is a DIRECTORY
/// per pid, and `fuse.sweepStale` unmounts and removes every `<digits>` entry
/// it finds — so it needs a parent that contains nothing but our mounts, which
/// under `$XDG_RUNTIME_DIR` means one more level. The HOME fallback already has
/// that level, which is why the two strings coincide there and only there.
/// The two strings are parameters rather than `getenv` calls so the tests below
/// need not mutate the process environment. That is not fastidiousness: a test
/// binary shares one environ, and unsetting HOME here once took down an
/// unrelated subprocess test three files away.
fn parentFrom(buf: *[std.fs.max_path_bytes:0]u8, xdg: ?[]const u8, home: ?[]const u8) ?[:0]const u8 {
if (xdg) |x| return std.fmt.bufPrintSentinel(buf, "{s}/pardes", .{x}, 0) catch null;
const h = home orelse return null;
return std.fmt.bufPrintSentinel(buf, "{s}/.local/state/pardes", .{h}, 0) catch null;
}
fn envSlice(name: [*:0]const u8) ?[]const u8 {
return if (libc.getenv(name)) |v| std.mem.span(v) else null;
}
fn parentDir(buf: *[std.fs.max_path_bytes:0]u8) ?[:0]const u8 {
return parentFrom(buf, envSlice("XDG_RUNTIME_DIR"), envSlice("HOME"));
}
/// The mount point itself, from `Options.fs`: EMPTY means a bare `--fs`, so
/// derive `<parent>/<pid>`, and anything else is the `--fs=<dir>` the user
/// named, which wins verbatim — scripts and the snapshot harness need a name
/// they can predict.
///
/// A named point must be absolute for the reason fuse.zig gives: the path is
/// handed to a setuid helper that resolves it against its OWN cwd, so a
/// relative one names somewhere else. Passing it through unresolved rather than
/// rooting it here keeps that one rule in one place; `Fs.mount` returns
/// `error.MountPathNotAbsolute`.
fn mountPoint(buf: *[std.fs.max_path_bytes:0]u8, named: []const u8, parent: ?[]const u8) ?[:0]const u8 {
if (named.len != 0) return std.fmt.bufPrintSentinel(buf, "{s}", .{named}, 0) catch null;
const dir = parent orelse return null;
// unsigned: {d} prints a leading '+' for a positive SIGNED int
return std.fmt.bufPrintSentinel(buf, "{s}/{d}", .{ dir, @as(u32, @intCast(libc.getpid())) }, 0) catch null;
}
/// Sweep, derive, mount. Null when the session did not ask for a filesystem —
/// and also when it asked and the mount failed, which is deliberately the same
/// answer: a missing `fuse3`, a `user_allow_other`-less config or a kernel
/// without FUSE must cost the user their scripting, never their session. The
/// failure is reported once, on pane 0's message row, and everything else runs.
///
/// Call after the core exists and before the first frame: the mount is live the
/// moment it returns, so a script racing startup finds a filesystem whose panes
/// are already there.
pub fn start(gpa: std.mem.Allocator, core: *pardes.Pardes) ?*fuse.Fs {
const named = core.opts.fs orelse return null;
var parent_buf: [std.fs.max_path_bytes:0]u8 = undefined;
const parent = parentDir(&parent_buf);
var buf: [std.fs.max_path_bytes:0]u8 = undefined;
const point = mountPoint(&buf, named, parent) orelse {
core.reportError(0, "fs mount", error.NoRuntimeDirectory);
return null;
};
// Both of these are about a point we DERIVED. A `--fs=<dir>` the user named
// is not a directory we are entitled to unmount other things out of, its
// siblings are not ours to guess about, and it is not ours to remove on the
// way out either — `owns_dir` is what keeps `Fs.deinit` from rmdir'ing a
// directory the user made.
const derived = named.len == 0;
if (derived) if (parent) |dir| fuse.sweepStale(dir);
const fs = fuse.Fs.mount(gpa, .{ .mount = point, .owns_dir = derived }) catch |err| {
log.warn("--fs: cannot mount at {s}: {t}", .{ point, err });
core.reportError(0, "fs mount", err);
return null;
};
log.info("--fs: serving {s}", .{point});
return fs;
}
/// Start the one background thread, if there is a filesystem to start it for.
/// It waits for POLLIN on `/dev/fuse` and calls `wake(ctx)` — nothing else; it
/// never touches the core, the descriptor's data, or a request. Both hosts pass
/// a one-line callback that posts their own wake event, which is the ONLY thing
/// that differs between them here.
///
/// A thread that will not spawn is not a filesystem that will not work: the
/// frame poll drains the same requests either way, so the loss is wake latency
/// (a script waits for the next event to arrive from anywhere) and the session
/// is not worth failing over it. That is also the documented no-parallelism
/// backend: skip this call entirely and everything still works.
pub fn wake(fs: ?*fuse.Fs, ctx: ?*anyopaque, callback: *const fn (?*anyopaque) void) void {
const f = fs orelse return;
f.wakeThread(ctx, callback) catch |err|
log.warn("--fs: no poll thread ({t}); draining once per frame instead", .{err});
}
/// What one frame's worth of filesystem work amounted to. Two separate facts,
/// because the two hosts need different ones: an interactive loop asks whether
/// to re-arm itself, while the headless grid harness asks whether anything
/// happened at all — its contract is one frame per event, and a request that
/// changed a pane IS an event.
pub const Drained = struct {
/// Requests answered, parked retries included.
count: usize = 0,
/// The cap stopped the batch with requests still waiting in the kernel.
pending: bool = false,
};
/// One frame's worth of filesystem work.
///
/// The two loops are both to null and in this order, which is fuse.zig's
/// contract rather than a preference:
///
/// - `retry()`'s null ENDS AND RESETS the round, so a caller that took one
/// parked request per frame would leave the second-oldest blocked reader
/// waiting 32 frames. The round is bounded by the park table, so it needs
/// no cap of its own.
/// - `next()`'s null is what acknowledges the drain to the poll thread. That
/// handshake is what stops a level-triggered `poll()` from spinning a core,
/// which is why `pending` has to keep the loop hot: no ack has been sent,
/// so nothing else will wake us.
pub fn drain(fs: *fuse.Fs, core: *pardes.Pardes) Drained {
var d: Drained = .{};
while (fs.retry()) |req| {
step(fs, core, req);
d.count += 1;
}
while (d.count < max_batch) {
const req = fs.next() orelse return d;
step(fs, core, req);
d.count += 1;
}
d.pending = true;
return d;
}
/// One request, one answer, and nothing in between: `req.data` borrows storage
/// the next `next()` overwrites, and the `.fs_reply` this emits is drained
/// before the loop can move on — so the borrow window is a single step, exactly
/// as the design contract requires. The reply normally reaches `Fs.reply`
/// through the host's `push_fs_reply`, because the payload bytes are resolved
/// by `pardes.fsPayload` inside `perform` and are only valid there.
///
/// The exception is the `if` at the end. The core's effect ring is bounded and
/// `emit` DROPS on overflow, which for every other effect costs a repaint and
/// for this one costs a foreign process: an unanswered FUSE request leaves its
/// writer in uninterruptible sleep and its park slot used forever, and 32 of
/// those make the whole mount answer EAGAIN. One `ctl` write reaches the cap
/// (`put` emits a `.save_file` per line). So this loop, which is the only place
/// that knows a request is outstanding, watches the effects it performs for the
/// answer and invents an EIO when none came.
fn step(fs: *fuse.Fs, core: *pardes.Pardes, req: pardes.acmefs.Req) void {
core.update(.{ .fs_req = req });
var answered = false;
while (core.nextEffect()) |e| {
if (e == .fs_reply and e.fs_reply.tag == req.tag) answered = true;
core.perform(e);
}
if (!answered) {
const eio = pardes.acmefs.Reply.fail(req.tag, pardes.acmefs.E.IO);
fs.reply(&eio, "");
}
}
/// What a pane shell is told about the filesystem: `PARDES_FS` is the mount and
/// `PARDES_PANE` is this pane's serial, so a script run inside a pane addresses
/// its own window with no arguments. That pair is acme's `winid` (exec.c), and
/// the serial rather than the slot index because slots are reused and serials
/// never are — `$PARDES_FS/$PARDES_PANE/body` must not start naming somebody
/// else's pane after a close.
///
/// Exported in the PARENT, immediately before the fork, and this is the one
/// place pardes cannot copy acme. acme calls `putenv` in the child, which is
/// safe there because `rfork(RFENVG)` has just given that child a private
/// environment group. A Linux fork has no such thing, and `setenv` between fork
/// and exec can deadlock on an allocator lock some other thread held at fork
/// time — the same rule that already forces `shell_bin.resolve` above the fork
/// in both hosts. The cost is that pardes's own environ carries the
/// last-spawned pane's number; nothing in pardes reads it, and a subprocess
/// that inherits it was spawned on behalf of a pane anyway.
///
/// With no filesystem the pair is REMOVED rather than left alone. A pardes
/// started inside a pardes that does serve one inherits both variables from its
/// parent's pane shell, and a session with no mount of its own must not hand
/// its panes an address that resolves to a window in someone else's session.
pub fn exportPaneEnv(fs: ?*const fuse.Fs, serial: u32) void {
const f = fs orelse {
_ = unsetenv("PARDES_FS");
_ = unsetenv("PARDES_PANE");
return;
};
_ = setenv("PARDES_FS", f.path.ptr, 1);
var buf: [16:0]u8 = undefined;
const id = std.fmt.bufPrintSentinel(&buf, "{d}", .{serial}, 0) catch return;
_ = setenv("PARDES_PANE", id.ptr, 1);
}
const testing = std.testing;
test "the mount point is one level below a per-user parent, named by our pid" {
// $XDG_RUNTIME_DIR is shared with every other program in the session, so
// the mounts need a `pardes/` of their own under it — the level
// nested.socketDir does not have, and the reason this is not that function.
// Asserted rather than merely described, because `fuse.sweepStale` unmounts
// and removes every `<digits>` entry in whatever directory it is handed.
var parent: [std.fs.max_path_bytes:0]u8 = undefined;
const dir = parentFrom(&parent, "/run/user/1000", "/home/tester").?;
try testing.expectEqualStrings("/run/user/1000/pardes", dir);
var buf: [std.fs.max_path_bytes:0]u8 = undefined;
var expect: [std.fs.max_path_bytes]u8 = undefined;
try testing.expectEqualStrings(
try std.fmt.bufPrint(&expect, "{s}/{d}", .{ dir, @as(u32, @intCast(libc.getpid())) }),
mountPoint(&buf, "", dir).?,
);
}
test "no XDG_RUNTIME_DIR falls back to the home state directory, which has the level already" {
var parent: [std.fs.max_path_bytes:0]u8 = undefined;
try testing.expectEqualStrings(
"/home/tester/.local/state/pardes",
parentFrom(&parent, null, "/home/tester").?,
);
}
test "a session with no filesystem removes an inherited address rather than passing it on" {
// PARDES_FS/PARDES_PANE are ours alone, and this leaves them the way an
// --fs-less session leaves them: absent. Nothing else in the test binary
// reads either name, which is why this is the one env-touching test here.
_ = setenv("PARDES_FS", "/run/user/1000/pardes/999", 1);
_ = setenv("PARDES_PANE", "7", 1);
exportPaneEnv(null, 3);
try testing.expect(libc.getenv("PARDES_FS") == null);
try testing.expect(libc.getenv("PARDES_PANE") == null);
}
|