Files
mommysgoodpuppy 75c751b4cf Fix webxr hot reload segfaulting the whole process
A webxr reload tore the worker down with raylib's audio device still
open: React does not unmount the scene on worker teardown, so the device
was never closed, and miniaudio's device thread kept running raylib code
after Deno unloaded the library. Close every audio backend in the webxr
shutdown path, and stop the audio backend from unloading the shared
raylib library itself.

--watch-webxr also crashed at startup: @react-three/xr type-imports
iwer/lib/device/XRDevice (the npm build layout), which our import map
sent to a nonexistent directory, and Deno.watchFs throws on missing
paths. Map that path to the source file, and skip missing directories
when watching. The bad import also made every reload refuse to run,
since the module graph never loaded cleanly.

Agent: Claude Code 2.1.296; model claude-opus-5-5; thread 941be8c7-aca1-4664-b284-7cc0eed69e21
2026-10-13 17:39:48 +03:00

221 lines
7.2 KiB
TypeScript

/**
* Dev loop for the `webxr` actor: reload it whenever a file it loads changes.
*
* Deno cannot evict a module from a running isolate, so new code needs a new
* worker — which is exactly what `RELOAD` builds (snapshot → shutdown →
* recreate → restore). This only decides *when* to send it.
*
* Two things make that safe to do on every save:
*
* - **Only the actor's own module graph is watched**, as `deno info` reports
* it, and it is re-read after every change so a newly imported file is
* picked up.
* - **Nothing is reloaded until the graph loads.** `RELOAD` drops the old actor
* before the new worker starts; a worker that fails to load (a half-typed
* save) never reports in, and the actor is gone until the server restarts.
* `deno info` parses every module in the graph in a fraction of a second, so a
* syntax error or a missing import keeps the old actor running and says why.
* (A module that throws while *evaluating* still gets past this check.)
*/
import { dirname, fromFileUrl } from "@std/path";
export type ModuleGraph = {
/** Absolute paths of every local file in the graph. */
files: Set<string>;
/** `path: message` for each module that failed to resolve or parse. */
errors: string[];
};
type DenoInfoModule = { specifier?: string; error?: string };
/** The parts of `deno info --json` that matter here. */
export function parseModuleGraph(info: { modules?: DenoInfoModule[] }): ModuleGraph {
const files = new Set<string>();
const errors: string[] = [];
for (const module of info.modules ?? []) {
const specifier = module.specifier ?? "";
const local = specifier.startsWith("file://");
if (local) files.add(fromFileUrl(specifier));
if (module.error != null) {
errors.push(`${local ? fromFileUrl(specifier) : specifier}: ${module.error}`);
}
}
return { files, errors };
}
export async function loadModuleGraph(entry: string): Promise<ModuleGraph> {
const output = await new Deno.Command(Deno.execPath(), {
args: ["info", "--allow-import", "--json", entry],
stdout: "piped",
stderr: "piped",
}).output();
if (!output.success) {
return { files: new Set(), errors: [new TextDecoder().decode(output.stderr).trim()] };
}
return parseModuleGraph(JSON.parse(new TextDecoder().decode(output.stdout)));
}
/**
* Directories to watch, not files: editors save by writing a temporary file
* and renaming it over the original, which a watch on the file itself misses.
*/
export function watchDirectories(files: Iterable<string>): string[] {
return [...new Set([...files].map((file) => dirname(file)))].sort();
}
function isDirectory(path: string): boolean {
try {
return Deno.statSync(path).isDirectory;
} catch {
return false;
}
}
/**
* Coalesces bursts of changes into one run, and never runs twice at once: a
* change that arrives mid-run queues exactly one more run after it.
*/
export class ReloadScheduler {
#timer: ReturnType<typeof setTimeout> | null = null;
#running = false;
#rerun = false;
#pending = new Set<string>();
constructor(
private readonly run: (changed: string[]) => Promise<void>,
private readonly debounceMs = 300,
) {}
notify(path: string): void {
this.#pending.add(path);
if (this.#running) {
this.#rerun = true;
return;
}
if (this.#timer != null) clearTimeout(this.#timer);
this.#timer = setTimeout(() => void this.#flush(), this.debounceMs);
}
async #flush(): Promise<void> {
this.#timer = null;
this.#running = true;
try {
do {
this.#rerun = false;
const changed = [...this.#pending];
this.#pending.clear();
await this.run(changed);
} while (this.#rerun);
} finally {
this.#running = false;
}
}
dispose(): void {
if (this.#timer != null) clearTimeout(this.#timer);
this.#timer = null;
}
}
export type WebXrAutoReloadOptions = {
/** The actor's module, e.g. `petplay/webxr.ts`. */
entry: string;
/** The actor to reload, or `null` while there is none. */
getTarget: () => string | null;
reload: (actorId: string) => Promise<void>;
/** Headset feedback; the server log gets everything regardless. */
toast: (title: string, content: string, tone: "info" | "warning") => void;
log: (message: string) => void;
debounceMs?: number;
};
const RELEVANT_EVENTS = new Set<Deno.FsEvent["kind"]>(["create", "modify", "rename", "remove"]);
/** Watches `entry`'s module graph and reloads the actor on change. */
export async function startWebXrAutoReload(
options: WebXrAutoReloadOptions,
): Promise<{ stop: () => void }> {
const { entry, log } = options;
let graph = await loadModuleGraph(entry);
let watcher: Deno.FsWatcher | null = null;
let watched = "";
let stopped = false;
const scheduler = new ReloadScheduler(async (changed) => {
const next = await loadModuleGraph(entry);
if (next.errors.length > 0) {
// Keep watching what was there plus anything new, so the fix is seen too.
graph = { files: new Set([...graph.files, ...next.files]), errors: next.errors };
rewatch();
log(
`[webxr-autoreload] not reloading; the code does not load:\n ${next.errors.join("\n ")}`,
);
// `/abs/dir/file.ts: SyntaxError: …` plus a code frame → `file.ts: SyntaxError: …`.
const first = next.errors[0]!.split("\n")[0]!;
options.toast(
"WebXR not reloaded",
first.slice(first.lastIndexOf("/", first.indexOf(": ")) + 1),
"warning",
);
return;
}
graph = next;
rewatch();
const target = options.getTarget();
if (target == null) {
log("[webxr-autoreload] no webxr actor to reload");
return;
}
const names = changed.map((path) => path.split("/").at(-1)).join(", ");
log(`[webxr-autoreload] reloading webxr (${names})`);
const started = performance.now();
try {
await options.reload(target);
} catch (error) {
log(`[webxr-autoreload] reload failed: ${error instanceof Error ? error.message : error}`);
return;
}
const ms = Math.round(performance.now() - started);
log(`[webxr-autoreload] reloaded in ${ms}ms`);
options.toast("WebXR reloaded", names, "info");
}, options.debounceMs);
function rewatch(): void {
// A module that failed to resolve can name a directory that does not exist, and
// `watchFs` throws on any missing path.
const directories = watchDirectories(graph.files).filter(isDirectory);
const key = directories.join("\n");
if (stopped || key === watched) return;
watcher?.close();
watched = key;
const next = Deno.watchFs(directories, { recursive: false });
watcher = next;
void (async () => {
try {
for await (const event of next) {
if (!RELEVANT_EVENTS.has(event.kind)) continue;
for (const path of event.paths) {
if (graph.files.has(path)) scheduler.notify(path);
}
}
} catch {
// Closed by a rewatch or stop.
}
})();
}
rewatch();
log(
`[webxr-autoreload] watching ${graph.files.size} files in ${
watchDirectories(graph.files).length
} directories`,
);
return {
stop: () => {
stopped = true;
scheduler.dispose();
watcher?.close();
},
};
}