1b10e0de
fix: hosts rm is one read-modify-write for every spelling, and forgets every match
a73x 2026-08-28 19:53
Commit message
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"); |