diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-30 21:35:50 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:17 -0300 |
| commit | 4e5719a133030e8ed29799d4959bb0b917092d7c (patch) | |
| tree | d933325f8bc15e4484385c5b00f64b63794ffa20 | |
| parent | 20685b048fcb307840451471679339c9b9704b23 (diff) | |
| download | pardes-4e5719a133030e8ed29799d4959bb0b917092d7c.tar.gz pardes-4e5719a133030e8ed29799d4959bb0b917092d7c.zip | |
An editor killed with SIGTERM or SIGHUP removes its 9P socket, and a start sweeps the pardes-9p-<pid>.sock files whose pid is gone
The runtime directory had gathered a hundred sockets of dead editors:
only a clean exit removed its own, and a signal or a crash left it. The
terminal's kill handler and, where nothing else handles the signals, a
handler of the listener's own now unlink the socket before the signal's
death. At listen, each pardes-9p-<pid>.sock whose pid kill(pid, 0)
answers ESRCH for, that is a socket of this user's and that nothing
answers on, is removed; a live pid's, a named session's and anything
else are never touched.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
| -rw-r--r-- | src/9p_io.zig | 124 | ||||
| -rw-r--r-- | src/tty/tty.zig | 2 | ||||
| -rw-r--r-- | test/fs.py | 20 |
3 files changed, 145 insertions, 1 deletions
diff --git a/src/9p_io.zig b/src/9p_io.zig index e475416e..fe1a707f 100644 --- a/src/9p_io.zig +++ b/src/9p_io.zig @@ -825,6 +825,7 @@ pub const Listener = struct { pardes.turn.wake_parked = null; pardes.turn.answer_held = null; pardes.turn.stop(); + own_socket_len = 0; // removed here, not again by a signal if (l.path_len != 0) { var z: [sun_path_len:0]u8 = undefined; @memcpy(z[0..l.path_len], l.path_buf[0..l.path_len]); @@ -835,6 +836,90 @@ pub const Listener = struct { } }; +/// The socket this process listens on, kept where a fatal signal's handler +/// can reach it without allocating (unlinkOwnSocket); empty when none. +var own_socket: [sun_path_len:0]u8 = undefined; +var own_socket_len: usize = 0; + +/// Removes the socket this process listens on. Async-signal-safe (one +/// unlink): SIGTERM and SIGHUP call it before the signal's own death, so a +/// killed editor leaves no socket behind. +pub fn unlinkOwnSocket() void { + if (comptime !supported) return; + const n = own_socket_len; + if (n == 0) return; + own_socket_len = 0; + _ = libc.unlink(own_socket[0..n :0]); +} + +/// SIGTERM and SIGHUP with no handler of their own (a GUI's): the socket +/// goes, then the signal's own death. A host that handles them itself (the +/// terminal's Killed, the detached server's quit) removes it its own way. +fn armSocketCleanup() void { + const sig = std.posix.SIG; + const on: std.posix.Sigaction = .{ .handler = .{ .handler = onFatalSignal }, .mask = std.posix.sigemptyset(), .flags = std.posix.SA.RESETHAND }; + for ([_]std.posix.SIG{ sig.TERM, sig.HUP }) |s| { + var was: std.posix.Sigaction = undefined; + std.posix.sigaction(s, null, &was); + if (was.handler.handler == sig.DFL) std.posix.sigaction(s, &on, null); + } +} + +fn onFatalSignal(s: std.posix.SIG) callconv(.c) void { + unlinkOwnSocket(); + // SA_RESETHAND put the default back: the same signal, now fatal. + _ = std.c.raise(s); +} + +/// Removes `pardes-9p-<pid>.sock` files under `dir` whose pid is gone +/// (kill(pid, 0) answers ESRCH): what an editor killed by a signal it could +/// not catch, or a crash, left behind. A live pid's socket, a named +/// session's, anything not a socket of this user's, and one something +/// still answers on are left alone. +pub fn sweepDeadSockets(io: std.Io, dir: [:0]const u8) void { + if (comptime !supported) return; + var names: [8192]u8 = undefined; + var staged: usize = 0; + { + const d = std.Io.Dir.openDirAbsolute(io, dir, .{ .iterate = true }) catch return; + defer d.close(io); + var read_buf: [std.Io.Dir.Iterator.reader_buffer_len]u8 align(@alignOf(usize)) = undefined; + var reader: std.Io.Dir.Reader = .init(d, &read_buf); + while (true) { + const listed = (reader.next(io) catch break) orelse break; + if (deadSocketPid(listed.name) == null) continue; + if (staged + 1 + listed.name.len > names.len) break; + names[staged] = @intCast(listed.name.len); + @memcpy(names[staged + 1 ..][0..listed.name.len], listed.name); + staged += 1 + listed.name.len; + } + } + var i: usize = 0; + while (i < staged) { + const name = names[i + 1 ..][0..names[i]]; + i += 1 + name.len; + const pid = deadSocketPid(name).?; + if (pid == libc.getpid()) continue; + if (std.posix.errno(std.c.kill(pid, @enumFromInt(0))) != .SRCH) continue; + var path_buf: [sun_path_len:0]u8 = undefined; + const path = std.fmt.bufPrintSentinel(&path_buf, "{s}/{s}", .{ dir, name }, 0) catch continue; + const facts = statNoFollow(path) orelse continue; + if (facts.mode & 0o170000 != 0o140000 or facts.uid != libc.getuid()) continue; + if (probe(path) == .live) continue; + _ = libc.unlink(path); + } +} + +/// The pid a `pardes-9p-<pid>.sock` name carries, or null for any other. +fn deadSocketPid(name: []const u8) ?std.c.pid_t { + if (!std.mem.startsWith(u8, name, prefix) or !std.mem.endsWith(u8, name, ".sock")) return null; + const digits = name[prefix.len .. name.len - ".sock".len]; + if (digits.len == 0 or digits.len > 10) return null; + for (digits) |c| if (!std.ascii.isDigit(c)) return null; + const pid = std.fmt.parseInt(std.c.pid_t, digits, 10) catch return null; + return if (pid > 0) pid else null; +} + pub fn socketPath(buf: *[sun_path_len]u8, dir: []const u8, name: []const u8) ?[:0]const u8 { if (name.len == 0) return null; if (std.mem.indexOfAny(u8, name, "/\x00") != null) return null; @@ -851,6 +936,8 @@ pub fn listen(io: std.Io, gpa: std.mem.Allocator, core: *pardes.Pardes, named: [ return null; }; if (!ensureSocketDir(dir)) return null; + // What dead editors left behind goes first, never a live one's. + sweepDeadSockets(io, dir); const entry_name = if (named.len != 0) named else fallback; // Checked before anything is made: the turn's hooks point at the // listener from here on, and a path too long for sun_path used to free @@ -917,6 +1004,10 @@ pub fn listen(io: std.Io, gpa: std.mem.Allocator, core: *pardes.Pardes, named: [ l.deinit(gpa); return null; } + @memcpy(own_socket[0..p.len], p); + own_socket[p.len] = 0; + own_socket_len = p.len; + armSocketCleanup(); l.postToRegistry(entry_name); if (tcp_dial) |dial| { if (!std.mem.startsWith(u8, dial, "tcp!")) { @@ -1134,6 +1225,39 @@ test "a name that is not one path component is no address at all" { try testing.expect(socketPath(&buf, "/run", "a\x00b") == null); } +test "the startup sweep removes a dead pid's socket and leaves a live pid's, a named one and a plain file" { + if (comptime !supported) return error.SkipZigTest; + var dir_buf: [sun_path_len:0]u8 = undefined; + const base = socketDir(&dir_buf) orelse return error.SkipZigTest; + if (!ensureSocketDir(base)) return error.SkipZigTest; + var sub_buf: [sun_path_len:0]u8 = undefined; + const sub = std.fmt.bufPrintSentinel(&sub_buf, "{s}/deadsweep-{d}", .{ base, @as(u32, @intCast(libc.getpid())) }, 0) catch return error.SkipZigTest; + if (libc.mkdir(sub, 0o700) != 0) return error.SkipZigTest; + var paths: [4][sun_path_len:0]u8 = undefined; + // No process has this pid: kill(pid, 0) answers ESRCH. + const dead = try std.fmt.bufPrintSentinel(&paths[0], "{s}/" ++ prefix ++ "2147483647.sock", .{sub}, 0); + const live = try std.fmt.bufPrintSentinel(&paths[1], "{s}/" ++ prefix ++ "{d}.sock", .{ sub, @as(u32, @intCast(std.c.getppid())) }, 0); + const named = try std.fmt.bufPrintSentinel(&paths[2], "{s}/" ++ prefix ++ "work.sock", .{sub}, 0); + const plain = try std.fmt.bufPrintSentinel(&paths[3], "{s}/" ++ prefix ++ "2147483646.sock", .{sub}, 0); + defer { + for ([_][:0]const u8{ dead, live, named, plain }) |p| _ = libc.unlink(p); + _ = libc.rmdir(sub); + } + for ([_][:0]const u8{ dead, live, named }) |p| { + const fd = bindSocket(p); + if (fd < 0) return error.SkipZigTest; + _ = libc.close(fd); // closed: what a killed editor leaves + } + const fd = libc.open(plain, .{ .CREAT = true, .ACCMODE = .WRONLY }, @as(libc.mode_t, 0o600)); + if (fd < 0) return error.SkipZigTest; + _ = libc.close(fd); + sweepDeadSockets(testing.io, sub); + try testing.expect(statNoFollow(dead) == null); + try testing.expect(statNoFollow(live) != null); + try testing.expect(statNoFollow(named) != null); + try testing.expect(statNoFollow(plain) != null); +} + test "the registry sweep takes the dead entries and leaves everything else" { if (comptime !supported) return error.SkipZigTest; // Under the real runtime directory, because a sun_path is 108 bytes diff --git a/src/tty/tty.zig b/src/tty/tty.zig index eabbbe22..8d121b1f 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -1150,6 +1150,8 @@ const Killed = struct { _ = std.c.write(fd, reset, reset.len); _ = std.c.tcsetattr(fd, .FLUSH, &cooked); } + // The 9P socket goes too: a killed editor leaves none behind. + ninep_io.unlinkOwnSocket(); // SA_RESETHAND put the default back: the same signal, now fatal. _ = std.c.raise(sig); } @@ -6,6 +6,7 @@ import os import shutil import select import signal +import socket from pathlib import Path import subprocess import struct @@ -1413,13 +1414,25 @@ def test(binary, quic=False): # Killed with SIGTERM or SIGHUP, a tty editor puts its terminal # back (main screen, cooked mode) before it dies: a nested one # killed in a pane leaves that pane usable. + # ...and its 9P socket goes with it. One a dead editor left behind + # (no process has its pid) is swept at the next start; a live + # pid's, here this test's own, never is. + def unix_socket_file(path): + made = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + made.bind(str(path)) + made.close() for sig in (signal.SIGTERM, signal.SIGHUP): + dead_socket = root / 'pardes-9p-2147483647.sock' + live_socket = root / f'pardes-9p-{os.getpid()}.sock' + for path in (dead_socket, live_socket): + if not path.exists(): + unix_socket_file(path) master, slave = os.openpty() fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 80, 0, 0)) cooked = termios.tcgetattr(slave) env = {k: v for k, v in os.environ.items() if not k.startswith('PARDES_')} env.update(HOME=str(root), XDG_RUNTIME_DIR=str(root), TERM='xterm-256color', PARDES_NOTIME='1') - killed = subprocess.Popen([binary, '--tty', '--9p=killed', 'x.txt'], cwd=root, env=env, + killed = subprocess.Popen([binary, '--tty', 'x.txt'], cwd=root, env=env, stdin=slave, stdout=slave, stderr=subprocess.DEVNULL, start_new_session=True, preexec_fn=lambda: fcntl.ioctl(0, termios.TIOCSCTTY, 0)) @@ -1431,8 +1444,13 @@ def test(binary, quic=False): if select.select([master], [], [], .1)[0]: seen += os.read(master, 65536) time.sleep(.3) + own_socket = root / f'pardes-9p-{killed.pid}.sock' + assert own_socket.exists() + assert not dead_socket.exists() + assert live_socket.exists() killed.send_signal(sig) assert killed.wait(timeout=5) == -sig + assert not own_socket.exists(), own_socket while select.select([master], [], [], .2)[0]: try: chunk = os.read(master, 65536) |
