lib/windowing/src/wayland/seat/clipboard.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const sys = @import("sys");
  3 const native = @import("wayland");
  4 const windowing = @import("../../root.zig");
  5 
  6 const core = native.protocol.core;
  7 const runtime = native.runtime;
  8 
  9 pub const receive_timeout_ms: u32 = 1000;
 10 
 11 pub const Error = error{
 12     OutOfMemory,
 13     CreateFailed,
 14     SynchronizeFailed,
 15     TransferFailed,
 16     TransferTimedOut,
 17     TransferTooLarge,
 18 };
 19 
 20 pub const Synchronizer = struct {
 21     context: ?*anyopaque,
 22     run: *const fn (context: ?*anyopaque) anyerror!void,
 23 
 24     fn synchronize(self: Synchronizer) Error!void {
 25         self.run(self.context) catch return error.SynchronizeFailed;
 26     }
 27 };
 28 
 29 pub const Clipboard = struct {
 30     allocator: std.mem.Allocator,
 31     client: *runtime.Client,
 32     received_text_byte_count: usize,
 33     transfer_read: []u8,
 34     source: windowing.clipboard.Source,
 35     manager: ?u32 = null,
 36     device: ?u32 = null,
 37     source_id: ?u32 = null,
 38     offers: OfferSet = .{},
 39 
 40     pub fn init(
 41         allocator: std.mem.Allocator,
 42         client: *runtime.Client,
 43         capacity: windowing.clipboard.Capacity,
 44     ) std.mem.Allocator.Error!Clipboard {
 45         std.debug.assert(capacity.received_text_byte_count > 0);
 46         std.debug.assert(capacity.transfer_read_byte_count > 0);
 47         const transfer_read = try allocator.alloc(u8, capacity.transfer_read_byte_count);
 48         errdefer allocator.free(transfer_read);
 49         const source = try windowing.clipboard.Source.init(allocator, capacity);
 50         return .{
 51             .allocator = allocator,
 52             .client = client,
 53             .received_text_byte_count = capacity.received_text_byte_count,
 54             .transfer_read = transfer_read,
 55             .source = source,
 56         };
 57     }
 58 
 59     pub fn bindSeat(self: *Clipboard, manager: u32, seat: u32) !void {
 60         self.resetDevice();
 61         self.manager = manager;
 62         var created: [1]u32 = undefined;
 63         try self.client.request(
 64             manager,
 65             core.wl_data_device_manager.requests.get_data_device.opcode,
 66             &.{ .{ .new_id = .fixed }, .{ .object = seat } },
 67             &created,
 68         );
 69         self.device = created[0];
 70     }
 71 
 72     pub fn unbindSeat(self: *Clipboard) void {
 73         self.resetDevice();
 74     }
 75 
 76     pub fn dispatch(self: *Clipboard, event: *runtime.RoutedView) !bool {
 77         if (event.object_id == self.source_id) {
 78             try self.dispatchSource(event, true);
 79             return true;
 80         }
 81         if (event.object_id == self.device) {
 82             try self.dispatchDevice(event, true);
 83             return true;
 84         }
 85         if (self.offers.find(event.object_id)) |offer| {
 86             try self.dispatchOffer(offer, event);
 87             return true;
 88         }
 89         if (std.mem.eql(u8, event.interface.name, core.wl_data_device.metadata.name)) {
 90             try self.dispatchDevice(event, false);
 91             return true;
 92         }
 93         if (std.mem.eql(u8, event.interface.name, core.wl_data_source.metadata.name)) {
 94             try self.dispatchSource(event, false);
 95             return true;
 96         }
 97         return false;
 98     }
 99 
100     pub fn setText(
101         self: *Clipboard,
102         text: []const u8,
103         serial: u32,
104     ) windowing.clipboard.PublishError!void {
105         try self.source.replace(text);
106         self.destroySource(false);
107         const manager = self.manager orelse return error.Unavailable;
108         const device = self.device orelse return error.Unavailable;
109         var created: [1]u32 = undefined;
110         self.client.request(
111             manager,
112             core.wl_data_device_manager.requests.create_data_source.opcode,
113             &.{.{ .new_id = .fixed }},
114             &created,
115         ) catch return error.Unexpected;
116         const source_id = created[0];
117         for (text_mime_preference) |mime| {
118             self.client.request(
119                 source_id,
120                 core.wl_data_source.requests.offer.opcode,
121                 &.{.{ .string = mime }},
122                 &.{},
123             ) catch {
124                 self.destroyDetachedSource(source_id);
125                 return error.Unexpected;
126             };
127         }
128         self.client.request(
129             device,
130             core.wl_data_device.requests.set_selection.opcode,
131             &.{ .{ .object = source_id }, .{ .uint = serial } },
132             &.{},
133         ) catch {
134             self.destroyDetachedSource(source_id);
135             return error.Unexpected;
136         };
137         self.source_id = source_id;
138     }
139 
140     pub fn getTextAlloc(
141         self: *Clipboard,
142         output_allocator: std.mem.Allocator,
143         synchronizer: Synchronizer,
144     ) Error!?[]u8 {
145         const selection = self.offers.selection orelse return null;
146         const mime = selection.mimes.preferred() orelse return null;
147 
148         const descriptors = transferPipe() catch return error.TransferFailed;
149         defer sys.fd.close(descriptors[0]);
150         var write_owned = true;
151         defer if (write_owned) sys.fd.close(descriptors[1]);
152 
153         self.client.request(
154             selection.id,
155             core.wl_data_offer.requests.receive.opcode,
156             &.{ .{ .string = mime }, .{ .descriptor_owned = descriptors[1] } },
157             &.{},
158         ) catch return error.TransferFailed;
159         write_owned = false;
160         try synchronizer.synchronize();
161 
162         return try readBoundedAlloc(
163             output_allocator,
164             descriptors[0],
165             receive_timeout_ms,
166             self.received_text_byte_count,
167             self.transfer_read,
168         );
169     }
170 
171     pub fn deinit(self: *Clipboard) void {
172         self.resetDevice();
173         self.source.deinit();
174         self.allocator.free(self.transfer_read);
175         self.* = undefined;
176     }
177 
178     fn resetDevice(self: *Clipboard) void {
179         self.destroySource(true);
180         if (self.offers.takePending()) |offer| self.destroyDetachedOffer(offer, true);
181         if (self.offers.takeSelection()) |offer| self.destroyDetachedOffer(offer, true);
182         const device = self.device orelse return;
183         if (self.client.object(device)) |object| {
184             if (object.version >= core.wl_data_device.requests.release.since) {
185                 self.client.request(
186                     device,
187                     core.wl_data_device.requests.release.opcode,
188                     &.{},
189                     &.{},
190                 ) catch {};
191             }
192         }
193         self.device = null;
194         self.manager = null;
195     }
196 
197     fn destroySource(self: *Clipboard, clear: bool) void {
198         if (self.source_id) |source_id| self.destroyDetachedSource(source_id);
199         self.source_id = null;
200         if (clear) self.source.clear();
201     }
202 
203     fn destroyDetachedSource(self: *Clipboard, source_id: u32) void {
204         self.client.request(
205             source_id,
206             core.wl_data_source.requests.destroy.opcode,
207             &.{},
208             &.{},
209         ) catch {};
210     }
211 
212     fn introduceOffer(self: *Clipboard, id: u32) void {
213         if (self.offers.introduce(id)) |displaced| self.destroyDetachedOffer(displaced, true);
214     }
215 
216     fn replaceSelection(self: *Clipboard, id: ?u32) void {
217         const transition = self.offers.replaceSelection(id);
218         if (transition.displaced) |offer| self.destroyDetachedOffer(offer, true);
219         if (transition.orphaned) |offer| self.destroyDetachedOffer(offer, true);
220     }
221 
222     fn discardDragOffer(self: *Clipboard, id: ?u32) void {
223         if (self.offers.discardDrag(id)) |offer| self.destroyDetachedOffer(offer, true);
224     }
225 
226     fn destroyDetachedOffer(self: *Clipboard, offer: Offer, send_destroy: bool) void {
227         if (send_destroy) {
228             self.client.request(
229                 offer.id,
230                 core.wl_data_offer.requests.destroy.opcode,
231                 &.{},
232                 &.{},
233             ) catch {};
234         }
235     }
236 
237     fn dispatchDevice(self: *Clipboard, event: *runtime.RoutedView, active: bool) !void {
238         var decoder = try event.borrowedDecoder();
239         switch (event.metadata.opcode) {
240             core.wl_data_device.events.data_offer.opcode => {
241                 const offer_id = (try decoder.newId()).raw;
242                 try decoder.finish();
243                 if (active) {
244                     self.introduceOffer(offer_id);
245                 } else {
246                     self.client.request(
247                         offer_id,
248                         core.wl_data_offer.requests.destroy.opcode,
249                         &.{},
250                         &.{},
251                     ) catch {};
252                 }
253             },
254             core.wl_data_device.events.enter.opcode => {
255                 _ = try decoder.unsigned();
256                 _ = try decoder.object();
257                 _ = try decoder.fixed();
258                 _ = try decoder.fixed();
259                 const offer_id = if (try decoder.optionalObject()) |value| value.raw else null;
260                 try decoder.finish();
261                 if (active) self.discardDragOffer(offer_id);
262             },
263             core.wl_data_device.events.leave.opcode,
264             core.wl_data_device.events.drop.opcode,
265             => try decoder.finish(),
266             core.wl_data_device.events.motion.opcode => {
267                 _ = try decoder.unsigned();
268                 _ = try decoder.fixed();
269                 _ = try decoder.fixed();
270                 try decoder.finish();
271             },
272             core.wl_data_device.events.selection.opcode => {
273                 const offer_id = if (try decoder.optionalObject()) |value| value.raw else null;
274                 try decoder.finish();
275                 if (active) self.replaceSelection(offer_id);
276             },
277             else => return error.InvalidDataDeviceEvent,
278         }
279     }
280 
281     fn dispatchOffer(_: *Clipboard, offer: *Offer, event: *runtime.RoutedView) !void {
282         var decoder = try event.borrowedDecoder();
283         switch (event.metadata.opcode) {
284             core.wl_data_offer.events.offer.opcode => {
285                 const mime = try decoder.string();
286                 try decoder.finish();
287                 offer.mimes.add(mime);
288             },
289             core.wl_data_offer.events.source_actions.opcode,
290             core.wl_data_offer.events.action.opcode,
291             => {
292                 _ = try decoder.unsigned();
293                 try decoder.finish();
294             },
295             else => return error.InvalidDataOfferEvent,
296         }
297     }
298 
299     fn dispatchSource(self: *Clipboard, event: *runtime.RoutedView, active: bool) !void {
300         var decoder = try event.borrowedDecoder();
301         switch (event.metadata.opcode) {
302             core.wl_data_source.events.target.opcode => {
303                 _ = try decoder.optionalString();
304                 try decoder.finish();
305             },
306             core.wl_data_source.events.send.opcode => {
307                 const mime = try decoder.string();
308                 _ = try decoder.descriptor();
309                 try decoder.finish();
310                 const descriptor = try event.takeDescriptor(0);
311                 defer sys.fd.close(descriptor);
312                 if (active and isTextMime(mime)) writeSource(descriptor, self.source.text());
313             },
314             core.wl_data_source.events.cancelled.opcode => {
315                 try decoder.finish();
316                 if (active) self.destroySource(true);
317             },
318             core.wl_data_source.events.dnd_drop_performed.opcode,
319             core.wl_data_source.events.dnd_finished.opcode,
320             => try decoder.finish(),
321             core.wl_data_source.events.action.opcode => {
322                 _ = try decoder.unsigned();
323                 try decoder.finish();
324             },
325             else => return error.InvalidDataSourceEvent,
326         }
327     }
328 
329     pub fn status(self: *const Clipboard) windowing.clipboard.Status {
330         return self.source.status();
331     }
332 };
333 
334 const Offer = struct {
335     id: u32,
336     mimes: MimeTypes = .{},
337 };
338 
339 const SelectionTransition = struct {
340     displaced: ?Offer = null,
341     orphaned: ?Offer = null,
342 };
343 
344 const MimeTypes = struct {
345     present: [text_mime_preference.len]bool = @splat(false),
346 
347     fn add(self: *MimeTypes, mime: []const u8) void {
348         for (text_mime_preference, 0..) |candidate, index| {
349             if (std.mem.eql(u8, candidate, mime)) self.present[index] = true;
350         }
351     }
352 
353     fn preferred(self: *const MimeTypes) ?[:0]const u8 {
354         for (text_mime_preference, self.present) |candidate, present| {
355             if (present) return candidate;
356         }
357         return null;
358     }
359 };
360 
361 const OfferSet = struct {
362     pending: ?Offer = null,
363     selection: ?Offer = null,
364 
365     fn find(self: *OfferSet, id: u32) ?*Offer {
366         if (self.pending) |*offer| if (offer.id == id) return offer;
367         if (self.selection) |*offer| if (offer.id == id) return offer;
368         return null;
369     }
370 
371     fn introduce(self: *OfferSet, id: u32) ?Offer {
372         const pending = self.pending;
373         self.pending = .{ .id = id };
374         return pending;
375     }
376 
377     fn replaceSelection(self: *OfferSet, id: ?u32) SelectionTransition {
378         if (id) |value| {
379             if (self.selection) |offer| {
380                 if (offer.id == value) return .{};
381             }
382             if (self.pending) |offer| {
383                 if (offer.id == value) {
384                     const displaced = self.selection;
385                     self.selection = offer;
386                     self.pending = null;
387                     return .{ .displaced = displaced };
388                 }
389             }
390         }
391         const displaced = self.selection;
392         const orphaned = self.pending;
393         self.selection = null;
394         self.pending = null;
395         return .{ .displaced = displaced, .orphaned = orphaned };
396     }
397 
398     fn discardDrag(self: *OfferSet, id: ?u32) ?Offer {
399         const value = id orelse return null;
400         if (self.selection) |offer| {
401             if (offer.id == value) return null;
402         }
403         const pending = self.pending orelse return null;
404         if (pending.id != value) return null;
405         self.pending = null;
406         return pending;
407     }
408 
409     fn takePending(self: *OfferSet) ?Offer {
410         const offer = self.pending;
411         self.pending = null;
412         return offer;
413     }
414 
415     fn takeSelection(self: *OfferSet) ?Offer {
416         const offer = self.selection;
417         self.selection = null;
418         return offer;
419     }
420 };
421 
422 const text_mime_preference = [_][:0]const u8{
423     "text/plain;charset=utf-8",
424     "UTF8_STRING",
425     "text/plain",
426 };
427 
428 fn isTextMime(mime: []const u8) bool {
429     for (text_mime_preference) |candidate| {
430         if (std.mem.eql(u8, candidate, mime)) return true;
431     }
432     return false;
433 }
434 
435 fn writeSource(descriptor: sys.fd.Descriptor, bytes: []const u8) void {
436     var offset: usize = 0;
437     while (offset < bytes.len) {
438         const written = sys.fd.write(descriptor, bytes[offset..]) catch return;
439         if (written == 0) return;
440         offset += written;
441     }
442 }
443 
444 fn readBoundedAlloc(
445     allocator: std.mem.Allocator,
446     descriptor: sys.fd.Descriptor,
447     timeout_ms: u32,
448     max_bytes: usize,
449     scratch: []u8,
450 ) Error![]u8 {
451     std.debug.assert(max_bytes > 0);
452     std.debug.assert(scratch.len > 0);
453     var bytes: std.ArrayList(u8) = .empty;
454     errdefer bytes.deinit(allocator);
455 
456     const now = sys.time.nanoTimestamp();
457     const timeout_ns = @as(i128, timeout_ms) * std.time.ns_per_ms;
458     const deadline = now + timeout_ns;
459 
460     while (true) {
461         const remaining = deadline - sys.time.nanoTimestamp();
462         if (remaining <= 0) return error.TransferTimedOut;
463         const rounded_ms = @divFloor(remaining + std.time.ns_per_ms - 1, std.time.ns_per_ms);
464         const poll_timeout: i32 = @intCast(@min(rounded_ms, std.math.maxInt(i32)));
465         var poll_fds = [_]sys.poll.Descriptor{.{
466             .fd = descriptor,
467             .events = sys.poll.Event.input,
468             .revents = 0,
469         }};
470         const ready = sys.poll.wait(&poll_fds, poll_timeout) catch return error.TransferFailed;
471         if (ready == 0) return error.TransferTimedOut;
472         if ((poll_fds[0].revents & sys.poll.Event.invalid) != 0) return error.TransferFailed;
473 
474         while (true) {
475             const read_count = sys.fd.read(descriptor, scratch) catch |failure| switch (failure) {
476                 error.WouldBlock => break,
477                 else => return error.TransferFailed,
478             };
479             if (read_count == 0) return try bytes.toOwnedSlice(allocator);
480             if (read_count > max_bytes -| bytes.items.len) return error.TransferTooLarge;
481             try bytes.appendSlice(allocator, scratch[0..read_count]);
482         }
483     }
484 }
485 
486 fn transferPipe() ![2]sys.fd.Descriptor {
487     const descriptors = try sys.fd.pipeWithOptions(.{ .close_on_exec = true });
488     errdefer sys.fd.close(descriptors[0]);
489     errdefer sys.fd.close(descriptors[1]);
490     try sys.fd.setNonBlocking(descriptors[0]);
491     return descriptors;
492 }
493 
494 test "clipboard MIME preference is deterministic" {
495     var mimes: MimeTypes = .{};
496     mimes.add("text/plain");
497     mimes.add("application/octet-stream");
498     mimes.add("UTF8_STRING");
499     mimes.add("text/plain;charset=utf-8");
500     mimes.add("text/plain");
501     try std.testing.expectEqual([3]bool{ true, true, true }, mimes.present);
502     try std.testing.expectEqualStrings("text/plain;charset=utf-8", mimes.preferred().?);
503 }
504 
505 test "clipboard offers follow pending selection and discarded drag roles" {
506     var offers: OfferSet = .{};
507     try std.testing.expect(offers.introduce(10) == null);
508     offers.find(10).?.mimes.add("text/plain");
509     const first = offers.replaceSelection(10);
510     try std.testing.expect(first.displaced == null);
511     try std.testing.expect(first.orphaned == null);
512     try std.testing.expect(offers.pending == null);
513     try std.testing.expectEqual(@as(u32, 10), offers.selection.?.id);
514 
515     try std.testing.expect(offers.introduce(20) == null);
516     const second = offers.replaceSelection(20);
517     try std.testing.expectEqual(@as(u32, 10), second.displaced.?.id);
518     try std.testing.expect(second.orphaned == null);
519     try std.testing.expectEqual(@as(u32, 20), offers.selection.?.id);
520 
521     try std.testing.expect(offers.introduce(30) == null);
522     try std.testing.expectEqual(@as(u32, 30), offers.discardDrag(30).?.id);
523     try std.testing.expect(offers.pending == null);
524     try std.testing.expectEqual(@as(u32, 20), offers.selection.?.id);
525 
526     try std.testing.expect(offers.introduce(40) == null);
527     try std.testing.expectEqual(@as(u32, 40), offers.introduce(50).?.id);
528     const cleared = offers.replaceSelection(null);
529     try std.testing.expectEqual(@as(u32, 20), cleared.displaced.?.id);
530     try std.testing.expectEqual(@as(u32, 50), cleared.orphaned.?.id);
531     try std.testing.expect(offers.pending == null);
532     try std.testing.expect(offers.selection == null);
533 }
534 
535 test "clipboard initialization owns exact read and publication storage" {
536     const capacity = try windowing.clipboard.Capacity.derive(.{
537         .received_text_byte_count = 7,
538         .published_text_byte_count = 5,
539         .transfer_read_byte_count = 3,
540         .retained_wait_event_count = 2,
541     });
542     const client: *runtime.Client = undefined;
543     var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 0 });
544     try std.testing.expectError(
545         error.OutOfMemory,
546         Clipboard.init(failing.allocator(), client, capacity),
547     );
548 
549     failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 1 });
550     try std.testing.expectError(
551         error.OutOfMemory,
552         Clipboard.init(failing.allocator(), client, capacity),
553     );
554 
555     failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 2 });
556     var clipboard = try Clipboard.init(failing.allocator(), client, capacity);
557     defer clipboard.deinit();
558     try std.testing.expectEqual(@as(usize, 3), clipboard.transfer_read.len);
559     try std.testing.expectEqual(@as(usize, 7), clipboard.received_text_byte_count);
560     try std.testing.expectEqual(@as(usize, 5), clipboard.source.bytes.len);
561     try std.testing.expectEqual(@as(usize, 2), failing.allocations);
562 }
563 
564 test "clipboard data source send transfers its descriptor and exact retained bytes" {
565     const capacity = try windowing.clipboard.Capacity.derive(.{
566         .received_text_byte_count = 7,
567         .published_text_byte_count = 16,
568         .transfer_read_byte_count = 3,
569         .retained_wait_event_count = 2,
570     });
571     const client: *runtime.Client = undefined;
572     var clipboard = try Clipboard.init(std.testing.allocator, client, capacity);
573     defer clipboard.deinit();
574     try clipboard.source.replace("exact λ");
575     clipboard.source_id = 77;
576 
577     const descriptors = try transferPipe();
578     defer sys.fd.close(descriptors[0]);
579     var encoder = try native.protocol.value.Encoder.init(
580         std.testing.allocator,
581         native.protocol.value.default_encoder_capacity,
582     );
583     defer encoder.deinit();
584     try encoder.string(text_mime_preference[0]);
585     try encoder.descriptorBorrowed(descriptors[1]);
586     const encoded = try encoder.finish(&core.wl_data_source.events.send);
587     var routed_descriptors = [_]sys.fd.Descriptor{descriptors[1]};
588     var event: runtime.RoutedView = .{
589         .object_id = 77,
590         .interface = &core.wl_data_source.metadata,
591         .version = 3,
592         .metadata = encoded.metadata,
593         .payload = encoded.payload,
594         .descriptors = &routed_descriptors,
595     };
596     try std.testing.expect(try clipboard.dispatch(&event));
597     clipboard.source_id = null;
598     try std.testing.expectEqual(@as(sys.fd.Descriptor, -1), routed_descriptors[0]);
599 
600     var received: [16]u8 = undefined;
601     const received_count = try sys.fd.read(descriptors[0], &received);
602     try std.testing.expectEqualStrings("exact λ", received[0..received_count]);
603 }
604 
605 test "clipboard data source cancellation clears retained bytes" {
606     const capacity = try windowing.clipboard.Capacity.derive(.{
607         .received_text_byte_count = 7,
608         .published_text_byte_count = 16,
609         .transfer_read_byte_count = 3,
610         .retained_wait_event_count = 2,
611     });
612     const client: *runtime.Client = undefined;
613     var clipboard = try Clipboard.init(std.testing.allocator, client, capacity);
614     defer clipboard.deinit();
615     try clipboard.source.replace("retained");
616 
617     var no_descriptors: [0]sys.fd.Descriptor = .{};
618     var event: runtime.RoutedView = .{
619         .object_id = 78,
620         .interface = &core.wl_data_source.metadata,
621         .version = 3,
622         .metadata = &core.wl_data_source.events.cancelled,
623         .payload = &.{},
624         .descriptors = &no_descriptors,
625     };
626     try clipboard.dispatchSource(&event, true);
627     try std.testing.expectEqualStrings("", clipboard.source.text());
628 }
629 
630 test "clipboard transfer reader enforces its byte bound" {
631     const descriptors = try transferPipe();
632     defer sys.fd.close(descriptors[0]);
633     var write_open = true;
634     defer if (write_open) sys.fd.close(descriptors[1]);
635     try writeAll(descriptors[1], "large");
636     sys.fd.close(descriptors[1]);
637     write_open = false;
638     var scratch: [2]u8 = undefined;
639     try std.testing.expectError(
640         error.TransferTooLarge,
641         readBoundedAlloc(std.testing.allocator, descriptors[0], 100, 4, &scratch),
642     );
643 }
644 
645 test "clipboard transfer reader reuses nondefault scratch across reads" {
646     const descriptors = try transferPipe();
647     defer sys.fd.close(descriptors[0]);
648     var write_open = true;
649     defer if (write_open) sys.fd.close(descriptors[1]);
650     try writeAll(descriptors[1], "hello");
651     sys.fd.close(descriptors[1]);
652     write_open = false;
653     var scratch: [2]u8 = undefined;
654     const text = try readBoundedAlloc(std.testing.allocator, descriptors[0], 100, 5, &scratch);
655     defer std.testing.allocator.free(text);
656     try std.testing.expectEqualStrings("hello", text);
657 }
658 
659 test "clipboard transfer reader times out while a source stays silent" {
660     const descriptors = try transferPipe();
661     defer sys.fd.close(descriptors[0]);
662     defer sys.fd.close(descriptors[1]);
663     var scratch: [3]u8 = undefined;
664     try std.testing.expectError(
665         error.TransferTimedOut,
666         readBoundedAlloc(std.testing.allocator, descriptors[0], 5, 8, &scratch),
667     );
668 }
669 
670 fn writeAll(descriptor: sys.fd.Descriptor, bytes: []const u8) !void {
671     var offset: usize = 0;
672     while (offset < bytes.len) offset += try sys.fd.write(descriptor, bytes[offset..]);
673 }