1//! An implementation of file-system watching based on the `FSEventStream` API in macOS.
2//! While macOS supports kqueue, it does not allow detecting changes to files without
3//! placing watches on each individual file, meaning FD limits are reached incredibly
4//! quickly. The File System Events API works differently: it implements *recursive*
5//! directory watches, managed by a system service. Rather than being in libc, the API is
6//! exposed by the CoreServices framework. To avoid a compile dependency on the framework
7//! bundle, we dynamically load CoreServices with `std.DynLib`.
8//!
9//! While the logic in this file *is* specialized to `std.Build.Watch`, efforts have been
10//! made to keep that specialization to a minimum. Other use cases could be served with
11//! relatively minimal modifications to the `watch_paths` field and its usages (in
12//! particular the `setPaths` function). We avoid using the global GCD dispatch queue in
13//! favour of creating our own and synchronizing with an explicit semaphore, meaning this
14//! logic is thread-safe and does not affect process-global state.
15//!
16//! In theory, this API is quite good at avoiding filesystem race conditions. In practice,
17//! the logic that would avoid them is currently disabled, because the build system kind
18//! of relies on them at the time of writing to avoid redundant work -- see the comment at
19//! the top of `wait` for details.
20const FsEvents = @This();
21
22const enable_debug_logs = false;
23
24core_services: std.DynLib,
25resolved_symbols: ResolvedSymbols,
26
27paths_arena: std.heap.ArenaAllocator.State,
28/// The roots of the recursive watches. FSEvents has relatively small limits on the number
29/// of watched paths, so this slice must not be too long. The paths themselves are allocated
30/// into `paths_arena`, but this slice is allocated into the GPA.
31watch_roots: [][:0]const u8,
32/// All of the paths being watched. Value is the set of steps which depend on the file/directory.
33/// Keys and values are in `paths_arena`, but this map is allocated into the GPA.
34watch_paths: std.array_hash_map.String([]const std.Build.Configuration.Step.Index),
35
36/// The semaphore we use to block the thread calling `wait` until the callback determines a relevant
37/// event has occurred. This is retained across `wait` calls for simplicity and efficiency.
38waiting_semaphore: dispatch.semaphore_t,
39/// This dispatch queue is created by us and executes serially. It exists exclusively to trigger the
40/// callbacks of the FSEventStream we create. This is not in use outside of `wait`, but is retained
41/// across `wait` calls for simplicity and efficiency.
42dispatch_queue: dispatch.queue_t,
43/// In theory, this field avoids race conditions. In practice, it is essentially unused at the time
44/// of writing. See the comment at the start of `wait` for details.
45since_event: FSEventStreamEventId,
46
47cwd_path: []const u8,
48
49must_reconfigure: bool,
50
51/// All of the symbols we pull from the `dlopen`ed CoreServices framework. If any of these symbols
52/// is not present, `init` will close the framework and return an error.
53const ResolvedSymbols = struct {
54 FSEventStreamCreate: *const fn (
55 allocator: CFAllocatorRef,
56 callback: FSEventStreamCallback,
57 ctx: ?*const FSEventStreamContext,
58 paths_to_watch: CFArrayRef,
59 since_when: FSEventStreamEventId,
60 latency: CFTimeInterval,
61 flags: FSEventStreamCreateFlags,
62 ) callconv(.c) FSEventStreamRef,
63 FSEventStreamSetDispatchQueue: *const fn (stream: FSEventStreamRef, queue: dispatch.queue_t) callconv(.c) void,
64 FSEventStreamStart: *const fn (stream: FSEventStreamRef) callconv(.c) bool,
65 FSEventStreamStop: *const fn (stream: FSEventStreamRef) callconv(.c) void,
66 FSEventStreamInvalidate: *const fn (stream: FSEventStreamRef) callconv(.c) void,
67 FSEventStreamRelease: *const fn (stream: FSEventStreamRef) callconv(.c) void,
68 FSEventStreamGetLatestEventId: *const fn (stream: ConstFSEventStreamRef) callconv(.c) FSEventStreamEventId,
69 FSEventsGetCurrentEventId: *const fn () callconv(.c) FSEventStreamEventId,
70 CFRelease: *const fn (cf: *const anyopaque) callconv(.c) void,
71 CFArrayCreate: *const fn (
72 allocator: CFAllocatorRef,
73 values: [*]const usize,
74 num_values: CFIndex,
75 call_backs: ?*const CFArrayCallBacks,
76 ) callconv(.c) CFArrayRef,
77 CFStringCreateWithCString: *const fn (
78 alloc: CFAllocatorRef,
79 c_str: [*:0]const u8,
80 encoding: CFStringEncoding,
81 ) callconv(.c) CFStringRef,
82 CFAllocatorCreate: *const fn (allocator: CFAllocatorRef, context: *const CFAllocatorContext) callconv(.c) CFAllocatorRef,
83 kCFAllocatorUseContext: *const CFAllocatorRef,
84};
85
86pub fn init(cwd_path: []const u8) error{ OpenFrameworkFailed, MissingCoreServicesSymbol, SystemResources }!FsEvents {
87 var core_services = std.DynLib.open("/System/Library/Frameworks/CoreServices.framework/CoreServices") catch
88 return error.OpenFrameworkFailed;
89 errdefer core_services.close();
90
91 var resolved_symbols: ResolvedSymbols = undefined;
92 const info = @typeInfo(ResolvedSymbols).@"struct";
93 inline for (info.field_names, info.field_types) |f_name, f_type| {
94 @field(resolved_symbols, f_name) = core_services.lookup(f_type, f_name) orelse return error.MissingCoreServicesSymbol;
95 }
96
97 return .{
98 .core_services = core_services,
99 .resolved_symbols = resolved_symbols,
100 .paths_arena = .{},
101 .watch_roots = &.{},
102 .watch_paths = .empty,
103 .waiting_semaphore = dispatch.semaphore_create(0) orelse return error.SystemResources,
104 .dispatch_queue = dispatch.queue_create("zig-watch", .SERIAL()) orelse return error.SystemResources,
105 // Not `.since_now`, because this means we can init `FsEvents` *before* we do work in order
106 // to notice any changes which happened during said work.
107 .since_event = resolved_symbols.FSEventsGetCurrentEventId(),
108 .cwd_path = cwd_path,
109 .must_reconfigure = false,
110 };
111}
112
113pub fn deinit(fse: *FsEvents, gpa: Allocator, io: Io) void {
114 _ = io;
115 fse.waiting_semaphore.as_object().release();
116 fse.dispatch_queue.as_object().release();
117 fse.core_services.close();
118
119 gpa.free(fse.watch_roots);
120 fse.watch_paths.deinit(gpa);
121 {
122 var paths_arena = fse.paths_arena.promote(gpa);
123 paths_arena.deinit();
124 }
125}
126
127pub fn setPaths(fse: *FsEvents, maker: *Maker, steps: []const std.Build.Configuration.Step.Index) !void {
128 const gpa = maker.gpa;
129
130 var paths_arena_instance = fse.paths_arena.promote(gpa);
131 defer fse.paths_arena = paths_arena_instance.state;
132 const paths_arena = paths_arena_instance.allocator();
133
134 var need_dirs: std.array_hash_map.String(void) = .empty;
135 defer need_dirs.deinit(gpa);
136
137 fse.watch_paths.clearRetainingCapacity();
138
139 // We take `step_index` by pointer for a slight memory optimization in a moment.
140 for (steps) |*step_index| {
141 const step = maker.stepByIndex(step_index.*);
142 for (step.inputs.table.keys(), step.inputs.table.values()) |path, *files| {
143 const resolved_dir = try std.fs.path.resolvePosix(paths_arena, &.{
144 fse.cwd_path, path.root_dir.path orelse ".", path.sub_path,
145 });
146 try need_dirs.put(gpa, resolved_dir, {});
147 for (files.items) |file_name| {
148 const watch_path = if (std.mem.eql(u8, file_name, "."))
149 resolved_dir
150 else
151 try std.fs.path.join(paths_arena, &.{ resolved_dir, file_name });
152 const gop = try fse.watch_paths.getOrPut(gpa, watch_path);
153 if (gop.found_existing) {
154 const old_steps = gop.value_ptr.*;
155 const new_steps = try paths_arena.alloc(std.Build.Configuration.Step.Index, old_steps.len + 1);
156 @memcpy(new_steps[0..old_steps.len], old_steps);
157 new_steps[old_steps.len] = step_index.*;
158 gop.value_ptr.* = new_steps;
159 } else {
160 // This is why we captured `step` by pointer! We can avoid allocating a slice of one
161 // step in the arena in the common case where a file is referenced by only one step.
162 gop.value_ptr.* = step_index[0..1];
163 }
164 }
165 }
166 }
167
168 {
169 // There's no point looking at directories inside other ones (e.g. "/foo" and "/foo/bar").
170 // To eliminate these, we'll re-add directories in order of path length with a redundancy check.
171 const old_dirs = try gpa.dupe([]const u8, need_dirs.keys());
172 defer gpa.free(old_dirs);
173 std.mem.sort([]const u8, old_dirs, {}, struct {
174 fn lessThan(ctx: void, a: []const u8, b: []const u8) bool {
175 ctx;
176 return std.mem.lessThan(u8, a, b);
177 }
178 }.lessThan);
179 need_dirs.clearRetainingCapacity();
180 for (old_dirs) |dir_path| {
181 var it: std.fs.path.ComponentIterator(.posix, u8) = .init(dir_path);
182 while (it.next()) |component| {
183 if (need_dirs.contains(component.path)) {
184 // this path is '/foo/bar/qux', but '/foo' or '/foo/bar' was already added
185 break;
186 }
187 } else {
188 need_dirs.putAssumeCapacityNoClobber(dir_path, {});
189 }
190 }
191 }
192
193 // `need_dirs` is now a set of directories to watch with no redundancy. In practice, this is very
194 // likely to have reduced it to a quite small set (e.g. it'll typically coalesce a full `src/`
195 // directory into one entry). However, the FSEventStream API has a fairly low undocumented limit
196 // on total watches (supposedly 4096), so we should handle the case where we exceed it. To be
197 // safe, because this API can be a little unpredictable, we'll cap ourselves a little *below*
198 // that known limit.
199 if (need_dirs.count() > 2048) {
200 // Fallback: watch the whole filesystem. This is excessive, but... it *works* :P
201 if (enable_debug_logs) watch_log.debug("too many dirs; recursively watching root", .{});
202 fse.watch_roots = try gpa.realloc(fse.watch_roots, 1);
203 fse.watch_roots[0] = "/";
204 } else {
205 fse.watch_roots = try gpa.realloc(fse.watch_roots, need_dirs.count());
206 for (fse.watch_roots, need_dirs.keys()) |*out, in| {
207 out.* = try paths_arena.dupeSentinel(u8, in, 0);
208 }
209 }
210 if (enable_debug_logs) {
211 watch_log.debug("watching {d} paths using {d} recursive watches:", .{ fse.watch_paths.count(), fse.watch_roots.len });
212 for (fse.watch_roots) |dir_path| {
213 watch_log.debug("- '{s}'", .{dir_path});
214 }
215 }
216}
217
218pub fn wait(fse: *FsEvents, maker: *Maker, timeout_ns: ?u64) error{ OutOfMemory, StartFailed, MustReconfigure }!Watch.WaitResult {
219 if (fse.watch_roots.len == 0) @panic("nothing to watch");
220 const gpa = maker.gpa;
221
222 const rs = fse.resolved_symbols;
223
224 // At the time of writing, using `since_event` in the obvious way causes redundant rebuilds
225 // to occur, because one step modifies a file which is an input to another step. The solution
226 // to this problem will probably be either:
227 //
228 // a) Don't include the output of one step as a watch input of another; only mark external
229 // files as watch inputs. Or...
230 //
231 // b) Note the current event ID when a step begins, and disregard events preceding that ID
232 // when considering whether to dirty that step in `eventCallback`.
233 //
234 // For now, to avoid the redundant rebuilds, we bypass this `since_event` mechanism. This does
235 // introduce race conditions, but the other `std.Build.Watch` implementations suffer from those
236 // too at the time of writing, so this is kind of expected.
237 fse.since_event = .since_now;
238
239 const cf_allocator = rs.CFAllocatorCreate(rs.kCFAllocatorUseContext.*, &.{
240 .version = 0,
241 .info = @constCast(&gpa),
242 .retain = null,
243 .release = null,
244 .copy_description = null,
245 .allocate = &cf_alloc_callbacks.allocate,
246 .reallocate = &cf_alloc_callbacks.reallocate,
247 .deallocate = &cf_alloc_callbacks.deallocate,
248 .preferred_size = null,
249 }) orelse return error.OutOfMemory;
250 defer rs.CFRelease(cf_allocator);
251
252 const cf_paths = try gpa.alloc(?CFStringRef, fse.watch_roots.len);
253 @memset(cf_paths, null);
254 defer {
255 for (cf_paths) |o| if (o) |p| rs.CFRelease(p);
256 gpa.free(cf_paths);
257 }
258 for (fse.watch_roots, cf_paths) |raw_path, *cf_path| {
259 cf_path.* = rs.CFStringCreateWithCString(cf_allocator, raw_path, .utf8);
260 }
261 const cf_paths_array = rs.CFArrayCreate(cf_allocator, @ptrCast(cf_paths), @intCast(cf_paths.len), null);
262 defer rs.CFRelease(cf_paths_array);
263
264 const callback_ctx: EventCallbackCtx = .{
265 .fse = fse,
266 .maker = maker,
267 };
268 const event_stream = rs.FSEventStreamCreate(
269 null,
270 &eventCallback,
271 &.{
272 .version = 0,
273 .info = @constCast(&callback_ctx),
274 .retain = null,
275 .release = null,
276 .copy_description = null,
277 },
278 cf_paths_array,
279 fse.since_event,
280 0.05, // 0.05s latency; higher values increase efficiency by coalescing more events
281 .{ .watch_root = true, .file_events = true },
282 );
283 defer rs.FSEventStreamRelease(event_stream);
284 rs.FSEventStreamSetDispatchQueue(event_stream, fse.dispatch_queue);
285 defer rs.FSEventStreamInvalidate(event_stream);
286 if (!rs.FSEventStreamStart(event_stream)) return error.StartFailed;
287 defer rs.FSEventStreamStop(event_stream);
288 const result = fse.waiting_semaphore.wait(timeout: {
289 const ns = timeout_ns orelse break :timeout .FOREVER;
290 break :timeout .time(.NOW, @intCast(ns));
291 });
292 if (fse.must_reconfigure) return error.MustReconfigure;
293 return switch (result) {
294 0 => .dirty,
295 else => .timeout,
296 };
297}
298
299const cf_alloc_callbacks = struct {
300 const log = std.log.scoped(.cf_alloc);
301 fn allocate(size: CFIndex, hint: CFOptionFlags, info: ?*const anyopaque) callconv(.c) ?*const anyopaque {
302 if (enable_debug_logs) log.debug("allocate {d}", .{size});
303 _ = hint;
304 const gpa: *const Allocator = @ptrCast(@alignCast(info));
305 const mem = gpa.alignedAlloc(u8, .of(usize), @intCast(size + @sizeOf(usize))) catch return null;
306 const metadata: *usize = @ptrCast(mem);
307 metadata.* = @intCast(size);
308 return mem[@sizeOf(usize)..].ptr;
309 }
310 fn reallocate(ptr: ?*anyopaque, new_size: CFIndex, hint: CFOptionFlags, info: ?*const anyopaque) callconv(.c) ?*const anyopaque {
311 if (enable_debug_logs) log.debug("reallocate @{*} {d}", .{ ptr, new_size });
312 _ = hint;
313 if (ptr == null or new_size == 0) return null; // not a bug: documentation explicitly states that realloc on NULL should return NULL
314 const gpa: *const Allocator = @ptrCast(@alignCast(info));
315 const old_base: [*]align(@alignOf(usize)) u8 = @alignCast(@as([*]u8, @ptrCast(ptr)) - @sizeOf(usize));
316 const old_size = @as(*const usize, @ptrCast(old_base)).*;
317 const old_mem = old_base[0 .. old_size + @sizeOf(usize)];
318 const new_mem = gpa.realloc(old_mem, @intCast(new_size + @sizeOf(usize))) catch return null;
319 const metadata: *usize = @ptrCast(new_mem);
320 metadata.* = @intCast(new_size);
321 return new_mem[@sizeOf(usize)..].ptr;
322 }
323 fn deallocate(ptr: *anyopaque, info: ?*const anyopaque) callconv(.c) void {
324 if (enable_debug_logs) log.debug("deallocate @{*}", .{ptr});
325 const gpa: *const Allocator = @ptrCast(@alignCast(info));
326 const old_base: [*]align(@alignOf(usize)) u8 = @alignCast(@as([*]u8, @ptrCast(ptr)) - @sizeOf(usize));
327 const old_size = @as(*const usize, @ptrCast(old_base)).*;
328 const old_mem = old_base[0 .. old_size + @sizeOf(usize)];
329 gpa.free(old_mem);
330 }
331};
332
333const EventCallbackCtx = struct {
334 fse: *FsEvents,
335 maker: *Maker,
336};
337
338fn eventCallback(
339 stream: ConstFSEventStreamRef,
340 client_callback_info: ?*anyopaque,
341 num_events: usize,
342 events_paths_ptr: *anyopaque,
343 events_flags_ptr: [*]const FSEventStreamEventFlags,
344 events_ids_ptr: [*]const FSEventStreamEventId,
345) callconv(.c) void {
346 const ctx: *const EventCallbackCtx = @ptrCast(@alignCast(client_callback_info));
347 const maker = ctx.maker;
348 const fse = ctx.fse;
349 const rs = fse.resolved_symbols;
350 const events_paths_ptr_casted: [*]const [*:0]const u8 = @ptrCast(@alignCast(events_paths_ptr));
351 const events_paths = events_paths_ptr_casted[0..num_events];
352 const events_ids = events_ids_ptr[0..num_events];
353 const events_flags = events_flags_ptr[0..num_events];
354 var any_dirty = false;
355 for (events_paths, events_ids, events_flags) |event_path_nts, event_id, event_flags| {
356 _ = event_id;
357 if (event_flags.history_done) continue; // sentinel
358 const event_path = std.mem.span(event_path_nts);
359 switch (event_flags.must_scan_sub_dirs) {
360 false => {
361 if (fse.watch_paths.get(event_path)) |steps| {
362 assert(steps.len > 0);
363 if (invalidateSteps(maker, steps) catch |err| switch (err) {
364 error.MustReconfigure => {
365 fse.must_reconfigure = true;
366 break;
367 },
368 }) any_dirty = true;
369 }
370 if (std.fs.path.dirname(event_path)) |event_dirname| {
371 // Modifying '/foo/bar' triggers the watch on '/foo'.
372 if (fse.watch_paths.get(event_dirname)) |steps| {
373 assert(steps.len > 0);
374 if (invalidateSteps(maker, steps) catch |err| switch (err) {
375 error.MustReconfigure => {
376 fse.must_reconfigure = true;
377 break;
378 },
379 }) any_dirty = true;
380 }
381 }
382 },
383 true => {
384 // This is unlikely, but can occasionally happen when bottlenecked: events have been
385 // coalesced into one. We want to see if any of these events are actually relevant
386 // to us. The only way we can reasonably do that in this rare edge case is iterate
387 // the watch paths and see if any is under this directory. That's acceptable because
388 // we would otherwise kick off a rebuild which would be clearing those paths anyway.
389 const changed_path = std.fs.path.dirname(event_path) orelse event_path;
390 for (fse.watch_paths.keys(), fse.watch_paths.values()) |watching_path, steps| {
391 if (dirStartsWith(watching_path, changed_path)) {
392 if (invalidateSteps(maker, steps) catch |err| switch (err) {
393 error.MustReconfigure => {
394 fse.must_reconfigure = true;
395 break;
396 },
397 }) any_dirty = true;
398 }
399 }
400 },
401 }
402 }
403 if (any_dirty or fse.must_reconfigure) {
404 fse.since_event = rs.FSEventStreamGetLatestEventId(stream);
405 _ = fse.waiting_semaphore.signal();
406 }
407}
408fn dirStartsWith(path: []const u8, prefix: []const u8) bool {
409 if (std.mem.eql(u8, path, prefix)) return true;
410 if (!std.mem.startsWith(u8, path, prefix)) return false;
411 if (path[prefix.len] != '/') return false; // `path` is `/foo/barx`, `prefix` is `/foo/bar`
412 return true; // `path` is `/foo/bar/...`, `prefix` is `/foo/bar`
413}
414
415fn invalidateSteps(maker: *Maker, steps: []const std.Build.Configuration.Step.Index) !bool {
416 var any_dirty = false;
417 for (steps) |step_index| {
418 const step = maker.stepByIndex(step_index);
419 if (try maker.invalidateResult(step)) any_dirty = true;
420 }
421 return any_dirty;
422}
423
424const CFAllocatorRef = ?*const opaque {};
425const CFArrayRef = *const opaque {};
426const CFStringRef = *const opaque {};
427const CFTimeInterval = f64;
428const CFIndex = i32;
429const CFOptionFlags = enum(u32) { _ };
430const CFAllocatorRetainCallBack = *const fn (info: ?*const anyopaque) callconv(.c) *const anyopaque;
431const CFAllocatorReleaseCallBack = *const fn (info: ?*const anyopaque) callconv(.c) void;
432const CFAllocatorCopyDescriptionCallBack = *const fn (info: ?*const anyopaque) callconv(.c) CFStringRef;
433const CFAllocatorAllocateCallBack = *const fn (alloc_size: CFIndex, hint: CFOptionFlags, info: ?*const anyopaque) callconv(.c) ?*const anyopaque;
434const CFAllocatorReallocateCallBack = *const fn (ptr: ?*anyopaque, new_size: CFIndex, hint: CFOptionFlags, info: ?*const anyopaque) callconv(.c) ?*const anyopaque;
435const CFAllocatorDeallocateCallBack = *const fn (ptr: *anyopaque, info: ?*const anyopaque) callconv(.c) void;
436const CFAllocatorPreferredSizeCallBack = *const fn (size: CFIndex, hint: CFOptionFlags, info: ?*const anyopaque) callconv(.c) CFIndex;
437const CFAllocatorContext = extern struct {
438 version: CFIndex,
439 info: ?*anyopaque,
440 retain: ?CFAllocatorRetainCallBack,
441 release: ?CFAllocatorReleaseCallBack,
442 copy_description: ?CFAllocatorCopyDescriptionCallBack,
443 allocate: CFAllocatorAllocateCallBack,
444 reallocate: ?CFAllocatorReallocateCallBack,
445 deallocate: ?CFAllocatorDeallocateCallBack,
446 preferred_size: ?CFAllocatorPreferredSizeCallBack,
447};
448const CFArrayCallBacks = opaque {};
449const CFStringEncoding = enum(u32) {
450 invalid_id = std.math.maxInt(u32),
451 mac_roman = 0,
452 windows_latin_1 = 0x500,
453 iso_latin_1 = 0x201,
454 next_step_latin = 0xB01,
455 ascii = 0x600,
456 unicode = 0x100,
457 utf8 = 0x8000100,
458 non_lossy_ascii = 0xBFF,
459};
460
461const FSEventStreamRef = *opaque {};
462const ConstFSEventStreamRef = *const @typeInfo(FSEventStreamRef).pointer.child;
463const FSEventStreamCallback = *const fn (
464 stream: ConstFSEventStreamRef,
465 client_callback_info: ?*anyopaque,
466 num_events: usize,
467 event_paths: *anyopaque,
468 event_flags: [*]const FSEventStreamEventFlags,
469 event_ids: [*]const FSEventStreamEventId,
470) callconv(.c) void;
471const FSEventStreamContext = extern struct {
472 version: CFIndex,
473 info: ?*anyopaque,
474 retain: ?CFAllocatorRetainCallBack,
475 release: ?CFAllocatorReleaseCallBack,
476 copy_description: ?CFAllocatorCopyDescriptionCallBack,
477};
478const FSEventStreamEventId = enum(u64) {
479 since_now = std.math.maxInt(u64),
480 _,
481};
482const FSEventStreamCreateFlags = packed struct(u32) {
483 use_cf_types: bool = false,
484 no_defer: bool = false,
485 watch_root: bool = false,
486 ignore_self: bool = false,
487 file_events: bool = false,
488 _: u27 = 0,
489};
490const FSEventStreamEventFlags = packed struct(u32) {
491 must_scan_sub_dirs: bool,
492 user_dropped: bool,
493 kernel_dropped: bool,
494 event_ids_wrapped: bool,
495 history_done: bool,
496 root_changed: bool,
497 mount: bool,
498 unmount: bool,
499 _: u24 = 0,
500};
501
502const dispatch = std.c.dispatch;
503const std = @import("std");
504const Io = std.Io;
505const assert = std.debug.assert;
506const Allocator = std.mem.Allocator;
507const watch_log = std.log.scoped(.watch);
508const Maker = @import("../../Maker.zig");
509const Watch = @import("../Watch.zig");