a73x

32c6ea16

Fix clipboard source transfer threading

a73x   2026-04-09 18:26


diff --git a/src/wayland.zig b/src/wayland.zig
index dcf5d2d..98e3014 100644
--- a/src/wayland.zig
+++ b/src/wayland.zig
@@ -326,6 +326,69 @@ const OwnedSelectionState = struct {
    }
};

const CancelSelectionDecision = struct {
    destroy_source: *wl.DataSource,
    cleared_active_selection: bool,
};

fn replaceSelectionSource(
    slot: *?*wl.DataSource,
    new_source: *wl.DataSource,
) ?*wl.DataSource {
    const old_source = slot.*;
    slot.* = new_source;
    return old_source;
}

fn cancelSelectionSource(
    slot: *?*wl.DataSource,
    source: *wl.DataSource,
) CancelSelectionDecision {
    if (slot.* == source) {
        slot.* = null;
        return .{
            .destroy_source = source,
            .cleared_active_selection = true,
        };
    }
    return .{
        .destroy_source = source,
        .cleared_active_selection = false,
    };
}

fn writeClipboardTransfer(payload: []const u8, fd: i32) !void {
    defer _ = c.close(fd);

    var written: usize = 0;
    while (written < payload.len) {
        const n = try posix.write(fd, payload[written..]);
        if (n == 0) return error.BrokenPipe;
        written += n;
    }
}

const ClipboardTransfer = struct {
    alloc: std.mem.Allocator,
    payload: []u8,
    fd: i32,

    fn init(alloc: std.mem.Allocator, payload: []const u8, fd: i32) !ClipboardTransfer {
        return .{
            .alloc = alloc,
            .payload = try alloc.dupe(u8, payload),
            .fd = fd,
        };
    }

    fn run(self: ClipboardTransfer) void {
        defer self.alloc.free(self.payload);
        writeClipboardTransfer(self.payload, self.fd) catch |err| {
            std.log.err("clipboard transfer failed: {any}", .{err});
        };
    }
};

pub const Clipboard = struct {
    alloc: std.mem.Allocator,
    display: *wl.Display,
@@ -399,37 +462,36 @@ pub const Clipboard = struct {
        source.offer("text/plain;charset=utf-8");
        source.offer("text/plain");

        if (self.selection_source) |old_source| old_source.destroy();
        self.selection_source = source;
        if (replaceSelectionSource(&self.selection_source, source)) |old_source| {
            old_source.destroy();
        }
        self.data_device.setSelection(source, serial);
        _ = self.display.flush();
    }

    fn handleCancelledSource(self: *Clipboard, source: *wl.DataSource) void {
        if (self.selection_source == source) {
            self.selection_source = null;
            source.destroy();
        const decision = cancelSelectionSource(&self.selection_source, source);
        if (decision.cleared_active_selection) {
            self.owned_selection.clear(self.alloc);
            return;
        }
        source.destroy();
        decision.destroy_source.destroy();
    }

    fn sendOwnedSelection(self: *Clipboard, mime_type: [*:0]const u8, fd: i32) void {
        defer _ = c.close(fd);

        if (!self.owned_selection.canServeMime(std.mem.span(mime_type))) return;
        const text = self.owned_selection.text orelse return;

        var written: usize = 0;
        while (written < text.len) {
            const n = posix.write(fd, text[written..]) catch |err| {
                std.log.err("clipboard write failed: {any}", .{err});
                return;
            };
            if (n == 0) return;
            written += n;
        }
        const transfer = ClipboardTransfer.init(self.alloc, text, fd) catch |err| {
            std.log.err("clipboard transfer setup failed: {any}", .{err});
            _ = c.close(fd);
            return;
        };
        const thread = std.Thread.spawn(.{}, ClipboardTransfer.run, .{transfer}) catch |err| {
            std.log.err("clipboard transfer thread spawn failed: {any}", .{err});
            self.alloc.free(transfer.payload);
            _ = c.close(fd);
            return;
        };
        thread.detach();
    }
};

@@ -874,6 +936,55 @@ test "OwnedSelectionState replaces text and recognizes offered mime types" {
    try std.testing.expectEqual(@as(u32, 0), state.serial);
}

test "replaceSelectionSource returns prior source to destroy" {
    const old_source: *wl.DataSource = @ptrFromInt(0x1000);
    const new_source: *wl.DataSource = @ptrFromInt(0x2000);

    var slot: ?*wl.DataSource = null;
    try std.testing.expect(replaceSelectionSource(&slot, old_source) == null);
    try std.testing.expect(slot == old_source);

    const replaced = replaceSelectionSource(&slot, new_source);
    try std.testing.expect(replaced == old_source);
    try std.testing.expect(slot == new_source);
}

test "cancelledSelectionSource clears active source and ignores stale source" {
    const stale_source: *wl.DataSource = @ptrFromInt(0x1000);
    const active_source: *wl.DataSource = @ptrFromInt(0x2000);

    var slot: ?*wl.DataSource = active_source;

    const stale = cancelSelectionSource(&slot, stale_source);
    try std.testing.expect(stale.destroy_source == stale_source);
    try std.testing.expect(!stale.cleared_active_selection);
    try std.testing.expect(slot == active_source);

    const active = cancelSelectionSource(&slot, active_source);
    try std.testing.expect(active.destroy_source == active_source);
    try std.testing.expect(active.cleared_active_selection);
    try std.testing.expect(slot == null);
}

test "writeClipboardTransfer writes payload and closes fd" {
    const pipefds = try std.posix.pipe();
    defer std.posix.close(pipefds[0]);

    try writeClipboardTransfer("clipboard payload", pipefds[1]);

    var out = std.ArrayList(u8).empty;
    defer out.deinit(std.testing.allocator);

    var buf: [64]u8 = undefined;
    while (true) {
        const n = try std.posix.read(pipefds[0], &buf);
        if (n == 0) break;
        try out.appendSlice(std.testing.allocator, buf[0..n]);
    }

    try std.testing.expectEqualStrings("clipboard payload", out.items);
}

fn wmBaseListener(wm_base: *xdg.WmBase, event: xdg.WmBase.Event, _: *xdg.WmBase) void {
    switch (event) {
        .ping => |p| wm_base.pong(p.serial),