Skip to content

Commit e289f55

Browse files
docs(architecture): describe the system that exists, not the plan
docs/ARCHITECTURE.md was the original seven-phase project plan, and CONTRIBUTING.md sends every new contributor to it with "read it before moving anything between layers". What it described was a Zig system layer, io_uring, eBPF tracepoints, gRPC, shared-memory IPC, SharedArrayBuffer notifications and Markov-chain pre-hashing -- a system that was either removed in 2.0.0 or never built. Rewritten around the four boundaries that do exist: the hand-written FFI that both sides assert the layout of, the watcher's contract with the kernel backends, where an event becomes a rebuild decision, and the engine indirection that lets require() never throw. Each states what crosses it and what either side may assume. A closing section records what was removed and why, so the Zig layer is not reintroduced by someone reading a stale plan.
1 parent ff22b87 commit e289f55

1 file changed

Lines changed: 136 additions & 62 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 136 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,138 @@
11
# Architecture
22

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

Comments
 (0)