|
1 | 1 | # Architecture |
2 | 2 |
|
3 | | -### **Phase 1: Core Hashing Engine (C)** |
4 | | -- Implement base portable hashing function using XXH3 algorithm as foundation (proven fastest non-cryptographic hasher) |
5 | | -- Add SIMD-optimized paths: AVX-512 for Intel Sapphire Rapids, AVX2 for older x86, NEON for ARM/Apple Silicon |
6 | | -- Implement incremental hashing with 4KB block-based approach for partial file updates |
7 | | -- Benchmark against xxHash, BLAKE3, and CityHash to verify 5-10x improvement on file operations |
8 | | -- Target: <0.5ms for 1MB file, <5ms for 100MB file |
9 | | - |
10 | | -### **Phase 2: System Integration Layer (Zig)** |
11 | | -- Implement inotify wrapper for Linux with IN_MODIFY, IN_CREATE, IN_DELETE events |
12 | | -- Add fanotify support for mount-wide monitoring without per-directory watch limits |
13 | | -- Implement io_uring for zero-copy file reading with registered buffers and SQPOLL mode |
14 | | -- Create memory-mapped ring buffer (64MB default) for lock-free event passing between kernel and userspace |
15 | | -- Add eBPF tracepoint hooks on vfs_write/vfs_open for cases where inotify hits limits |
16 | | -- Target: <1ms latency from file change to event notification |
17 | | - |
18 | | -### **Phase 3: High-Level API and Daemon (Rust)** |
19 | | -- Build daemon using tokio async runtime with multi-threaded executor |
20 | | -- Implement shared memory IPC using memmap2 crate for zero-copy communication with Node.js |
21 | | -- Add gRPC server (using tonic) for remote daemon connections |
22 | | -- Create hierarchical hash cache with dashmap for concurrent access |
23 | | -- Implement file pattern matching using globset crate for .gitignore-style excludes |
24 | | -- Add configuration hot-reload without daemon restart |
25 | | - |
26 | | -### **Phase 4: Node.js Integration** |
27 | | -- Use napi-rs v2 with latest N-API features for Node.js bindings |
28 | | -- Implement SharedArrayBuffer-based communication for instant change notifications |
29 | | -- Create webpack plugin that hooks into webpack's FileSystemWatcher interface |
30 | | -- Add Rspack/Turbopack compatibility layers (both are Rust-based and growing fast) |
31 | | -- Support for Vite through custom HMR API integration |
32 | | -- Include TypeScript definitions generated automatically by napi-rs |
33 | | - |
34 | | -### **Phase 5: Advanced Optimizations** |
35 | | -- Implement predictive pre-hashing using machine learning (track edit patterns with simple Markov chains) |
36 | | -- Add binary diff hashing - only rehash modified 4KB blocks using rolling checksums |
37 | | -- Implement FSEvents for macOS using kqueue and fseventsd integration |
38 | | -- Add ReadDirectoryChangesW support for Windows using completion ports |
39 | | -- Create eBPF program for in-kernel filtering to reduce userspace events by 90% |
40 | | -- Support for DRBD/NFS environments using fanotify marks on mount points |
41 | | - |
42 | | -### **Phase 6: Platform Extensions** |
43 | | -- Add Docker container support by monitoring overlayfs layers |
44 | | -- Implement Kubernetes ConfigMap/Secret watching via inotify on mounted volumes |
45 | | -- Create VS Code extension that uses daemon for instant file search/indexing |
46 | | -- Add support for remote development (Codespaces/Gitpod) with WebSocket transport |
47 | | -- Integrate with Bazel/Buck2/Turborepo for monorepo build caching |
48 | | - |
49 | | -### **Phase 7: Testing and Benchmarking** |
50 | | -- Test on major OSS projects: Next.js (50K+ files), Chromium (200K+ files), Linux kernel (70K+ files) |
51 | | -- Create reproducible benchmarks comparing against Watchman, Chokidar, and native webpack watching |
52 | | -- Implement stress tests: 1M files, 10K concurrent changes, network filesystems |
53 | | -- Add integration tests for all major bundlers: webpack 5, Vite 5, Rspack, Turbopack, esbuild |
54 | | -- Performance targets: 100x faster than Chokidar, 50x faster than webpack native watching |
55 | | - |
56 | | -### **Key Technology Choices for 2025:** |
57 | | -- **io_uring** over epoll - 30% better performance for file operations on Linux 5.19+ |
58 | | -- **eBPF** for overflow handling - when inotify limits hit, fall back to kernel tracing |
59 | | -- **NAPI-RS v2** - fastest Node.js binding framework, used by @node-rs/xxhash and others |
60 | | -- **Rust async** with tokio - better than threads for handling 100K+ concurrent file watches |
61 | | -- **Shared memory** over Unix sockets - zero-copy IPC for sub-millisecond latency |
62 | | -- **XXH3** as base algorithm - fastest for small inputs, perfect for incremental hashing |
63 | | - |
64 | | -This architecture specifically targets the webpack ecosystem pain points: slow initial scanning (we'll do 50K files in <200ms), high CPU usage during watching (we'll use <1% CPU idle), and slow change detection (we'll achieve <5ms from save to webpack notification). |
| 3 | +Retrigger answers one question for a dev server: **which files changed, and did their |
| 4 | +bytes actually change?** Everything below exists to answer it quickly and to be honest |
| 5 | +when it cannot. |
| 6 | + |
| 7 | +Three toolchains, four boundaries. This document is about the boundaries — what crosses |
| 8 | +each one, and what each side is allowed to assume. `CONTRIBUTING.md` covers how to build |
| 9 | +and test; the root `README.md` names the layers. Read this before moving anything between |
| 10 | +them. |
| 11 | + |
| 12 | +## The layers |
| 13 | + |
| 14 | +| Path | Language | Responsibility | |
| 15 | +| ---------------------------- | ---------- | ------------------------------------------------- | |
| 16 | +| `src/core` | C11 | XXH3-64, with a kernel per SIMD level | |
| 17 | +| `src/daemon/retrigger-core` | Rust | Safe FFI over the C engine | |
| 18 | +| `src/daemon/retrigger-system`| Rust | The watcher: backends, filtering, coalescing | |
| 19 | +| `src/daemon/retrigger-daemon`| Rust | Optional standalone daemon over HTTP/JSON and SSE | |
| 20 | +| `src/bindings/nodejs` | Rust + JS | N-API addon, JS fallback, bundler plugins | |
| 21 | + |
| 22 | +Each depends only on the one above it in the hash column, and the watcher and the hash |
| 23 | +meet for the first time in the layer that consumes both. |
| 24 | + |
| 25 | +## Boundary 1: C to Rust |
| 26 | + |
| 27 | +`src/core` exposes a C ABI through `include/retrigger_hash.h`. `retrigger-core` declares |
| 28 | +it by hand rather than generating bindings, so building from source does not require |
| 29 | +`libclang`. |
| 30 | + |
| 31 | +What makes hand-written declarations safe is that **both sides assert the layout |
| 32 | +independently**: the header carries `_Static_assert`s and the Rust module carries matching |
| 33 | +`const` assertions. If the two ever disagree, one of them stops compiling rather than |
| 34 | +reading a struct at the wrong offset at runtime. |
| 35 | + |
| 36 | +Two invariants live here and are load-bearing everywhere above: |
| 37 | + |
| 38 | +- **One algorithm, always.** `hash(x)` is XXH3-64 of `x` — every platform, every size, |
| 39 | + every entry point. An earlier version chose between BLAKE3 and XXH3 by input size, |
| 40 | + which meant the same bytes hashed differently depending on how many of them there were. |
| 41 | +- **Dispatch is a runtime decision.** `dispatch.c` reads CPU features through |
| 42 | + `cpuid`/`xgetbv` and selects a scalar, SSE2, AVX2, AVX-512, or NEON kernel. Nothing is |
| 43 | + compiled with `-march=native`. The machine that builds the binary is not the machine |
| 44 | + that runs it, and a compile-time choice turns a portability question into an |
| 45 | + illegal-instruction crash. |
| 46 | + |
| 47 | +Every kernel computes the same function, bit for bit. `rtr_hash_force_level` exists so a |
| 48 | +single machine can prove it, and the C suite checks the result against reference vectors |
| 49 | +from upstream xxHash rather than against the engine's own output. |
| 50 | + |
| 51 | +## Boundary 2: the kernel to the watcher |
| 52 | + |
| 53 | +`retrigger-system` is a safe layer over the `notify` crate — `forbid(unsafe_code)` at the |
| 54 | +crate root, no FFI. `notify` supplies inotify, FSEvents, and `ReadDirectoryChangesW`; this |
| 55 | +crate supplies the properties a dev-server watcher needs and those backends do not agree |
| 56 | +on by themselves: |
| 57 | + |
| 58 | +- **A bounded queue that reports its own losses.** Capacity is finite, so a burst larger |
| 59 | + than the queue must lose events. When that happens the watcher emits |
| 60 | + `EventKind::RescanRequired` rather than dropping events quietly. A consumer that sees it |
| 61 | + must re-read the tree instead of trusting the stream. |
| 62 | +- **Per-path coalescing that never swallows a delete.** Rapid writes to one path collapse |
| 63 | + into one event within the window; a delete or rename is never absorbed by a write that |
| 64 | + preceded it. |
| 65 | +- **Uniform semantics across backends.** Recursion, and the meaning of each event kind, |
| 66 | + are the same on all three platforms. macOS needs the most work here: FSEvents reports a |
| 67 | + *union of flags* accumulated since the last notification rather than a sequence of |
| 68 | + operations, so event kinds are re-derived from the file system before delivery. |
| 69 | +- **Filtering before queueing.** Include and exclude globs are applied before an event |
| 70 | + reaches the queue, so an excluded tree cannot consume the capacity a watched one needs. |
| 71 | +- **A lifecycle that joins its threads.** `stop()` returns when the threads are actually |
| 72 | + gone, not when they have been asked to leave. |
| 73 | + |
| 74 | +## Boundary 3: events to decisions |
| 75 | + |
| 76 | +An event says a file was written. A bundler needs to know whether to rebuild, and those |
| 77 | +are different questions: editors, formatters, code generators, and `git checkout` all |
| 78 | +rewrite files byte-identically. This is where the hash earns its place — a digest is kept |
| 79 | +per path, and a write whose digest matches the cached one is not a change. |
| 80 | + |
| 81 | +The decision is implemented twice, in Rust (`processor.rs`) and in JavaScript |
| 82 | +(`lib/content.js`), and the two decision tables are deliberately identical so the |
| 83 | +in-process watcher and the daemon cannot disagree about what counts as a change. |
| 84 | + |
| 85 | +The digests themselves are *not* comparable across engines — the addon hashes with XXH3-64 |
| 86 | +and the JavaScript fallback with BLAKE2b-64 — and they do not need to be. Each path is |
| 87 | +only ever compared against its own previous digest, taken by the same engine in the same |
| 88 | +process. Both engines therefore reach identical `contentChanged` answers from |
| 89 | +non-identical digest values. |
| 90 | + |
| 91 | +Unreadable, oversized, and deleted files all resolve to "changed". Failing open is the |
| 92 | +only safe direction: a missed rebuild is a wrong answer that looks correct. |
| 93 | + |
| 94 | +## Boundary 4: native to JavaScript |
| 95 | + |
| 96 | +`src/bindings/nodejs` is the published package, and the boundary that matters most, |
| 97 | +because it is the one users cross by accident. |
| 98 | + |
| 99 | +An **engine** is the small surface the rest of the package depends on: `createWatcher`, |
| 100 | +four hash entry points, and SIMD reporting. Two implementations satisfy it — the N-API |
| 101 | +addon (`src/lib.rs`, over the two Rust crates) and a pure-JavaScript one (`lib/js-watcher.js`, |
| 102 | +`lib/hash-js.js`). `lib/engine.js` picks one at load time; `Retrigger` never branches on |
| 103 | +which it received. |
| 104 | + |
| 105 | +That indirection is the whole shape of the install story. `require('@retrigger/core')` |
| 106 | +must never throw. Where a prebuilt binary exists it is used; where none does, the package |
| 107 | +degrades to JavaScript with one warning line and no stack trace, because a fallback is |
| 108 | +expected rather than exceptional. A shared parity suite runs both engines against the same |
| 109 | +assertions, and CI has a job that deletes every `.node` file and runs the suite to prove |
| 110 | +the fallback alone is sufficient. |
| 111 | + |
| 112 | +Above the engine sit the two bundler integrations. Both are gated through the same content |
| 113 | +oracle, including each bundler's own watcher where it keeps one running — Vite's chokidar |
| 114 | +is deliberately left alive so a failure here can never be the reason a dev server stops |
| 115 | +reloading, but its events are passed through the same digest cache so a no-op save does |
| 116 | +not reload the browser and a real edit is not invalidated twice. |
| 117 | + |
| 118 | +## The daemon is optional |
| 119 | + |
| 120 | +`retrigger-daemon` runs one watcher and one hash cache for several processes and exposes |
| 121 | +them over HTTP/JSON with server-sent events and Prometheus metrics. It is built from the |
| 122 | +same two crates the addon uses, which is what keeps its answers identical to the |
| 123 | +in-process ones. |
| 124 | + |
| 125 | +Nothing requires it. Watching happens in-process by default, and `@retrigger/core` does |
| 126 | +not install it. It exists for the case where several processes would otherwise each open |
| 127 | +their own watcher over the same tree. |
| 128 | + |
| 129 | +## What is deliberately absent |
| 130 | + |
| 131 | +Removed in 2.0.0, and not to be reintroduced without a decision that says why: |
| 132 | + |
| 133 | +- **The Zig system layer.** It sat between Rust and the kernel, never armed its inotify |
| 134 | + thread on Linux, and described `FileEvent` with a fat pointer where Rust read a thin |
| 135 | + one. `notify` covers the same three backends with no fourth toolchain. |
| 136 | +- **gRPC and shared-memory IPC.** The daemon speaks HTTP/JSON. Two more wire formats cost |
| 137 | + more than the latency they saved for a process that is optional to begin with. |
| 138 | +- **`src-js`.** Superseded by `lib/`, which is what the package ships. |
0 commit comments