summaryrefslogtreecommitdiff
path: root/src/file_watch.zig
blob: 46764a73b12ff31fe3017edbc2bcd9833f9f756b (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
//! Native file-watch state and the mark/reconcile transaction shared by the
//! TTY and SDL hosts. Their event loops still own blocking waits, wake
//! coalescing, and retry scheduling; this file owns the identical synchronous
//! operation each wake performs. Text buffers already own the bytes whose hash
//! is their generation. Large PDFs use metadata which changes for in-place and
//! rename-over saves, avoiding a second whole-document allocation.
//!
//! Two kernels, one transaction: inotify on linux, kqueue on macos. `init`,
//! `wait`, `stop`, `drain`, `markDir` and `unmarkDir` are the whole of that
//! difference, and nothing above them knows which one it is on.
const std = @import("std");
const builtin = @import("builtin");
const libc = std.c;
const linux = std.os.linux;
const pardes = @import("pardes.zig");
const colors = @import("colors.zig");
const filesystem = @import("fs.zig");
const message = pardes.Messages.Message;

pub const Identity = struct {
    inode: std.Io.File.INode,
    size: u64,
    mtime_ns: i96,
    ctime_ns: i96,

    pub fn eql(a: Identity, b: Identity) bool {
        return std.meta.eql(a, b);
    }
};

/// The last disk generation accepted by a native shell. Text panes already
/// need their bytes, so their exact content hash is cheapest. PDF panes reopen
/// the pathname directly and use metadata, avoiding a second whole-document
/// allocation merely to discover whether MuPDF should reopen it.
pub const Generation = union(enum) {
    text: u64,
    pdf: ?Identity,
};

/// One native directory mark, plus what macos needs beside it.
///
/// `wd` is an inotify wd on linux and an open EVTONLY directory descriptor on
/// macos. Either way it marks the CONTAINING directory, because editor-style
/// rename-over saves replace the file inode.
///
/// `file_wd` and `kq` exist only on macos, and only because a kqueue directory
/// filter reports changes to the DIRECTORY — entries added, removed, renamed —
/// and never a write to a file already inside it. inotify's CLOSE_WRITE covers
/// that from the directory mark; kqueue has no equivalent, so an in-place save
/// needs a second filter on the file itself. `kq` is the queue both filters
/// live on, kept here so `reloadPane` can re-arm the file filter after a
/// rename-over gave the pathname a new inode, without every caller between it
/// and a host having to carry the descriptor.
///
/// `generation` is the reconciled disk snapshot; Restore establishes this
/// baseline without replacing its saved buffer. Serial rejects reused slots.
pub const Watch = struct {
    wd: c_int,
    file_wd: c_int = -1,
    kq: c_int = -1,
    serial: u32,
    generation: Generation,
    /// The path last read while watched (its hash): its going away later is
    /// a deletion to say, where a path never there (a pane renamed to a new
    /// name) is not.
    existed: ?u64 = null,
};

/// The final slot is the one non-pane watched payload: the active ThemeFile.
/// Sharing the table also shares directory descriptors correctly when a user
/// happens to open that .zon as an ordinary document.
pub const theme_slot = pardes.MAX_PANES;
/// After it, one slot a post chain file (ShaderBuild.zig): marked only so
/// a save there wakes the host, which has the files read again; no
/// generation of theirs is kept here.
pub const shader_slot = theme_slot + 1;
pub const Table = [shader_slot + pardes.config.Runtime.Post.max]?Watch;

/// Whether this OS has a watcher at all. Off it every entry point below
/// returns the inert answer: `watchPath` installs nothing, so `watches` stays
/// empty and `reloadPane` returns false for every slot. The core then keeps its
/// own record of what was asked and simply never gets a `file_changed`, which
/// is what a host with no watcher has always done here.
pub const supported = builtin.os.tag == .linux or builtin.os.tag == .macos;

/// The wake filter's `ident` on macos. `ident` is a directory descriptor for
/// every other filter on this queue, and no descriptor is `maxInt(usize)`, so
/// nothing can collide with it.
const wake_ident: usize = std.math.maxInt(usize);

/// Create the descriptor a host waits on, and the ONE place the difference
/// between the two kernels is spelled.
///
/// linux: an inotify instance; a mark is a wd added to it.
/// macos: a kqueue; a mark is an EVFILT_VNODE filter whose `ident` is an open
/// directory descriptor, plus the one EVFILT_USER filter registered here that
/// `stop` triggers to end a parked `wait`.
///
/// `polled` is the only flag that survives the port. On linux it adds
/// IN_NONBLOCK, because a host that finds this descriptor ready in its own
/// `poll(2)` (detached/server.zig) must be able to stop draining it, while a
/// host that parks a thread in `wait` (tty.zig, gui.zig) wants the blocking
/// read. On macos neither host needs either flag: a kqueue is not inherited
/// across `fork(2)`, so there is no CLOEXEC to ask for, and whether a wait
/// blocks is the timeout argument to `kevent(2)` rather than a property of the
/// queue.
///
/// -1 when there is no watcher to make, which every entry point below reads as
/// "mark nothing".
pub fn init(polled: bool) c_int {
    switch (builtin.os.tag) {
        .linux => return libc.inotify_init1(if (polled)
            linux.IN.CLOEXEC | linux.IN.NONBLOCK
        else
            linux.IN.CLOEXEC),
        .macos => {
            const kq = libc.kqueue();
            if (kq < 0) return -1;
            const changes = [_]libc.Kevent{.{
                .ident = wake_ident,
                .filter = libc.EVFILT.USER,
                .flags = libc.EV.ADD | libc.EV.CLEAR,
                .fflags = 0,
                .data = 0,
                .udata = 0,
            }};
            var events: [1]libc.Kevent = undefined;
            if (libc.kevent(kq, &changes, 1, &events, 0, null) < 0) {
                _ = libc.close(kq);
                return -1;
            }
            return kq;
        },
        else => return -1,
    }
}

/// Park until a marked directory changes. False ends the watcher: `stop` was
/// called, or the queue died.
///
/// macos only, because it exists for the one thing a kqueue cannot do the way
/// an inotify descriptor can — be read. The linux hosts keep reading theirs
/// (tty.zig through `std.Io.File`, so teardown's `cancel` interrupts it), and
/// this is the shape that replaces that where there is nothing to read.
///
/// Deliberately does NOT report WHAT changed, for the reason the watcher
/// threads in tty.zig and gui.zig already give: the loop re-reads every marked
/// pane anyway.
pub fn wait(fd: c_int) bool {
    if (comptime builtin.os.tag != .macos) return false;
    if (fd < 0) return false;
    const none = [_]libc.Kevent{};
    var events: [8]libc.Kevent = undefined;
    while (true) {
        const n = libc.kevent(fd, &none, 0, &events, events.len, null);
        if (n < 0) {
            if (libc.errno(n) == .INTR) continue;
            return false;
        }
        if (n == 0) continue;
        for (events[0..@intCast(n)]) |event| {
            if (event.filter == libc.EVFILT.USER) return false;
        }
        return true;
    }
}

/// Wake a host parked in `wait` so it returns BEFORE the descriptor it is
/// parked on is closed. A no-op on linux, where cancelling the future reading
/// that descriptor is what ends the watcher.
pub fn stop(fd: c_int) void {
    if (comptime builtin.os.tag != .macos) return;
    if (fd < 0) return;
    const changes = [_]libc.Kevent{.{
        .ident = wake_ident,
        .filter = libc.EVFILT.USER,
        .flags = 0,
        .fflags = libc.NOTE.TRIGGER,
        .data = 0,
        .udata = 0,
    }};
    var events: [1]libc.Kevent = undefined;
    _ = libc.kevent(fd, &changes, 1, &events, 0, null);
}

/// Consume every pending edge without blocking, for a host that found the
/// descriptor ready in its own `poll(2)` rather than parking a thread on it.
/// True when at least one of them was a directory change.
///
/// DRAINED TO EMPTY on both kernels, and for the same reason: the descriptor is
/// level-triggered, so a queue left partly full makes the next `poll` return
/// ready immediately, and each of those rounds is a whole pump.
pub fn drain(fd: c_int) bool {
    if (fd < 0) return false;
    switch (builtin.os.tag) {
        .linux => {
            var buf: [4096]u8 = undefined;
            var seen = false;
            while (libc.read(fd, &buf, buf.len) > 0) seen = true;
            return seen;
        },
        .macos => {
            const none = [_]libc.Kevent{};
            const now: libc.timespec = .{ .sec = 0, .nsec = 0 };
            var events: [16]libc.Kevent = undefined;
            var seen = false;
            while (true) {
                const n = libc.kevent(fd, &none, 0, &events, events.len, &now);
                if (n <= 0) return seen;
                for (events[0..@intCast(n)]) |event| {
                    if (event.filter != libc.EVFILT.USER) seen = true;
                }
                if (n < events.len) return seen;
            }
        },
        else => return false,
    }
}

/// Install the kernel mark on `dir_z` and return its handle, or -1.
///
/// The handle is an inotify wd on linux and an open directory descriptor on
/// macos, and BOTH have to answer "the same directory twice is the same
/// handle": `watchPath`'s sharing loop compares handles to decide when the last
/// user of a mark is gone, and `Watch` keeps no path to compare instead.
/// inotify does that deduplication itself. On macos it is done here, by device
/// and inode, because each EVFILT_VNODE filter needs a descriptor of its own.
fn markDir(fd: c_int, dir_z: [:0]const u8, watches: *const Table) c_int {
    switch (builtin.os.tag) {
        .linux => {
            // CLOSE_WRITE coalesces one writer's writes; MOVED_TO and CREATE
            // cover rename-over and delete-then-recreate saves; DELETE and
            // MOVED_FROM a file going away, said at once (File.deleted), not
            // when something else next changes in the directory.
            const mask = linux.IN.CLOSE_WRITE | linux.IN.MOVED_TO | linux.IN.CREATE | linux.IN.DELETE | linux.IN.MOVED_FROM | linux.IN.ONLYDIR;
            return libc.inotify_add_watch(fd, dir_z, mask);
        },
        .macos => {
            // EVTONLY is the point of the descriptor: it marks the directory
            // without counting as a reference that would keep an unmounting
            // volume busy. DIRECTORY is inotify's ONLYDIR.
            const dir_fd = libc.open(dir_z, .{
                .ACCMODE = .RDONLY,
                .EVTONLY = true,
                .CLOEXEC = true,
                .DIRECTORY = true,
            }, @as(libc.mode_t, 0));
            if (dir_fd < 0) return -1;
            var want: libc.Stat = undefined;
            if (libc.fstat(dir_fd, &want) != 0) {
                _ = libc.close(dir_fd);
                return -1;
            }
            for (watches) |other| {
                const candidate = other orelse continue;
                var have: libc.Stat = undefined;
                if (libc.fstat(candidate.wd, &have) != 0) continue;
                if (have.dev != want.dev or have.ino != want.ino) continue;
                _ = libc.close(dir_fd);
                return candidate.wd;
            }
            // What a DIRECTORY filter reports is its entries changing, which
            // is a rename-over or a delete-then-recreate — MOVED_TO and CREATE
            // above. It does NOT report a write to a file already inside it,
            // which is what CLOSE_WRITE covers there; `markFile` is that half.
            // RENAME and DELETE here are the marked directory itself going
            // away.
            //
            // EV_CLEAR is not optional: without it the filter stays triggered
            // once it has fired and every `wait` returns immediately forever.
            const changes = [_]libc.Kevent{.{
                .ident = @intCast(dir_fd),
                .filter = libc.EVFILT.VNODE,
                .flags = libc.EV.ADD | libc.EV.CLEAR,
                .fflags = libc.NOTE.WRITE | libc.NOTE.RENAME | libc.NOTE.DELETE,
                .data = 0,
                .udata = 0,
            }};
            var events: [1]libc.Kevent = undefined;
            if (libc.kevent(fd, &changes, 1, &events, 0, null) < 0) {
                _ = libc.close(dir_fd);
                return -1;
            }
            return dir_fd;
        },
        else => return -1,
    }
}

/// The other half of a macos mark: the filter on the watched FILE, which is the
/// only thing that reports an in-place save. -1 when there is no file there yet
/// — a path that does not exist is still worth marking the directory for, and
/// the create arrives on that mark.
///
/// Not deduplicated, unlike the directory: two panes on the same file are two
/// slots that each want their own wake, and a file filter has exactly one user.
fn markFile(kq: c_int, path_z: [:0]const u8) c_int {
    if (comptime builtin.os.tag != .macos) return -1;
    const file_fd = libc.open(path_z, .{
        .ACCMODE = .RDONLY,
        .EVTONLY = true,
        .CLOEXEC = true,
    }, @as(libc.mode_t, 0));
    if (file_fd < 0) return -1;
    // EXTEND and ATTRIB beside WRITE because an append and a truncate-in-place
    // are both saves, and DELETE/RENAME because losing this inode is how a
    // rename-over reaches the pane whose file it replaced.
    const changes = [_]libc.Kevent{.{
        .ident = @intCast(file_fd),
        .filter = libc.EVFILT.VNODE,
        .flags = libc.EV.ADD | libc.EV.CLEAR,
        .fflags = libc.NOTE.WRITE | libc.NOTE.EXTEND | libc.NOTE.ATTRIB |
            libc.NOTE.RENAME | libc.NOTE.DELETE | libc.NOTE.REVOKE,
        .data = 0,
        .udata = 0,
    }};
    var events: [1]libc.Kevent = undefined;
    if (libc.kevent(kq, &changes, 1, &events, 0, null) < 0) {
        _ = libc.close(file_fd);
        return -1;
    }
    return file_fd;
}

/// Re-point a live mark's file filter at whatever the pathname is NOW. Called
/// from `reloadPane`, because a rename-over leaves the old filter on an inode
/// that no longer answers to this name and a later in-place save would then
/// wake nobody. A no-op off macos, where the directory mark covers both cases
/// by itself and there is no second filter to move.
fn remarkFile(live: *Watch, path: ?[]const u8) void {
    if (comptime builtin.os.tag != .macos) return;
    if (live.kq < 0) return;
    const watched = filesystem.localPath(path orelse return) orelse return;
    var path_buf: [4096:0]u8 = undefined;
    if (watched.len >= path_buf.len) return;
    @memcpy(path_buf[0..watched.len], watched);
    path_buf[watched.len] = 0;
    // Mark first, compare second. There is no `stat` in std.c on this target —
    // only `fstat` — and opening the path is what tells us both whether it is
    // there and which inode it is now.
    const fresh = markFile(live.kq, path_buf[0..watched.len :0]);
    if (fresh < 0) return; // nothing at that name now; keep whatever we had
    if (live.file_wd >= 0) {
        var have: libc.Stat = undefined;
        var want: libc.Stat = undefined;
        // Same inode as the filter already on it? Then that one still reports
        // this file and the registration just made is a duplicate to drop.
        if (libc.fstat(live.file_wd, &have) == 0 and libc.fstat(fresh, &want) == 0 and
            have.dev == want.dev and have.ino == want.ino)
        {
            _ = libc.close(fresh);
            return;
        }
        _ = libc.close(live.file_wd);
    }
    live.file_wd = fresh;
}

/// Drop one slot's marks. The DIRECTORY mark goes only when its last slot is
/// gone — several panes share it — while the file filter has exactly one user
/// and always goes. On macos a filter dies with the descriptor it was
/// registered against, so the close IS the removal.
fn unmark(fd: c_int, old: Watch, drop_dir: bool) void {
    if (comptime builtin.os.tag == .macos) {
        if (old.file_wd >= 0) _ = libc.close(old.file_wd);
    }
    if (!drop_dir) return;
    switch (builtin.os.tag) {
        .linux => _ = libc.inotify_rm_watch(fd, old.wd),
        .macos => _ = libc.close(old.wd),
        else => {},
    }
}

fn watchPath(
    fd: c_int,
    watches: *Table,
    slot: usize,
    path: ?[]const u8,
    serial: u32,
    generation: Generation,
) void {
    if (comptime !supported) return;
    if (fd < 0) return;
    if (watches[slot]) |old| {
        var shared = false;
        for (watches, 0..) |other, i| {
            const candidate = other orelse continue;
            if (i != slot and candidate.wd == old.wd) shared = true;
        }
        watches[slot] = null;
        unmark(fd, old, !shared);
    }
    const declared_path = path orelse return;
    const watched_path = filesystem.localPath(declared_path) orelse return;
    // The path may lie in a mount this editor serves, and marking it is a
    // stat and a lookup out there: the turn goes out with them.
    pardes.turn.yield();
    defer pardes.turn.back();
    const stat = @import("fs.zig").statPath(std.Io.Threaded.global_single_threaded.io(), watched_path, .{}) catch null;
    const dir = if (stat != null and stat.?.kind == .directory) watched_path else std.fs.path.dirname(watched_path) orelse ".";
    var dir_buf: [4096:0]u8 = undefined;
    if (dir.len >= dir_buf.len) return;
    @memcpy(dir_buf[0..dir.len], dir);
    dir_buf[dir.len] = 0;
    const wd = markDir(fd, dir_buf[0..dir.len :0], watches);
    if (wd < 0) return;
    watches[slot] = .{ .wd = wd, .kq = fd, .serial = serial, .generation = generation };
    // The file half, which only macos has and only for a path that exists yet.
    if (comptime builtin.os.tag == .macos) {
        if (watches[slot]) |*live| remarkFile(live, declared_path);
    }
}

/// Mark or unmark one pane pathname. Panes in the same directory share an
/// inotify descriptor, so removing one pane drops the kernel mark only after
/// its last user disappears.
pub fn watchPane(
    fd: c_int,
    watches: *Table,
    id: u8,
    path: ?[]const u8,
    serial: u32,
    generation: Generation,
) void {
    std.debug.assert(id < pardes.MAX_PANES);
    watchPath(fd, watches, id, path, serial, generation);
}

/// Mark the post chain files' directories, one slot each, dropping the
/// marks of files no longer in it.
pub fn watchShaders(fd: c_int, watches: *Table, paths: []const []const u8) void {
    for (0..pardes.config.Runtime.Post.max) |i|
        watchPath(fd, watches, shader_slot + i, if (i < paths.len) paths[i] else null, 0, .{ .text = 0 });
}

/// Reconcile one marked pane. A true result requests an immediate second pass:
/// the PDF pathname changed between the stat before MuPDF reopened it and the
/// stat after, so committing either identity would lose a generation.
pub fn reloadPane(
    core: *pardes.Pardes,
    io: std.Io,
    watches: *Table,
    id: usize,
    announce: bool,
) bool {
    const watched = watches[id] orelse return false;
    const pane = core.panes[id] orelse return false;
    if (pane.serial != watched.serial) return false;

    // Re-point the macos file filter at whatever this pathname is now, BEFORE
    // deciding anything: a rename-over left the old filter on a dead inode, and
    // the next in-place save would otherwise wake nobody. A no-op elsewhere,
    // and a no-op here too when the inode has not moved.
    if (comptime builtin.os.tag == .macos) {
        if (watches[id]) |*live| remarkFile(live, if (pane.file) |f| f.path else pane.pdfPath());
    }

    if (pane.file) |file| {
        const bytes = filesystem.read(core, file.path) catch |err| {
            // Gone from under the pane: said once, the text kept.
            if (err == error.FileNotFound and watched.existed == std.hash.Wyhash.hash(0, file.path)) pardes.panes.File.deleted(core, @intCast(id));
            return false;
        };
        if (watches[id]) |*live| live.existed = std.hash.Wyhash.hash(0, file.path);
        defer core.gpa.free(bytes);
        const hash = std.hash.Wyhash.hash(0, bytes);
        switch (watched.generation) {
            .text => |accepted| if (accepted == hash) return false,
            .pdf => {},
        }
        if (!core.reloadWatchedFile(@intCast(id), bytes)) return false;
        const current = core.panes[id] orelse return false;
        if (current.serial != watched.serial) return false;
        const current_file = current.file orelse return false;
        if (!std.mem.eql(u8, current_file.content, bytes)) return false;
        if (watches[id]) |*live| {
            if (live.serial == watched.serial)
                live.generation = .{ .text = hash };
        }
        if (announce) {
            var msg_buf: [256]u8 = undefined;
            core.setMessage(id, message.stamp(&msg_buf, "reloaded", current_file.path));
        }
        return false;
    }

    const path = pane.pdfPath() orelse return false;
    const before = identify(io, path) catch return false;
    switch (watched.generation) {
        .pdf => |accepted| if (accepted) |identity| if (identity.eql(before)) return false,
        .text => {},
    }
    if (!core.reloadWatchedFile(@intCast(id), &.{})) return false;
    const current = core.panes[id] orelse return false;
    if (current.serial != watched.serial) return false;
    const current_path = current.pdfPath() orelse return false;
    const after = identify(io, current_path) catch return true;
    if (!before.eql(after)) return true;
    if (watches[id]) |*live| {
        if (live.serial == watched.serial)
            live.generation = .{ .pdf = after };
    }
    if (announce) {
        var msg_buf: [256]u8 = undefined;
        core.setMessage(id, message.stamp(&msg_buf, "reloaded", current_path));
    }
    return false;
}

/// Install the directory mark before reconciling the open bytes/path. This
/// closes the drain-time race without requiring another filesystem event.
pub fn applyEffect(
    core: *pardes.Pardes,
    io: std.Io,
    fd: c_int,
    watches: *Table,
    id: u8,
    on: bool,
    mode: pardes.WatchMode,
) bool {
    var path: ?[]const u8 = null;
    var serial: u32 = 0;
    var generation: Generation = .{ .text = 0 };
    if (on) if (core.panes[id]) |pane| {
        serial = pane.serial;
        if (pane.file) |file| {
            path = file.path;
            generation = .{ .text = std.hash.Wyhash.hash(0, file.content) };
        } else if (pane.pdfPath()) |pdf_path| {
            path = pdf_path;
            generation = .{ .pdf = null };
        }
    };
    watchPane(fd, watches, id, path, serial, generation);
    if (on and mode == .baseline_disk) if (watches[id]) |*watched| {
        const pane = core.panes[id] orelse return false;
        const file = pane.file orelse return reloadPane(core, io, watches, id, false);
        const bytes = filesystem.read(core, file.path) catch return false;
        defer core.gpa.free(bytes);
        watched.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) };
        watched.existed = std.hash.Wyhash.hash(0, file.path);
        return false;
    };
    return on and watches[id] != null and reloadPane(core, io, watches, id, false);
}

/// Reconcile every mark after a coalesced directory wake.
pub fn reloadChanged(
    core: *pardes.Pardes,
    io: std.Io,
    gpa: std.mem.Allocator,
    watches: *Table,
) bool {
    var retry = false;
    for (watches[0..pardes.MAX_PANES], 0..) |slot, id| {
        if (slot != null) retry = reloadPane(core, io, watches, id, true) or retry;
    }
    if (watches[theme_slot] != null)
        retry = reloadTheme(core, gpa, watches, true) or retry;
    return retry;
}

/// Stop the old theme watch, synchronously load the requested snapshot, then
/// install its parent-directory mark and reconcile once to close the read ->
/// watch race. Returns true only when that reconciliation wants another pass.
pub fn applyThemeEffect(
    core: *pardes.Pardes,
    gpa: std.mem.Allocator,
    fd: c_int,
    watches: *Table,
    generation: u32,
    on: bool,
    animate: bool,
) bool {
    watchPath(fd, watches, theme_slot, null, 0, .{ .text = 0 });
    if (!on) return false;
    const request = colors.themeFileRequest(core, generation) orelse return false;
    // What fails here is the ThemeFile write's failure too, which waits on
    // this (the one-failure rule): late, as a Save's.
    const pane = request.pane;
    const bytes = filesystem.readFile(gpa, request.path) catch |err| {
        colors.failThemeFile(core, generation, err);
        core.noteLateFailure(pane);
        return false;
    };
    defer gpa.free(bytes);
    if (!colors.loadThemeFile(core, generation, bytes, animate)) {
        core.noteLateFailure(pane);
        return false;
    }
    const hash = std.hash.Wyhash.hash(0, bytes);
    watchPath(fd, watches, theme_slot, request.path, generation, .{ .text = hash });
    if (watches[theme_slot] == null) return false;
    return reloadTheme(core, gpa, watches, false);
}

/// Re-read the one active theme file after a coalesced directory edge. The
/// generation advances only after a valid parse, so a malformed save never
/// replaces the last good colors or blesses bad bytes as the new baseline.
pub fn reloadTheme(
    core: *pardes.Pardes,
    gpa: std.mem.Allocator,
    watches: *Table,
    announce: bool,
) bool {
    const watched = watches[theme_slot] orelse return false;
    const request = colors.themeFileRequest(core, watched.serial) orelse return false;
    // Same re-arm as reloadPane's, for the same reason: a .zon saved by rename
    // moves the inode the file filter is on.
    if (comptime builtin.os.tag == .macos) {
        if (watches[theme_slot]) |*entry| remarkFile(entry, request.path);
    }
    const bytes = filesystem.readFile(gpa, request.path) catch |err| {
        core.invalidate();
        colors.failThemeFile(core, watched.serial, err);
        return false;
    };
    defer gpa.free(bytes);
    const hash = std.hash.Wyhash.hash(0, bytes);
    switch (watched.generation) {
        .text => |accepted| if (accepted == hash) return false,
        .pdf => unreachable,
    }
    if (!colors.loadThemeFile(core, watched.serial, bytes, true)) return false;
    core.invalidate();
    const live = if (watches[theme_slot]) |*entry| entry else return false;
    if (live.serial != watched.serial) return false;
    live.generation = .{ .text = hash };
    if (announce) {
        var msg_buf: [256]u8 = undefined;
        core.setMessage(request.pane, message.stamp(&msg_buf, "reloaded theme", request.path));
    }
    return false;
}

pub fn identify(io: std.Io, path: []const u8) !Identity {
    const native = filesystem.localPath(path) orelse return error.NonLocalPath;
    pardes.turn.yield();
    defer pardes.turn.back();
    const stat = try @import("fs.zig").statPath(io, native, .{});
    if (stat.kind != .file) return error.NotFile;
    return .{
        .inode = stat.inode,
        .size = stat.size,
        .mtime_ns = stat.mtime.nanoseconds,
        .ctime_ns = stat.ctime.nanoseconds,
    };
}

test "restored file watches preserve snapshots and accept later disk changes" {
    if (comptime !supported or !filesystem.platform_has_fs) return;
    const io = std.testing.io;
    const gpa = std.testing.allocator;
    for ([_]struct { dirty: bool, exists: bool }{
        .{ .dirty = false, .exists = true },
        .{ .dirty = true, .exists = true },
        .{ .dirty = true, .exists = false },
    }) |case| {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        if (case.exists) try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "disk baseline\n" });
        var path_buf: [256]u8 = undefined;
        const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/watched", .{tmp.sub_path});
        const core = try pardes.Pardes.init(gpa, .{ .tty_only = true });
        defer core.deinit();
        while (core.nextEffect()) |_| {}
        const id: u8 = @intCast(core.freeSlot().?);
        const pane = try pardes.panes.File.restore(core, id, .{
            .kind = .file,
            .tag = "",
            .body = "",
            .file = .{ .path = path, .content = "restored snapshot\n", .dirty = case.dirty },
        });
        const fd = init(true);
        if (fd < 0) return error.NoWatcher;
        defer _ = libc.close(fd);
        var watches: Table = @splat(null);
        defer watchPane(fd, &watches, id, null, pane.serial, .{ .text = 0 });
        var armed = false;
        while (core.nextEffect()) |effect| switch (effect) {
            .watch => |watch| if (watch.pane == id and watch.on) {
                try std.testing.expectEqual(.baseline_disk, watch.mode);
                try std.testing.expect(!applyEffect(core, io, fd, &watches, id, watch.on, watch.mode));
                armed = true;
            },
            else => {},
        };
        try std.testing.expect(armed and watches[id] != null);
        // A clean pane's text is its file's, read at the Restore: a dump
        // that kept other text (an older dump) is not what comes back.
        const restored: []const u8 = if (case.dirty) "restored snapshot\n" else "disk baseline\n";
        try std.testing.expectEqualStrings(restored, pane.file.?.content);
        try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len);
        try std.testing.expectEqual(case.dirty, pane.file.?.revision != pane.file.?.saved_revision);
        _ = reloadChanged(core, io, gpa, &watches);
        try std.testing.expectEqualStrings(restored, pane.file.?.content);

        try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "later disk save\n" });
        _ = reloadChanged(core, io, gpa, &watches);
        // A snapshot restored with unsaved text keeps it: a later disk
        // change is said, never loaded over it (File.changed).
        if (case.dirty) {
            try std.testing.expectEqualStrings("restored snapshot\n", pane.file.?.content);
            try std.testing.expect(pane.file.?.disk_newer != null);
            continue;
        }
        try std.testing.expectEqualStrings("later disk save\n", pane.file.?.content);
        pardes.panes.File.undo(core, pane);
        try std.testing.expectEqualStrings(restored, pane.file.?.content);
        _ = reloadChanged(core, io, gpa, &watches);
        try std.testing.expectEqualStrings(restored, pane.file.?.content);
    }
}

test "ordinary file watches still reconcile changes between open and watch" {
    if (comptime !supported or !filesystem.platform_has_fs) return;
    const io = std.testing.io;
    const gpa = std.testing.allocator;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "opened\n" });
    var path_buf: [256]u8 = undefined;
    const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/watched", .{tmp.sub_path});
    const core = try pardes.Pardes.init(gpa, .{ .tty_only = true });
    defer core.deinit();
    while (core.nextEffect()) |_| {}
    const id: u8 = @intCast(core.freeSlot().?);
    const pane = try pardes.panes.File.open(core, id, path, 0);
    try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "changed before watch\n" });
    const fd = init(true);
    if (fd < 0) return error.NoWatcher;
    defer _ = libc.close(fd);
    var watches: Table = @splat(null);
    defer watchPane(fd, &watches, id, null, pane.serial, .{ .text = 0 });
    var armed = false;
    while (core.nextEffect()) |effect| switch (effect) {
        .watch => |watch| if (watch.pane == id and watch.on) {
            try std.testing.expectEqual(.reconcile, watch.mode);
            try std.testing.expect(!applyEffect(core, io, fd, &watches, id, watch.on, watch.mode));
            armed = true;
        },
        else => {},
    };
    try std.testing.expect(armed and watches[id] != null);
    try std.testing.expectEqualStrings("changed before watch\n", pane.file.?.content);
    pardes.panes.File.undo(core, pane);
    try std.testing.expectEqualStrings("opened\n", pane.file.?.content);
}

test "explicit OS directory watches refresh prefix-preserving listings" {
    if (comptime !supported or !filesystem.platform_has_fs) return;
    const gpa = std.testing.allocator;
    const core = try pardes.Pardes.init(gpa, .{ .tty_only = true });
    defer core.deinit();
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "first", .data = "first\n" });
    var directory_buf: [4096]u8 = undefined;
    const directory = directory_buf[0..try tmp.dir.realPath(std.testing.io, &directory_buf)];
    var path_buf: [4102]u8 = undefined;
    const path = try std.fmt.bufPrint(&path_buf, "/n/os{s}", .{directory});
    const pane_id = core.freeSlot().?;
    const pane = try pardes.panes.File.open(core, pane_id, path, 0);
    const fd = init(true);
    if (fd < 0) return error.NoWatcher;
    defer _ = libc.close(fd);
    var watches: Table = @splat(null);
    defer watchPane(fd, &watches, @intCast(pane_id), null, pane.serial, .{ .text = 0 });
    _ = applyEffect(core, std.testing.io, fd, &watches, @intCast(pane_id), true, .reconcile);
    try std.testing.expect(watches[pane_id] != null);
    try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "second", .data = "second\n" });
    _ = reloadChanged(core, std.testing.io, gpa, &watches);
    try std.testing.expect(std.mem.indexOf(u8, pane.file.?.content, "/second\n") != null);
    var rows = std.mem.tokenizeScalar(u8, pane.file.?.content, '\n');
    while (rows.next()) |row| {
        try std.testing.expect(std.mem.startsWith(u8, row, path));
        const bytes = try filesystem.read(core, row);
        defer gpa.free(bytes);
        try std.testing.expect(std.mem.endsWith(u8, bytes, "\n"));
    }
}

test "file identity changes for in-place and rename-over writes" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "a" });
    var path_buf: [256]u8 = undefined;
    const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/watched", .{tmp.sub_path});
    const before = try identify(io, path);
    try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "longer" });
    const after = try identify(io, path);
    try std.testing.expect(!before.eql(after));

    // The dominant editor save shape keeps size/content arbitrary but swaps
    // the inode. Keep this replacement the same length to prove identity does
    // not rely on size changing.
    try tmp.dir.writeFile(io, .{ .sub_path = "replacement", .data = "swap!!" });
    try tmp.dir.rename("replacement", tmp.dir, "watched", io);
    const replaced = try identify(io, path);
    try std.testing.expect(after.inode != replaced.inode);
    try std.testing.expect(!after.eql(replaced));
}

test "theme watch reloads valid ZON and keeps the last theme across a bad save" {
    if (comptime !supported or pardes.platform == .web) return;
    const io = std.testing.io;
    const gpa = std.testing.allocator;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    var base_buf: [std.fs.max_path_bytes]u8 = undefined;
    const base_len = try tmp.dir.realPath(io, &base_buf);
    const path = try std.fs.path.join(gpa, &.{ base_buf[0..base_len], "mine.zon" });
    defer gpa.free(path);

    var first = pardes.themes[0];
    first.name = "watched";
    first.bg = .{ 1, 2, 3 };
    var first_zon: std.Io.Writer.Allocating = .init(gpa);
    defer first_zon.deinit();
    try std.zon.stringify.serialize(first, .{ .whitespace = true }, &first_zon.writer);
    try tmp.dir.writeFile(io, .{ .sub_path = "mine.zon", .data = first_zon.written() });

    const core = try pardes.Pardes.init(gpa, .{ .tty_only = true });
    defer core.deinit();
    while (core.nextEffect()) |_| {}
    colors.requestThemeFile(core, 0, path);
    const request = while (core.nextEffect()) |effect| switch (effect) {
        .theme_file => |theme_file| break theme_file,
        else => {},
    } else return error.MissingThemeFileEffect;

    const fd = init(true);
    if (fd < 0) return error.WatchInitFailed;
    defer _ = libc.close(fd);
    var watches: Table = @splat(null);
    defer _ = applyThemeEffect(core, gpa, fd, &watches, 0, false, false);
    try std.testing.expect(!applyThemeEffect(
        core,
        gpa,
        fd,
        &watches,
        request.generation,
        true,
        false,
    ));
    try std.testing.expectEqual(@as(?[3]u8, .{ 1, 2, 3 }), core.theme().bg);
    try std.testing.expect(watches[theme_slot] != null);

    var second = first;
    second.bg = .{ 9, 8, 7 };
    var second_zon: std.Io.Writer.Allocating = .init(gpa);
    defer second_zon.deinit();
    try std.zon.stringify.serialize(second, .{ .whitespace = true }, &second_zon.writer);
    try tmp.dir.writeFile(io, .{ .sub_path = "mine.zon", .data = second_zon.written() });
    try std.testing.expect(!reloadTheme(core, gpa, &watches, false));
    try std.testing.expectEqual(@as(?[3]u8, .{ 9, 8, 7 }), core.theme().bg);

    try tmp.dir.writeFile(io, .{ .sub_path = "mine.zon", .data = ".{ .name = " });
    try std.testing.expect(!reloadTheme(core, gpa, &watches, false));
    try std.testing.expectEqual(@as(?[3]u8, .{ 9, 8, 7 }), core.theme().bg);
}

test "a watched file deleted wakes the watch at once, and its pane says so" {
    if (comptime !supported or !filesystem.platform_has_fs or builtin.os.tag != .linux) return;
    const io = std.testing.io;
    const gpa = std.testing.allocator;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "here\n" });
    var path_buf: [256]u8 = undefined;
    const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/watched", .{tmp.sub_path});
    const core = try pardes.Pardes.init(gpa, .{ .tty_only = true });
    defer core.deinit();
    while (core.nextEffect()) |_| {}
    const id: u8 = @intCast(core.freeSlot().?);
    const pane = try pardes.panes.File.open(core, id, path, 0);
    const fd = init(true);
    if (fd < 0) return error.NoWatcher;
    defer _ = libc.close(fd);
    var watches: Table = @splat(null);
    defer watchPane(fd, &watches, id, null, pane.serial, .{ .text = 0 });
    while (core.nextEffect()) |effect| switch (effect) {
        .watch => |watch| if (watch.pane == id and watch.on) {
            _ = applyEffect(core, io, fd, &watches, id, watch.on, watch.mode);
        },
        else => {},
    };
    _ = drain(fd);
    try tmp.dir.deleteFile(io, "watched");
    var pfd = [_]libc.pollfd{.{ .fd = fd, .events = libc.POLL.IN, .revents = 0 }};
    try std.testing.expect(libc.poll(&pfd, 1, 1000) > 0);
    _ = reloadChanged(core, io, gpa, &watches);
    try std.testing.expect(pane.file.?.disk_gone);
}