Files
Oxide/docs/file-watching.md
T
Homer Simpson 9eead719b0 Import Oxide engine (Stages 0–10) under MIT license
Full project snapshot migrated to new Gitea remote without history:
engine, editor, physics, script, examples, tests, docs, and assets.
Relicensed from GPLv3 to MIT and updated repo URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:41:02 +02:00

5.7 KiB

File Watching

oxide_engine::watch watches directories on disk and emits debounced, deduplicated change events. It is the Stage-6 foundation for the engine's live-reload story:

  • Stage 6 — reload changed assets (via AssetServer) so a texture or model edited in an external tool reappears in the running editor without restarting.
  • Stage 10 — recompile and hot-swap game scripts using the same event stream and the same debounce logic.
  • Editor — drives the Project panel's "files appeared / disappeared" refresh.

The same module covers all of these because the hard part — "wait until the filesystem stops twitching, then emit one event per path" — is identical in every case.

Why debounce

Filesystem events are noisy:

  • Most editors save in several syscalls (write the file, rename a temp file into place, chmod) — that is one logical change but several events.
  • Recursive watches re-fire while a directory's children are being created.
  • Backends collapse or split events differently across Linux, macOS, and Windows.

If the engine reloaded on every raw event, one save could re-parse a model many times over. The watcher gathers raw events into a pending set keyed by path, then emits one event per path once that path has been quiet for a configurable window.

Architecture (two layers)

The module is intentionally split so most behavior is unit-testable without touching real files.

Debouncer — the pure core

A plain struct that takes Instants from the caller. Tests drive it through a deterministic timeline; no sleep, no flaky timing dependence on the OS event queue.

use std::time::{Duration, Instant};
use oxide_engine::watch::{ChangeKind, Debouncer};

let mut d = Debouncer::new(Duration::from_millis(100));
let t0 = Instant::now();

d.record("assets/cube.gltf".into(), ChangeKind::Modified, t0);
d.record("assets/cube.gltf".into(), ChangeKind::Modified,
         t0 + Duration::from_millis(20));

// Still hot — nothing fires.
assert!(d.drain_ready(t0 + Duration::from_millis(80)).is_empty());

// After 100 ms of quiet, one event fires for the path.
let ready = d.drain_ready(t0 + Duration::from_millis(130));
assert_eq!(ready.len(), 1);

Coalescing rules (chosen to match what a reloader downstream cares about):

Earlier kind Newer kind Emitted kind
Created Modified Created
Removed Modified Created (file came back)
anything Removed Removed
anything else newer newer

FileWatcher — the real-world wrapper

Wraps a notify::RecommendedWatcher plus a worker thread that drives the debouncer with real time and forwards settled events through an mpsc channel.

use std::time::Duration;
use oxide_engine::watch::FileWatcher;

let (mut watcher, events) = FileWatcher::new(Duration::from_millis(150))?;
watcher.watch("path/to/project/assets")?;

// In the editor's per-frame tick, drain whatever has settled:
while let Ok(event) = events.try_recv() {
    println!("{:?} at {}", event.kind, event.path.display());
}
# Ok::<(), oxide_engine::watch::WatchError>(())

FileWatcher watches recursively. Dropping it stops the worker thread and disconnects the receiver — no manual cleanup.

The quiet window is a knob: too short and you get repeated events from one save; too long and the editor feels laggy. The default Stage-6 wiring uses ~150 ms.

Asset reload

reload_changed_assets is the wiring between the watcher and the asset server. For each Created or Modified event it calls AssetServer::reload_path, which re-runs the loader for every cached asset at that path and updates the existing handle in place — gameplay code holding the handle sees the new contents on its next read.

use std::time::Duration;
use oxide_engine::asset::AssetServer;
use oxide_engine::watch::{reload_changed_assets, FileWatcher};

let assets = AssetServer::new();
let (mut watcher, events) = FileWatcher::new(Duration::from_millis(150))?;
watcher.watch("path/to/project/assets")?;

// Per frame:
let batch: Vec<_> = std::iter::from_fn(|| events.try_recv().ok()).collect();
let reloaded = reload_changed_assets(&assets, batch);
if reloaded > 0 {
    log::info!("hot-reloaded {} asset(s)", reloaded);
}
# Ok::<(), oxide_engine::watch::WatchError>(())

AssetServer::reload_path is type-erased on purpose. The cache records, per entry, a function pointer that re-runs the loader for that entry's concrete type, so the watcher can react to a disk change without knowing every asset type at compile time. Paths that are not currently cached return zero work; the next load picks up the fresh contents anyway. Removed events do not invalidate cached handles — gameplay code may want the last-loaded copy to keep working.

What this groundwork enables

Stage Builds on
6 Editor live-reload of assets; Project panel refresh
7 Watch input-binding config for changes during a session
10 Script hot-reload (same watcher; the reloader recompiles + swaps the module)
11 WGSL shader hot-reload

Testing strategy

  • Unit tests drive Debouncer directly with fixed Instants — fast, deterministic, and they cover the coalescing rules exhaustively.
  • One tolerant smoke test writes to a temp dir and polls for an event with a generous deadline (seconds, not milliseconds). On containerized CI without a usable event backend the test prints SKIP: and passes — the unit tests already prove the logic is correct, this only checks the OS wiring is connected.