Skip to content

Commit 913078a

Browse files
midasdfclaude
andauthored
chore: update to zig 0.16 and zigzag v0.1.5 (#3)
* chore: update to zig 0.16 and zigzag v0.1.5 Migrates sshz to Zig 0.16's reworked I/O model, where filesystem, process, and network operations require an `Io` instance. The main entry point now takes `std.process.Init`, and `io` plus `environ_map` are threaded through ssh_config, meta, app, and checker. Notable changes: - main: `std.process.spawn(io, ...)` replaces `std.process.Child.init` - main: `std.Io.File.stdout()` replaces `std.fs.File{ .handle = ... }` - main: `init.minimal.args.toSlice(arena)` replaces `argsAlloc/argsFree` - ssh_config/meta: switch to `std.Io.Dir.cwd()` with buffered writers and `init.environ_map.get("HOME")` for env access - checker: use `std.Io.net.HostName.connect(io, port, .{})` for TCP status checks - views: `zz.Color.cyan`/`white` are now consts (not functions) - CI: install Zig 0.16.0 - build.zig.zon: bump zigzag to v0.1.5, set minimum_zig_version Note: the zigzag dependency hash needs to be regenerated with `zig fetch --save=zigzag <url>` against a real Zig 0.16 toolchain; the current value in build.zig.zon is the v0.1.2 hash and will be rejected at build time. * fix: correct readFileAlloc/rename param order for zig 0.16 - readFileAlloc takes (io, sub_path, gpa, limit) not (io, gpa, sub_path, limit) - rename uses method call style: dir.rename(old_path, new_dir, new_path, io) * fix: use ArrayList .empty and std.Io.Mutex for zig 0.16 - Unmanaged std.ArrayList no longer has default field values, so .{} initialization fails; switch to .empty - std.Thread.Mutex is gone; use std.Io.Mutex with lockUncancelable(io) and unlock(io) - ResultQueue now also takes an Io instance so its mutex can lock * fix: use std.Io.Timestamp.now() instead of removed std.time.timestamp * fix: complete migration to zig 0.16 - build and tests pass - build.zig.zon: use real v0.1.5 hash from zig fetch - app.zig: ArrayList(...).empty (not .{}) for new unmanaged ArrayList - checker.zig: pass .mode = .stream for HostName.connect - meta.zig/utils.zig/ssh_config.zig: route io through for the new std.Io.Timestamp.now(.real) clock API - tests/test_meta.zig: pass std.testing.io to recordConnection Verified locally: zig 0.16.0 builds the exe, all 29 tests pass, and zig fmt --check src/ is clean. * chore: remove accidentally committed zig-pkg cache, add to .gitignore * fix: address PR #3 review feedback - main: guard args[1][0] with len > 0 to avoid OOB on empty arg - checker: unlock mutex on toOwnedSlice OOM in joinAllThreads - checker: parse IPv4/IPv6 literals before HostName.init so bare IPv6 hosts no longer always report offline - ssh_config: createDirPath before openDir in writeFile so save succeeds when ~/.ssh is missing - ssh_config/meta: propagate dir/file errors instead of swallowing them in createDirPath/backupFile/rotateBackups * fix: address pre-existing bugs flagged in PR #3 review - ssh_config: Config now owns the raw file buffer so hosts/raw_lines slices remain valid after readFile (CodeRabbit critical: dangling reference on `parse` content) - meta: JSON-escape host names, tags, and port-forward fields when serialising so values containing `"`, `\\`, or control characters no longer corrupt meta.json (CodeRabbit major) - app.deleteSelectedHost: look up the config entry by name from the visible list instead of by display index, so sorting no longer causes the wrong host to be deleted (CodeRabbit critical) - app.saveForm: always call setTags so clearing the tags field in the edit form persists an empty list (CodeRabbit major) - app.connectToSelected: stop recording the connection in the TUI path; directConnect already records it after the TUI exits, so this was double-counting connect_count (CodeAnt major) * fix: maintain visible_indices so search/tag filters keep selected in sync Move filter logic from the renderer into Model.rebuildVisibleIndices. `selected` is now an index into `visible_indices` (the filtered display order), so navigation, edit ('e'), forward ('f'), delete ('d'), connect (Enter), and confirm-delete prompts all reference the same host the user actually sees highlighted, regardless of search text or tag filter. - add Model.visible_indices, rebuilt on rebuildHostList, cycleSortMode, cycleTagFilter, and every search-text mutation - moveDown bounds-check against visible_indices.items.len - views/host_list.zig drops matchesSearch/hasTag (now in app.zig) and iterates model.visible_indices directly * fix: deep-copy strings in ssh_config so addHost/removeHost don't leak `parse` previously returned a `Config` whose `Host` fields and `raw_lines` borrowed from the input buffer; `addHost` mixed in allocPrint'd lines, leaving `deinit` unable to tell which strings to free. The pre-existing leak forced one test to use `page_allocator` and skip `deinit` entirely. This change makes Config the sole owner of every string inside it: - `parse` deep-copies host names, host fields, and every raw line (with `errdefer` cleanup on partial OOM) - `setHostField` becomes fallible and dupes its value - `addHost` deep-copies its incoming `Host` and lines, then commits with a two-phase swap so a failed `toOwnedSlice` doesn't strand half-owned state on `config` - `removeHost` frees the dropped host's strings and the dropped `raw_lines` entries - `Config.deinit` walks hosts/lines and frees their strings via a shared `freeHostStrings` helper - `readFile` reverts to `defer free(content)` — no buffer transfer needed now that `parse` owns its copies - `Model.saveForm` no longer pre-dupes form values, since `addHost` now takes ownership; this also closes a leak when the form `name` field was empty - ssh_config tests for addHost switch to `std.testing.allocator` with `defer config.deinit(...)`, validating the ownership story with the leak detector --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: midasdf <midasdf@users.noreply.github.com>
1 parent af6f06a commit 913078a

15 files changed

Lines changed: 513 additions & 304 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ jobs:
1818

1919
- name: Install Zig
2020
run: |
21-
curl -sL "https://ziglang.org/download/0.15.2/zig-x86_64-linux-0.15.2.tar.xz" | tar -xJ
22-
echo "$PWD/zig-x86_64-linux-0.15.2" >> "$GITHUB_PATH"
21+
curl -sL "https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz" | tar -xJ
22+
echo "$PWD/zig-x86_64-linux-0.16.0" >> "$GITHUB_PATH"
2323
2424
- name: Format check
2525
continue-on-error: true

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
.zig-cache/
22
zig-out/
3+
zig-pkg/

build.zig.zon

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
.name = .sshz,
33
.version = "0.1.0",
44
.fingerprint = 0x138d7fea3306d410,
5+
.minimum_zig_version = "0.16.0",
56
.paths = .{ "build.zig", "build.zig.zon", "src" },
67
.dependencies = .{
78
.zigzag = .{
8-
.url = "https://github.com/meszmate/zigzag/archive/refs/tags/v0.1.2.tar.gz",
9-
.hash = "zigzag-0.1.0-YXwYS20gCwATYmA0IOAYQVHykNP--Dl7bKfp2UDE0K2I",
9+
.url = "https://github.com/meszmate/zigzag/archive/refs/tags/v0.1.5.tar.gz",
10+
.hash = "zigzag-0.1.2-YXwYS17aEQBlpxPETTrhY5leFh7vV0DpnXJbHogs4Lsv",
1011
},
1112
},
1213
}

src/app.zig

Lines changed: 136 additions & 53 deletions
Large diffs are not rendered by default.

src/checker.zig

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,27 +20,28 @@ pub const CheckRequest = struct {
2020
};
2121

2222
pub const ResultQueue = struct {
23-
mutex: std.Thread.Mutex = .{},
24-
results: std.ArrayList(CheckResult) = .{},
23+
mutex: std.Io.Mutex = std.Io.Mutex.init,
24+
results: std.ArrayList(CheckResult) = .empty,
2525
allocator: std.mem.Allocator,
26+
io: std.Io,
2627

27-
pub fn init(allocator: std.mem.Allocator) ResultQueue {
28-
return .{ .allocator = allocator };
28+
pub fn init(allocator: std.mem.Allocator, io: std.Io) ResultQueue {
29+
return .{ .allocator = allocator, .io = io };
2930
}
3031

3132
pub fn deinit(self: *ResultQueue) void {
3233
self.results.deinit(self.allocator);
3334
}
3435

3536
pub fn push(self: *ResultQueue, result: CheckResult) void {
36-
self.mutex.lock();
37-
defer self.mutex.unlock();
37+
self.mutex.lockUncancelable(self.io);
38+
defer self.mutex.unlock(self.io);
3839
self.results.append(self.allocator, result) catch {};
3940
}
4041

4142
pub fn drain(self: *ResultQueue, allocator: std.mem.Allocator) []CheckResult {
42-
self.mutex.lock();
43-
defer self.mutex.unlock();
43+
self.mutex.lockUncancelable(self.io);
44+
defer self.mutex.unlock(self.io);
4445
if (self.results.items.len == 0) return &.{};
4546
return self.results.toOwnedSlice(allocator) catch return &.{};
4647
}
@@ -51,19 +52,21 @@ pub const StatusChecker = struct {
5152
active_count: std.atomic.Value(u32),
5253
max_concurrent: u32 = 3,
5354
shutdown: std.atomic.Value(bool),
54-
threads: std.ArrayList(std.Thread) = .{},
55-
threads_mutex: std.Thread.Mutex = .{},
55+
threads: std.ArrayList(std.Thread) = .empty,
56+
threads_mutex: std.Io.Mutex = std.Io.Mutex.init,
5657
dispatcher_thread: ?std.Thread = null,
5758
generation: std.atomic.Value(u32),
5859
allocator: std.mem.Allocator,
60+
io: std.Io,
5961

60-
pub fn init(queue: *ResultQueue, allocator: std.mem.Allocator) StatusChecker {
62+
pub fn init(queue: *ResultQueue, allocator: std.mem.Allocator, io: std.Io) StatusChecker {
6163
return .{
6264
.queue = queue,
6365
.active_count = std.atomic.Value(u32).init(0),
6466
.shutdown = std.atomic.Value(bool).init(false),
6567
.generation = std.atomic.Value(u32).init(0),
6668
.allocator = allocator,
69+
.io = io,
6770
};
6871
}
6972

@@ -78,9 +81,12 @@ pub const StatusChecker = struct {
7881
}
7982

8083
fn joinAllThreads(self: *StatusChecker) void {
81-
self.threads_mutex.lock();
82-
const threads = self.threads.toOwnedSlice(self.allocator) catch return;
83-
self.threads_mutex.unlock();
84+
self.threads_mutex.lockUncancelable(self.io);
85+
const threads = self.threads.toOwnedSlice(self.allocator) catch {
86+
self.threads_mutex.unlock(self.io);
87+
return;
88+
};
89+
self.threads_mutex.unlock(self.io);
8490
for (threads) |t| t.join();
8591
self.allocator.free(threads);
8692
}
@@ -131,27 +137,36 @@ pub const StatusChecker = struct {
131137
_ = self.active_count.fetchSub(1, .release);
132138
continue;
133139
};
134-
self.threads_mutex.lock();
140+
self.threads_mutex.lockUncancelable(self.io);
135141
self.threads.append(self.allocator, thread) catch {
136-
self.threads_mutex.unlock();
142+
self.threads_mutex.unlock(self.io);
137143
thread.join();
138144
continue;
139145
};
140-
self.threads_mutex.unlock();
146+
self.threads_mutex.unlock(self.io);
141147
}
142148
}
143149

144150
fn checkWorker(self: *StatusChecker, host_index: usize, hostname: []const u8, port: u16, gen: u32) void {
145151
defer _ = self.active_count.fetchSub(1, .release);
146152
if (self.shutdown.load(.acquire)) return;
147153

148-
const status = tcpCheck(hostname, port);
154+
const status = tcpCheck(self.io, hostname, port);
149155
self.queue.push(.{ .host_index = host_index, .status = status, .generation = gen });
150156
}
151157
};
152158

153-
fn tcpCheck(hostname: []const u8, port: u16) HostStatus {
154-
const stream = std.net.tcpConnectToHost(std.heap.page_allocator, hostname, port) catch return .offline;
155-
stream.close();
159+
fn tcpCheck(io: std.Io, hostname: []const u8, port: u16) HostStatus {
160+
// Try IPv4/IPv6 literals first; HostName.init rejects ':' so a bare IPv6
161+
// address would otherwise always report offline.
162+
if (std.Io.net.IpAddress.parse(hostname, port)) |addr| {
163+
const stream = addr.connect(io, .{ .mode = .stream }) catch return .offline;
164+
stream.close(io);
165+
return .online;
166+
} else |_| {}
167+
168+
const host = std.Io.net.HostName.init(hostname) catch return .offline;
169+
const stream = host.connect(io, port, .{ .mode = .stream }) catch return .offline;
170+
stream.close(io);
156171
return .online;
157172
}

src/main.zig

Lines changed: 31 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,42 +5,40 @@ const app = @import("app");
55

66
const version = "0.1.0";
77

8-
pub fn main() !void {
9-
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
10-
defer _ = gpa.deinit();
11-
const allocator = gpa.allocator();
8+
pub fn main(init: std.process.Init) !void {
9+
const allocator = init.gpa;
10+
const arena_allocator = init.arena.allocator();
1211

13-
const args = try std.process.argsAlloc(allocator);
14-
defer std.process.argsFree(allocator, args);
12+
const args = try init.minimal.args.toSlice(arena_allocator);
1513

1614
if (args.len >= 2 and std.mem.eql(u8, args[1], "--help")) {
17-
printUsage();
15+
printUsage(init.io);
1816
return;
1917
}
2018

2119
if (args.len >= 2 and std.mem.eql(u8, args[1], "--version")) {
22-
const file = std.fs.File{ .handle = std.posix.STDOUT_FILENO };
20+
const file = std.Io.File.stdout();
2321
var buf: [256]u8 = undefined;
24-
var w = file.writer(&buf);
22+
var w = file.writer(init.io, &buf);
2523
try w.interface.print("sshz {s}\n", .{version});
2624
try w.interface.flush();
2725
return;
2826
}
2927

3028
// sshz <host> [command...] — direct connect
31-
if (args.len >= 2 and args[1][0] != '-') {
32-
try directConnect(allocator, args[1], args[2..]);
29+
if (args.len >= 2 and args[1].len > 0 and args[1][0] != '-') {
30+
try directConnect(allocator, init.io, init.environ_map, args[1], args[2..]);
3331
return;
3432
}
3533

3634
// sshz — launch TUI
37-
try launchTui(allocator);
35+
try launchTui(init);
3836
}
3937

40-
fn printUsage() void {
41-
const file = std.fs.File{ .handle = std.posix.STDOUT_FILENO };
38+
fn printUsage(io: std.Io) void {
39+
const file = std.Io.File.stdout();
4240
var buf: [4096]u8 = undefined;
43-
var w = file.writer(&buf);
41+
var w = file.writer(io, &buf);
4442
w.interface.print(
4543
\\SSHZ - SSH Connection Manager
4644
\\
@@ -55,8 +53,9 @@ fn printUsage() void {
5553
w.interface.flush() catch {};
5654
}
5755

58-
fn launchTui(allocator: std.mem.Allocator) !void {
59-
var program = try zz.Program(app.Model).init(allocator);
56+
fn launchTui(init: std.process.Init) !void {
57+
const allocator = init.gpa;
58+
var program = try zz.Program(app.Model).init(init.gpa, init.io, init.environ_map);
6059
try program.run();
6160

6261
// Grab connect_host before deinit frees it
@@ -73,39 +72,40 @@ fn launchTui(allocator: std.mem.Allocator) !void {
7372
program.deinit();
7473

7574
if (connect_host) |host_name| {
76-
try directConnect(allocator, host_name, &.{});
75+
try directConnect(allocator, init.io, init.environ_map, host_name, &.{});
7776
}
7877
}
7978

80-
fn directConnect(allocator: std.mem.Allocator, host_name: []const u8, extra_args: []const [:0]u8) !void {
79+
fn directConnect(allocator: std.mem.Allocator, io: std.Io, env: *const std.process.Environ.Map, host_name: []const u8, extra_args: []const [:0]const u8) !void {
8180
// Record connection in meta.json
82-
const meta_path = try meta_mod.defaultMetaPath(allocator);
81+
const meta_path = try meta_mod.defaultMetaPath(allocator, env);
8382
defer allocator.free(meta_path);
8483

85-
var store = try meta_mod.readFile(allocator, meta_path);
84+
var store = try meta_mod.readFile(allocator, io, meta_path);
8685
defer store.deinit(allocator);
8786

88-
try store.recordConnection(allocator, host_name);
89-
meta_mod.writeFile(allocator, &store, meta_path) catch {};
87+
try store.recordConnection(allocator, io, host_name);
88+
meta_mod.writeFile(allocator, io, &store, meta_path) catch {};
9089

9190
// Build args
92-
var argv: std.ArrayList([]const u8) = .{};
91+
var argv: std.ArrayList([]const u8) = .empty;
9392
defer argv.deinit(allocator);
9493

9594
try argv.append(allocator, "ssh");
9695
try argv.append(allocator, host_name);
9796
for (extra_args) |arg| try argv.append(allocator, arg);
9897

9998
// Exec ssh
100-
var child = std.process.Child.init(argv.items, allocator);
101-
child.stdin_behavior = .Inherit;
102-
child.stdout_behavior = .Inherit;
103-
child.stderr_behavior = .Inherit;
104-
try child.spawn();
105-
const term = try child.wait();
99+
var child = try std.process.spawn(io, .{
100+
.argv = argv.items,
101+
.stdin = .inherit,
102+
.stdout = .inherit,
103+
.stderr = .inherit,
104+
});
105+
const term = try child.wait(io);
106106

107107
switch (term) {
108-
.Exited => |code| if (code != 0) std.process.exit(code),
108+
.exited => |code| if (code != 0) std.process.exit(code),
109109
else => std.process.exit(1),
110110
}
111111
}

0 commit comments

Comments
 (0)