Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
name: CI

on:
push:
branches: [main]
pull_request:
schedule:
- cron: '0 0 * * 1'

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4

- name: Install Zig
run: |
curl -sL "https://ziglang.org/download/0.15.2/zig-x86_64-linux-0.15.2.tar.xz" | tar -xJ
echo "$PWD/zig-x86_64-linux-0.15.2" >> "$GITHUB_PATH"

- name: Format check
continue-on-error: true
run: zig fmt --check src/

- name: Build
run: zig build

- name: Test
run: zig build test

security-audit:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Zig Security Audit
env:
GH_EVENT: ${{ github.event_name }}
GH_BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
echo "## Zig Security Audit" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Pattern | Count | Level |" >> "$GITHUB_STEP_SUMMARY"
echo "|---------|-------|-------|" >> "$GITHUB_STEP_SUMMARY"

total=0

audit() {
local pattern="$1" level="$2" label="$3"
local count
count=$(grep -rn --include='*.zig' -e "$pattern" src/ 2>/dev/null | wc -l)
if [ "$count" -gt 0 ]; then
echo "| \`$label\` | $count | $level |" >> "$GITHUB_STEP_SUMMARY"
total=$((total + count))
fi
if [ "$GH_EVENT" = "pull_request" ] && [ -n "$GH_BASE_REF" ]; then
git diff --name-only "origin/$GH_BASE_REF" -- '*.zig' 2>/dev/null | while read -r file; do
[ -f "$file" ] || continue
grep -n "$pattern" "$file" 2>/dev/null | while IFS=: read -r line content; do
echo "::warning file=$file,line=$line::[$level] $label: $content"
done
done
fi
}

audit '@setRuntimeSafety\(false\)' 'Critical' '@setRuntimeSafety(false)'
audit '@ptrCast' 'Tracked' '@ptrCast'
audit '@ptrFromInt' 'Tracked' '@ptrFromInt'
audit '@intFromPtr' 'Tracked' '@intFromPtr'
audit '@alignCast' 'Tracked' '@alignCast'
audit 'catch unreachable' 'Review' 'catch unreachable'
audit 'orelse unreachable' 'Review' 'orelse unreachable'
audit '@cImport' 'Info' '@cImport'

echo "" >> "$GITHUB_STEP_SUMMARY"
echo "**Total: $total** patterns tracked" >> "$GITHUB_STEP_SUMMARY"
echo "_Not bugs — areas requiring careful review during changes._" >> "$GITHUB_STEP_SUMMARY"
2 changes: 1 addition & 1 deletion build.zig.zon
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.{
.name = .zplit,
.version = "0.1.0",
.version = "0.1.1",
.fingerprint = 0xbce5f8607c44d1ad,
.minimum_zig_version = "0.15.0",
.paths = .{ "build.zig", "build.zig.zon", "src" },
Expand Down
2 changes: 1 addition & 1 deletion src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,7 @@ pub fn main() !void {
return;
}
if (std.mem.eql(u8, subcmd, "--version") or std.mem.eql(u8, subcmd, "-v")) {
std.debug.print("zplit v0.1.0\n", .{});
std.debug.print("zplit v0.1.1\n", .{});
return;
}

Expand Down
2 changes: 1 addition & 1 deletion src/pane.zig
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ fn replaceNodeInParent(
}
}

fn firstLeafId(node: *const LayoutNode) PaneId {
pub fn firstLeafId(node: *const LayoutNode) PaneId {
return switch (node.*) {
.leaf => |l| l.id,
.split => |s| firstLeafId(s.first),
Expand Down
95 changes: 88 additions & 7 deletions src/server.zig
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ pub const ClientState = struct {
mode: mode_mod.Mode = .normal,
recv_buf: [RECV_BUF_SIZE]u8 = undefined,
recv_len: usize = 0,
write_failed: bool = false,
allocator: std.mem.Allocator,

pub fn init(allocator: std.mem.Allocator, id: u16, fd: posix.fd_t) !*ClientState {
Expand Down Expand Up @@ -347,7 +348,7 @@ pub const Server = struct {

if (tag == TAG_LISTEN) {
// Accept a new client connection
const new_fd = posix.accept(self.listen_fd, null, null, posix.SOCK.CLOEXEC) catch continue;
const new_fd = posix.accept(self.listen_fd, null, null, posix.SOCK.CLOEXEC | posix.SOCK.NONBLOCK) catch continue;
if (self.clients.count() >= MAX_CLIENTS) {
posix.close(new_fd);
continue;
Expand Down Expand Up @@ -448,6 +449,18 @@ pub const Server = struct {
cs.rows = hp.rows;
cs.screen.resize(hp.cols, hp.rows) catch {};
cs.screen.invalidate();
// Initialize active_panes for all existing tabs and pick a valid active_tab
var first_open_tab: ?u8 = null;
for (self.tab_manager.tabs, 0..) |maybe_tab, i| {
if (maybe_tab) |t| {
const tab_idx: u8 = @intCast(i);
cs.active_panes[tab_idx] = pane_mod.firstLeafId(t.pane_tree.root);
if (first_open_tab == null) first_open_tab = tab_idx;
}
}
if (first_open_tab) |tab_idx| {
cs.active_tab = tab_idx;
}
if (self.active_client == null) {
self.active_client = client_id;
}
Expand Down Expand Up @@ -557,15 +570,23 @@ pub const Server = struct {
const new_tab_idx = self.tab_manager.createTab(new_pane_id) catch return;
self.spawnPaneState(new_pane_id) catch return;
cs.active_tab = new_tab_idx;
cs.active_panes[new_tab_idx] = new_pane_id;
// Propagate new tab's focused pane to ALL clients
var it = self.clients.valueIterator();
while (it.next()) |other_cs| {
other_cs.*.active_panes[new_tab_idx] = new_pane_id;
}
self.invalidateAllClients();
self.composeAll();
},
.close_tab => {
self.destroyAllPanesInTab(cs.active_tab);
const nearest = self.tab_manager.closeTab(cs.active_tab) orelse return;
// Update ALL clients viewing the closed tab
// Reject closing the last tab before destroying any state
if (self.tab_manager.tabCount() <= 1) return;
const closed_tab = cs.active_tab;
// Clean up per-pane client state (scroll_offsets) before destroying
self.cleanupClientStateForTab(closed_tab);
self.destroyAllPanesInTab(closed_tab);
const nearest = self.tab_manager.closeTab(closed_tab) orelse return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Update ALL clients viewing the closed tab
var it = self.clients.valueIterator();
while (it.next()) |other_cs| {
if (other_cs.*.active_tab == closed_tab) {
Expand Down Expand Up @@ -822,6 +843,8 @@ pub const Server = struct {
while (it.next()) |cs| {
self.composeForClient(cs.*);
}
// Disconnect clients that failed to write (corrupted protocol stream)
self.sweepFailedClients();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// ── resizePtysForClient ──────────────────────────────────────────────
Expand Down Expand Up @@ -989,6 +1012,11 @@ pub const Server = struct {

// Swap buffers
cs.screen.swapBuffers();

// Disconnect clients that failed to write during this render
if (cs.write_failed) {
self.sweepFailedClients();
Comment on lines +1017 to +1018
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove in-loop client sweep from composeForClient

composeAll iterates self.clients via valueIterator() and calls composeForClient for each client, but this new block calls sweepFailedClients(), which disconnects/removes entries from the same hash map. Mutating std.AutoHashMap during an active iterator walk can invalidate the iterator, so a write failure in one client can cause skipped renders, incorrect iteration, or use-after-free behavior in that same compose pass. Since composeAll already performs a sweep after the loop, this per-client sweep should be deferred instead of running inside the iteration.

Useful? React with 👍 / 👎.

}
}

// ── sendDirtyRegionsTo ───────────────────────────────────────────────
Expand Down Expand Up @@ -1185,8 +1213,17 @@ pub const Server = struct {
const n = try protocol.encodeFrame(&buf, msg_type, payload);
var sent: usize = 0;
while (sent < n) {
const w = try posix.write(cs.fd, buf[sent..n]);
if (w == 0) return error.BrokenPipe;
const w = posix.write(cs.fd, buf[sent..n]) catch |err| {
// WouldBlock before any data sent = transient backpressure, skip frame
// WouldBlock after partial send = protocol stream corrupted, must disconnect
if (err == error.WouldBlock and sent == 0) return err;
cs.write_failed = true;
return err;
Comment on lines +1216 to +1221
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat nonblocking backpressure as retryable

Because accepted client sockets are now nonblocking, posix.write can return error.WouldBlock under normal transient backpressure, but this code marks write_failed for any write error and the client is disconnected on the next composeAll sweep. That means a healthy but briefly slow client can be dropped during output bursts (for example, large render updates), even though the connection is still valid. WouldBlock should be handled as a retry/queue condition rather than immediate protocol corruption.

Useful? React with 👍 / 👎.

};
Comment on lines +1216 to +1222
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The posix.write call on a non-blocking socket will return error.WouldBlock if the output buffer is full. While the intent is to disconnect stalled clients, treating WouldBlock as a fatal error in a tight while (sent < n) loop is very aggressive. If the client is only momentarily slow, this will disconnect them immediately. Consider if a partial write should be allowed to remain in a per-client buffer, or explicitly document that this server does not support buffering for slow clients and requires them to keep up with the render rate.

if (w == 0) {
cs.write_failed = true;
return error.BrokenPipe;
}
sent += w;
}
}
Expand All @@ -1213,6 +1250,50 @@ pub const Server = struct {
}
}

// ── sweepFailedClients ─────────────────────────────────────────────

/// Disconnect clients whose write_failed flag is set (protocol stream corrupted).
/// Safe to call after iteration since it collects IDs first.
fn sweepFailedClients(self: *Server) void {
var to_remove: [MAX_CLIENTS]u16 = undefined;
var count: u8 = 0;
var it = self.clients.iterator();
while (it.next()) |entry| {
if (entry.value_ptr.*.write_failed) {
if (count < MAX_CLIENTS) {
to_remove[count] = entry.key_ptr.*;
count += 1;
}
}
}
for (to_remove[0..count]) |cid| {
self.disconnectClient(cid);
}
}
Comment on lines +1257 to +1272
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The sweepFailedClients function uses a fixed-size stack array to_remove of size MAX_CLIENTS. While MAX_CLIENTS is currently small (8), this pattern is fragile if the limit is increased in the future. Additionally, since self.clients is a HashMap, the iteration order is undefined. If count were to reach MAX_CLIENTS (which it shouldn't given the current architecture), some failed clients might be missed in one pass. A more robust approach would be to use an ArrayList or simply perform the removal in a way that doesn't invalidate the iterator if the language/library supports it.

References
  1. Adhere to safe memory management and avoid potential stack overflows or logic errors when scaling constants. (link)


// ── cleanupClientStateForTab ────────────────────────────────────────

/// Remove scroll_offsets entries for all panes in the given tab from every client.
fn cleanupClientStateForTab(self: *Server, tab_idx: u8) void {
const tab = self.tab_manager.activeTab(tab_idx);
var cit = self.clients.valueIterator();
while (cit.next()) |client_cs| {
self.removeScrollOffsetsInNode(client_cs.*, tab.pane_tree.root);
}
}

fn removeScrollOffsetsInNode(self: *Server, cs_ptr: *ClientState, node: *pane_mod.LayoutNode) void {
switch (node.*) {
.leaf => |l| {
_ = cs_ptr.scroll_offsets.remove(l.id);
},
.split => |s| {
self.removeScrollOffsetsInNode(cs_ptr, s.first);
self.removeScrollOffsetsInNode(cs_ptr, s.second);
},
}
}

// ── destroyAllPanesInTab ─────────────────────────────────────────────

fn destroyAllPanesInTab(self: *Server, tab_idx: u8) void {
Expand Down
Loading