summaryrefslogtreecommitdiff
path: root/src/file_watch.zig
blob: b616d48173b7bf9f121cc01b6c66c2eebfbac07f (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
//! Native file-watch state and the Linux 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.
const std = @import("std");
const builtin = @import("builtin");
const libc = std.c;
const linux = std.os.linux;
const pardes = @import("pardes.zig");
const look = @import("look.zig");
const message = @import("message.zig");

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 Linux directory mark. The descriptor belongs to the containing
/// directory because editor-style rename-over saves replace the file inode.
/// `generation` is the last snapshot the core accepted, not merely one a host
/// observed; serial prevents a reused pane slot from committing stale data.
pub const Watch = struct {
    wd: c_int,
    serial: u32,
    generation: Generation,
};

pub const Table = [pardes.MAX_PANES]?Watch;

/// 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 {
    if (comptime builtin.os.tag != .linux) return;
    if (fd < 0) return;
    if (watches[id]) |old| {
        var shared = false;
        for (watches, 0..) |other, i| {
            const candidate = other orelse continue;
            if (i != id and candidate.wd == old.wd) shared = true;
        }
        if (!shared) _ = libc.inotify_rm_watch(fd, old.wd);
        watches[id] = null;
    }
    const watched_path = path orelse return;
    const dir = 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;
    // CLOSE_WRITE coalesces one writer's writes; MOVED_TO and CREATE cover
    // rename-over and delete-then-recreate saves.
    const mask = linux.IN.CLOSE_WRITE | linux.IN.MOVED_TO | linux.IN.CREATE | linux.IN.ONLYDIR;
    const wd = libc.inotify_add_watch(fd, dir_buf[0..dir.len :0], mask);
    if (wd < 0) return;
    watches[id] = .{ .wd = wd, .serial = serial, .generation = generation };
}

/// 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,
    gpa: std.mem.Allocator,
    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;

    if (pane.file) |file| {
        const bytes = look.readFile(gpa, file.path) catch 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 => {},
        }
        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,
    gpa: std.mem.Allocator,
    fd: c_int,
    watches: *Table,
    id: u8,
    on: bool,
) 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);
    return on and watches[id] != null and reloadPane(core, io, gpa, 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..) |slot, id| {
        if (slot != null) retry = reloadPane(core, io, gpa, watches, id, true) or retry;
    }
    return retry;
}

pub fn identify(io: std.Io, path: []const u8) !Identity {
    const stat = try std.Io.Dir.cwd().statFile(io, path, .{});
    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 "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));
}