a73x

1b10e0de

fix: hosts rm is one read-modify-write for every spelling, and forgets every match

a73x   2026-08-28 19:53

Commit message
fix: hosts rm is one read-modify-write for every spelling, and forgets every match

src/cli/mux_main.zig
Old New
@@ -704,16 +704,20 @@ fn hostsEdit(
704 704
705 if (adding) return hostsAdd(arena, spellings.items, path); 705 if (adding) return hostsAdd(arena, spellings.items, path);
706 706
707 // One read-modify-write for the whole line, like `add`: an IO error on
708 // the third of four must not leave the first two applied.
709 const gone = try arena.alloc(bool, spellings.items.len);
710 @memset(gone, false);
711 hosts.forgetMany(arena, path, spellings.items, gone) catch |err| {
712 std.debug.print("mux hosts rm: {s}: {s}\n", .{ path, hosts.reason(err) });
713 return 1;
714 };
707 var rc: u8 = 0; 715 var rc: u8 = 0;
708 for (spellings.items) |s| { 716 for (spellings.items, gone) |s, g| {
709 const gone = hosts.forget(arena, path, s) catch |err| {
710 std.debug.print("mux hosts rm: {s}: {s}\n", .{ path, hosts.reason(err) });
711 return 1;
712 };
713 // Removing what is not there is reported and non-zero — a script 717 // Removing what is not there is reported and non-zero — a script
714 // that thinks it dropped a host should learn it was spelled 718 // that thinks it dropped a host should learn it was spelled
715 // differently. The rest of the line still applies. 719 // differently. The rest of the line still applies.
716 if (!gone) { 720 if (!g) {
717 std.debug.print("mux hosts rm: not on the wall: {s}\n", .{s}); 721 std.debug.print("mux hosts rm: not on the wall: {s}\n", .{s});
718 rc = 1; 722 rc = 1;
719 } 723 }
src/hosts.zig
Old New
@@ -95,20 +95,53 @@ pub fn record(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8)
95 return true; 95 return true;
96 } 96 }
97 97
98 /// Reads verbatim, not through `load`, so the one command whose job is 98 /// One spelling; `forgetMany` owns the rules.
99 /// removing a line can remove a hand-edited line the grammar refuses.
100 /// `record` keeps reading strictly: growing a file whose content is not
101 /// understood re-saves the garbage as if it had been read.
102 pub fn forget(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8) !bool { 99 pub fn forget(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8) !bool {
100 var gone = [_]bool{false};
101 try forgetMany(alloc, path, &.{spelling}, &gone);
102 return gone[0];
103 }
104
105 /// Out of the file, marking `gone[i]` for each spelling that matched.
106 pub fn forgetMany(
107 alloc: std.mem.Allocator,
108 path: []const u8,
109 spellings: []const []const u8,
110 gone: []bool,
111 ) !void {
112 // `gone` is the caller's, one slot per spelling, and is only ever set.
113 std.debug.assert(gone.len == spellings.len);
114 // Verbatim, not through `load`: the one command whose job is removing a
115 // line has to reach a hand-edited line the grammar refuses. `record`
116 // keeps reading strictly — growing a file whose content is not
117 // understood re-saves the garbage as if it had been read.
118 //
119 // ONE read-modify-write, because `hostsAdd`'s rule is the file's rule:
120 // an IO error on the third of four must not leave the first two
121 // applied. EVERY copy, because `load` folds duplicate lines into one
122 // wall entry — stopping at the first match let a `rm` exit 0, print
123 // nothing, and leave the host still polled.
103 var lines = try wall.loadLines(alloc, path); 124 var lines = try wall.loadLines(alloc, path);
104 defer wall.freeLines(alloc, &lines); 125 defer wall.freeLines(alloc, &lines);
105 for (lines.items, 0..) |l, i| { 126 var removed = false;
106 if (!std.mem.eql(u8, l, spelling)) continue; 127 var i: usize = 0;
128 while (i < lines.items.len) {
129 var hit = false;
130 for (spellings, gone) |s, *g| {
131 if (!std.mem.eql(u8, lines.items[i], s)) continue;
132 g.* = true;
133 hit = true;
134 }
135 if (!hit) {
136 i += 1;
137 continue;
138 }
107 alloc.free(lines.orderedRemove(i)); 139 alloc.free(lines.orderedRemove(i));
108 try wall.saveLines(lines.items, path); 140 removed = true;
109 return true;
110 } 141 }
111 return false; 142 // A save that changes nothing is still a write someone else could lose
143 // an update to — see the unlocked read-modify-write this file lives with.
144 if (removed) try wall.saveLines(lines.items, path);
112 } 145 }
113 146
114 pub fn statePath(alloc: std.mem.Allocator) ![]const u8 { 147 pub fn statePath(alloc: std.mem.Allocator) ![]const u8 {
@@ -283,6 +316,47 @@ test "hosts.forget removes a line strict load refuses; record still will not gro
283 try std.testing.expect(try record(alloc, path, "other")); 316 try std.testing.expect(try record(alloc, path, "other"));
284 } 317 }
285 318
319 test "hosts.forget removes EVERY copy, so a rm cannot report success and change nothing" {
320 // `load` folds duplicate lines into one wall entry, so a hand-edited
321 // file with `box` twice is ONE host. A forget that stopped at the first
322 // match exited 0, printed nothing, and left `mux` still polling box.
323 const alloc = std.testing.allocator;
324 var tmp = try TmpDir.make();
325 defer tmp.cleanup();
326 const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()});
327 defer alloc.free(path);
328 try wall.saveBytes(path, "box\nkeep\nbox\n");
329
330 try std.testing.expect(try forget(alloc, path, "box"));
331 var lines = try wall.loadLines(alloc, path);
332 defer wall.freeLines(alloc, &lines);
333 try std.testing.expectEqual(@as(usize, 1), lines.items.len);
334 try std.testing.expectEqualStrings("keep", lines.items[0]);
335 try std.testing.expect(!try forget(alloc, path, "box"));
336 }
337
338 test "hosts.forgetMany: several names leave in ONE read-modify-write" {
339 // `hostsAdd`'s rule, which the `rm` loop broke by calling `forget` once
340 // per spelling: an IO error on the third of four must not leave the
341 // first two applied. One load, one save, and every name's verdict comes
342 // back from the same pass.
343 const alloc = std.testing.allocator;
344 var tmp = try TmpDir.make();
345 defer tmp.cleanup();
346 const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()});
347 defer alloc.free(path);
348 try wall.saveBytes(path, "a\nb\nkeep\na\n");
349
350 var gone = [_]bool{ false, false, false };
351 try forgetMany(alloc, path, &.{ "a", "absent", "b" }, &gone);
352 try std.testing.expectEqualSlices(bool, &.{ true, false, true }, &gone);
353
354 var lines = try wall.loadLines(alloc, path);
355 defer wall.freeLines(alloc, &lines);
356 try std.testing.expectEqual(@as(usize, 1), lines.items.len);
357 try std.testing.expectEqualStrings("keep", lines.items[0]);
358 }
359
286 test "hosts.statePathFrom: XDG_STATE_HOME wins, HOME falls back, file is mux/hosts" { 360 test "hosts.statePathFrom: XDG_STATE_HOME wins, HOME falls back, file is mux/hosts" {
287 const alloc = std.testing.allocator; 361 const alloc = std.testing.allocator;
288 const a = try statePathFrom(alloc, "/x", "/h"); 362 const a = try statePathFrom(alloc, "/x", "/h");