apps/lite/electron/src/watcher-architecture.md
I have the feeling, somebody is watching me - Rockwell
This is an overview of the architectural decisions around the project watcher and how subscribing to it works.
I chose to keep the logic around starting and stopping the watcher in Node, alongside with the managing of subscriptions. We want to keep the Node layer as simple as possible in Lite, but it made sense to have the subscription manager there.
The Node layer knows about the different windows and IPC channels, as well as their state (has a window been destroyed or a channel closed).
This keeps the UI subscription, as well as the watcher startup logic in Rust, as simple as it gets.
The Rust side exposed by crates/but-napi/src/lib.rs is intentionally minimal:
watcher_start(project_id, callback) starts one watcher and returns a handle.WatcherHandle only tracks ownership of that single watcher.stop() drops the inner Rust handle).This keeps Rust close to a stateless "start/forward/stop" boundary from the JavaScript point of view. In particular:
WebContents lifecycle.All of those policies are managed in apps/lite/electron/src/watcher.ts, where Electron runtime state already exists.
WatcherManager in Node guarantees at most one active Rust watcher per project id.
The deduplication mechanism is:
projectWatchers first. If present, reuse the existing watcher state.pendingProjectWatchers.watcherStart(...) once, store its promise in pendingProjectWatchers, then move the created handle into projectWatchers..finally(...) so failed and successful starts both clear the in-flight slot.This avoids duplicate Rust watchers during concurrent subscriptions to the same project.
A watcher may be shared, but subscriptions are not.
Every subscribeToProject(...) call creates:
subscriptionId.eventChannel.watcherSubscriptions.This is intentional because multiple consumers can exist for the same project:
The architecture deduplicates the expensive resource (Rust watcher process), not the app-level listeners.
It is possible to subscribe multiple times to the same project. And each subscription will have its own channel. So it's expected that the UI will only ever create one subscription per project.
We need to be extra careful with the watcher threads being spun. I made it so that at different steps we check for dead subscriptions and close them if necessary.
removeSubscription(subscriptionId):
watcherSubscriptions.senderSubscriptions).projectWatcher.handle.stop() and removes the project watcher from projectWatchers.registerSenderCleanup(sender) attaches sender.once("destroyed", ...).
When a window is closed:
removeSenderSubscriptions(senderId) iterates all its subscription ids.removeSubscription(...).In forwardWatcherEvent(...), if a subscription sender is destroyed or send(...) throws:
This provides lazy self-healing when stale subscriptions are encountered.
destroy() calls stopAllWatchersForShutdown():
pendingProjectWatchers, projectWatchers, watcherSubscriptions, and senderSubscriptions.This is a final safety net so no watcher ownership remains during process shutdown.
WatcherHandle ownership.