From 864407b240b812743d6f57a56daf9706b36fc027 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 15:14:00 -0300 Subject: pardes --wait for a new name waits on the pane it made, by serial, and a pane closed before it looks is done, not a failure fs.py's `pardes --wait 'wait new.txt'` failed two runs in three on the 0.0.8 merge. The stress case repeats the test's steps: 4 of 50 failed under load, each with "pardes: --wait: no pane shows that file". A new name's launch makes its pane, names it, then found it again by path in /index. The script had already seen the name, saved and removed the pane by then, so the lookup missed and --wait exited 1. Now the launch hands --wait the serial pane/new gave it. After a look, finding no pane on the file means that pane is already closed, which is the end --wait waits for: exit 0. The follow with its /index recheck covers a close in between, as before. Under the same load the stress case went 0 of 100. The same load showed a test race too, at fs.py's second Restore. The exec write is answered before the hang-up, but the client's clunk after it can meet the connection already cut. The test now requires the answer and tolerates the reset. Co-Authored-By: Claude Opus 5.5 --- src/main.zig | 21 ++++++++++++++------- test/fs.py | 13 +++++++++++-- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/main.zig b/src/main.zig index 576efc8e..859e6fb3 100644 --- a/src/main.zig +++ b/src/main.zig @@ -102,13 +102,18 @@ const help_text = /// pane `path` landed in (the one it already had, if open), then /log /// followed on one connection until that pane's `del` -- exit 0 -- or the /// connection ends with the session -- exit 1. -fn waitForDel(io: std.Io, gpa: std.mem.Allocator, dial: []const u8, path: []const u8) noreturn { - const first = ninep_io.Client.read(gpa, dial, "/index", "/index") catch std.process.exit(1); - const serial = paneShowing(first, path) orelse { - std.Io.File.stderr().writeStreamingAll(io, "pardes: --wait: no pane shows that file\n") catch {}; - std.process.exit(1); +fn waitForDel(io: std.Io, gpa: std.mem.Allocator, dial: []const u8, path: []const u8, known: ?u32) noreturn { + _ = io; + // The pane this launch made (a name not there yet) is known by its + // serial; one a look opened or went to is found by its file. Found by + // none after the look went there, it was closed already: that is the + // end being waited for, not a failure (a pane closed between the look + // and this read made `--wait` exit 1 before). + const serial = known orelse found: { + const first = ninep_io.Client.read(gpa, dial, "/index", "/index") catch std.process.exit(1); + defer gpa.free(first); + break :found paneShowing(first, path) orelse std.process.exit(0); }; - gpa.free(first); const Wait = struct { gpa: std.mem.Allocator, dial: []const u8, @@ -436,6 +441,7 @@ fn nativeMain(init: std.process.Init) !void { }; break :named std.fmt.bufPrint(&newbuf, "{s}/{s}", .{ std.mem.trimEnd(u8, dir.path, "/"), base }) catch |err| Refuse.with(init.io, word, err); }; + var made_serial: ?u32 = null; if (found_path != null) { var command_buf: [8192]u8 = undefined; const command = std.fmt.bufPrint(&command_buf, "{s}{s}\n", .{ path, word[target.path.len..] }) catch |err| Refuse.with(init.io, word, err); @@ -443,6 +449,7 @@ fn nativeMain(init: std.process.Init) !void { } else { const made = ninep_io.Client.read(arena, parent.dial, "/pane/new", "/pane/new") catch |err| Refuse.with(init.io, word, err); const serial = std.fmt.parseInt(u32, std.mem.trim(u8, made, " \n"), 10) catch |err| Refuse.with(init.io, word, err); + made_serial = serial; var name_buf: [64]u8 = undefined; const name = try std.fmt.bufPrint(&name_buf, "/pane/{d}/name", .{serial}); var line_buf: [4200]u8 = undefined; @@ -466,7 +473,7 @@ fn nativeMain(init: std.process.Init) !void { Refuse.with(init.io, word, err); }; } - if (wait) waitForDel(init.io, arena, parent.dial, path); + if (wait) waitForDel(init.io, arena, parent.dial, path, made_serial); return; } if (positional) |a| { diff --git a/test/fs.py b/test/fs.py index b02b9e45..3f11269a 100644 --- a/test/fs.py +++ b/test/fs.py @@ -1117,7 +1117,15 @@ def test(binary, quic=False): except OSError as refused: assert 'Modified' in str(refused), refused assert old.read('/pane/1/body') == b'changed after dump\n' - execute(old, control, 'Restore ' + str(saved)) + # Answered before the hang-up: the write's reply comes; the + # clunk after it may meet the connection already cut. + restore = old.open(f'/pane/{control}/exec', 1) + line = ('Restore ' + str(saved) + '\n').encode() + old.rpc(118, struct.pack(' Date: Wed, 30 Sep 2026 15:14:00 -0300 Subject: The 9P socket appears already listening: it is listened on under a name of its own and renamed into place A client that waits for the socket file and then connects, as fs.py's session helper and scripts do, was sometimes refused under load. The listener bound the socket at its final name, so the file existed a moment before listen(2), and a connect in that window got ECONNREFUSED. Now it listens under `.`, is chmodded, then renamed over the final name, so that name only ever names a listening socket. A final name held by a live listener is still refused, and a name with no room for the suffix is bound in place as before. The registry test checks the socket is listening where it appears and that the temporary name is gone. Co-Authored-By: Claude Opus 5.5 --- src/9p_io.zig | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/9p_io.zig b/src/9p_io.zig index 0eaa2d7e..e475416e 100644 --- a/src/9p_io.zig +++ b/src/9p_io.zig @@ -874,7 +874,29 @@ pub fn listen(io: std.Io, gpa: std.mem.Allocator, core: *pardes.Pardes, named: [ .greet_timeout_ms = Listener.greet_deadline_ms, }); const p = socketPath(&l.path_buf, dir, entry_name).?; - _ = l.runner.listen(.{ .unix = p }, max_conns) catch |err| retry: { + // Listened on under a name of its own, then renamed into place: a client + // that waits for the socket to appear finds it listening already. Bound + // at its own name, it was there a moment before listen(2), and a connect + // then was refused. (No room for the longer name: bound in place.) + var tmp_buf: [sun_path_len]u8 = undefined; + if (std.fmt.bufPrintSentinel(&tmp_buf, "{s}.{d}", .{ p, libc.getpid() }, 0) catch null) |tmp| { + const existing = @import("fs.zig").statPath(io, p, .{ .follow_symlinks = false }) catch null; + if (existing != null and (existing.?.kind != .unix_domain_socket or alive(p))) { + log.warn("something is already listening on {s}", .{p}); + l.deinit(gpa); + return null; + } + _ = libc.unlink(tmp); + _ = l.runner.listen(.{ .unix = tmp }, max_conns) catch { + l.deinit(gpa); + return null; + }; + if (libc.chmod(tmp, 0o600) != 0 or libc.rename(tmp, p) != 0) { + _ = libc.unlink(tmp); + l.deinit(gpa); + return null; + } + } else _ = l.runner.listen(.{ .unix = p }, max_conns) catch |err| retry: { const existing = @import("fs.zig").statPath(io, p, .{ .follow_symlinks = false }) catch null; if (err != error.AddressInUse or existing == null or existing.?.kind != .unix_domain_socket or alive(p)) { log.warn("something is already listening on {s}", .{p}); @@ -1338,6 +1360,11 @@ test "a listening editor posts itself into the 9P registry and unposts on stop" // The socket stays exactly where pardes has always bound it: // adopting the registry moves nothing, it only advertises. try testing.expect(statNoFollow(sock) != null); + // It appeared already listening, renamed into place from a name of + // its own, which is gone. + try testing.expect(alive(sock)); + var tmp_buf: [sun_path_len:0]u8 = undefined; + try testing.expect(statNoFollow(try std.fmt.bufPrintSentinel(&tmp_buf, "{s}.{d}", .{ sock, libc.getpid() }, 0)) == null); // And the registry holds a symlink to it one directory down, so // several editors group under /mnt/9p/pardes/ instead of // crowding the registry root — the layout zmx posts its -- cgit v1.3 From 31b7bcbae734cd1d0a432ff5acaf9c65bcd33897 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 15:26:58 -0300 Subject: A background job killed while the tty probe walks it is not the tty's owner, however far into exiting it is host_io's "a background job is not the tty's owner" failed now and then, 2 runs in 20 under load, always just after `kill %1`. The probe walks the shell's children and reads each one's exe. A killed background sleep, in the moment it is exiting (its exe link gone, not yet a zombie), has no exe to read and is not a zombie to skip, so the probe called the tty taken. A process outside the foreground process group holds nothing whatever state it is in, so one whose exe cannot be read is now skipped when its group is not the foreground one. Under the same load the test passed 50 runs in a row. The unit-test step's perf gate trips under that load, which is not this test. Co-Authored-By: Claude Opus 5.5 --- src/host_io.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/host_io.zig b/src/host_io.zig index c8c537b9..8cbdd2d4 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -1605,6 +1605,10 @@ pub fn ttyTaken(shell_pid: libc.pid_t, master_fd: c_int) bool { const exe = procExe(node.pid, &probe.exe) orelse { if (offTty(node.pid, &probe.blob)) continue; + // Exiting (its exe gone, not a zombie yet), or not ours + // to read: outside the foreground group it holds + // nothing, as a killed background job does for a moment. + if (pgrp) |g| if (g != fg) continue; return true; }; if (!std.mem.eql(u8, exe, self_exe)) { -- cgit v1.3