using GLib; namespace Spry.Cli { /** * `spry dev` — build, run and watch the application. * * Sets up the builddir when absent, builds with ninja, spawns the app * binary with the stack's library environment, then watches `src/`, * `meson.build` and `web-config.json`. On change it rebuilds * incrementally — meson's dependency graph re-runs the statum-mkpstm / * statum-mkres code generation automatically — and restarts the binary * when the build succeeds. On failure the compiler errors are shown and * the previous process keeps running, so the loop is never left dead. * * Restarts are cheap by design: Statum state is client-carried, the * browser transparently recovers via the transmit(retry) protocol, and * session identity survives via the static keys in web-config.json — * just refresh the page. */ public class Dev : Object { /** Delay bundling rapid consecutive saves into one rebuild. */ private const uint DEBOUNCE_MS = 200; /** Grace period after SIGTERM before escalating to SIGKILL. */ private const uint KILL_GRACE_SECONDS = 3; private string app_dir; private string app_name; private string build_dir; private string binary_path; private string? libdir; private int port; private MainLoop loop; private SubprocessLauncher launcher; private Subprocess? app_process = null; private FileMonitor[] monitors = {}; private uint debounce_source = 0; private bool expect_exit = false; private bool shutting_down = false; /** * Runs the dev loop in `app_dir`. Does not return until the user * interrupts it (Ctrl-C) or the initial build fails. */ public static int run(string app_dir, string app_name, int port, bool fresh, bool no_run, string? libdir) throws Error { var dev = new Dev(); dev.app_dir = app_dir; dev.app_name = app_name; dev.build_dir = Path.build_filename(app_dir, "builddir"); dev.binary_path = Path.build_filename(dev.build_dir, app_name); dev.libdir = libdir; dev.port = port; if (fresh) { dev.reset_database(); } dev.launcher = new SubprocessLauncher(SubprocessFlags.NONE); Tools.apply_env(dev.launcher, libdir); if (!FileUtils.test(Path.build_filename(dev.build_dir, "build.ninja"), FileTest.EXISTS)) { dev.step("Configuring builddir"); if (!dev.spawn_sync({ "meson", "setup", ".", "builddir" })) { stderr.printf("[spry] meson setup failed — fix the errors above and re-run spry dev\n"); return 1; } } dev.step("Building"); if (!dev.build()) { return 1; } if (no_run) { return 0; } dev.loop = new MainLoop(); dev.watch_sources(); dev.install_signal_handlers(); dev.spawn_child(); dev.loop.run(); return 0; } // ------------------------------------------------------------------ // Build / run / restart // ------------------------------------------------------------------ /** Deletes the application's SQLite store (honouring SPRY_DB_PATH). */ private void reset_database() { var configured = Environment.get_variable("SPRY_DB_PATH"); var path = configured != null && configured.length > 0 ? configured : app_name + ".sqlite"; if (FileUtils.test(path, FileTest.EXISTS)) { FileUtils.remove(path); stdout.printf("[spry] Deleted %s\n", path); } } /** Incrementally builds; returns true on success. */ private bool build() throws Error { return spawn_sync({ "ninja", "-C", "builddir" }); } /** * Runs a short-lived child that inherits this process's stdio, * with the stack's library environment applied. */ private bool spawn_sync(string[] argv) throws Error { var process = launcher.spawnv(argv); try { process.wait_check(); return true; } catch (Error e) { return false; } } private void spawn_child() { try { expect_exit = false; app_process = launcher.spawnv({ binary_path, port.to_string() }); stdout.printf("[spry] Running %s on port %d (Ctrl-C to stop)\n", app_name, port); watch_child.begin(); } catch (Error e) { stderr.printf("[spry] Could not run %s: %s\n", binary_path, e.message); } } /** * The single lifecycle waiter for the running child: on an expected * exit (restart) it spawns the replacement; on an unexpected exit it * reports and leaves the loop watching for the next successful * rebuild. */ private async void watch_child() { var process = app_process; if (process == null) { return; } try { yield ((!)process).wait_async(null); } catch (Error e) { } if (shutting_down) { return; } if (expect_exit) { spawn_child(); } else { app_process = null; stdout.printf("[spry] %s exited — save a file to rebuild, or Ctrl-C to quit\n", app_name); } } /** Rebuilds and, on success, replaces the running child. */ private void rebuild() { if (shutting_down) { return; } step("Rebuilding"); try { if (!build()) { stdout.printf("[spry] Build failed — %s is still running\n", app_name); return; } } catch (Error e) { stderr.printf("[spry] Build could not run: %s\n", e.message); return; } restart_child(); } /** * Stops the running child (SIGTERM, escalating to SIGKILL after the * grace period); the lifecycle waiter spawns the replacement once it * observes the exit. When no child is running the replacement starts * immediately. */ private void restart_child() { var process = app_process; if (process == null) { spawn_child(); return; } expect_exit = true; ((!)process).send_signal(ProcessSignal.TERM); Timeout.add_seconds(KILL_GRACE_SECONDS, () => { if (!shutting_down && app_process == process && !((!)process).get_if_exited()) { stdout.printf("[spry] %s did not exit — killing it\n", app_name); ((!)process).send_signal(ProcessSignal.KILL); } return false; }); } // ------------------------------------------------------------------ // Watching // ------------------------------------------------------------------ /** Watches the application's sources for changes. */ private void watch_sources() throws Error { watch_tree(File.new_for_path(Path.build_filename(app_dir, "src"))); watch_file(Path.build_filename(app_dir, "meson.build")); watch_file(Path.build_filename(app_dir, "web-config.json")); } private void watch_tree(File directory) { watch_directory(directory); try { var enumerator = directory.enumerate_children("standard::name,standard::type", FileQueryInfoFlags.NONE); FileInfo info; while ((info = enumerator.next_file()) != null) { if (info.get_file_type() == FileType.DIRECTORY) { watch_tree(File.new_for_path( Path.build_filename((!) directory.get_path(), info.get_name()))); } } } catch (Error e) { stderr.printf("[spry] Could not enumerate %s: %s\n", directory.get_path(), e.message); } } /** * Attaches a directory monitor; newly created subdirectories are * picked up as well, so `spry add` output is watched without a * restart. */ private void watch_directory(File directory) { try { var monitor = directory.monitor_directory(FileMonitorFlags.NONE); monitor.changed.connect((file, other, event) => { if (event == FileMonitorEvent.CREATED && file.query_file_type(FileQueryInfoFlags.NONE) == FileType.DIRECTORY) { watch_tree(file); } if (event != FileMonitorEvent.CHANGES_DONE_HINT) { schedule_rebuild(); } }); monitors += monitor; } catch (Error e) { stderr.printf("[spry] Could not watch %s: %s\n", directory.get_path(), e.message); } } private void watch_file(string path) { try { var monitor = File.new_for_path(path).monitor_file(FileMonitorFlags.NONE); monitor.changed.connect(() => schedule_rebuild()); monitors += monitor; } catch (Error e) { stderr.printf("[spry] Could not watch %s: %s\n", path, e.message); } } /** Bundles rapid saves into a single rebuild. */ private void schedule_rebuild() { if (debounce_source != 0) { Source.remove(debounce_source); } debounce_source = Timeout.add(DEBOUNCE_MS, () => { debounce_source = 0; rebuild(); return false; }); } // ------------------------------------------------------------------ // Lifecycle // ------------------------------------------------------------------ private void install_signal_handlers() { Unix.signal_add(ProcessSignal.INT, () => { shutdown(); return false; }); Unix.signal_add(ProcessSignal.TERM, () => { shutdown(); return false; }); } private void shutdown() { if (shutting_down) { return; } shutting_down = true; stdout.printf("\n[spry] Stopping %s\n", app_name); if (app_process != null) { ((!)app_process).send_signal(ProcessSignal.TERM); Timeout.add_seconds(KILL_GRACE_SECONDS, () => { if (app_process != null && !((!)app_process).get_if_exited()) { ((!)app_process).send_signal(ProcessSignal.KILL); } return false; }); } Timeout.add(KILL_GRACE_SECONDS * 1000, () => { loop.quit(); return false; }); } private void step(string label) { stdout.printf("[spry] %s…\n", label); } } }