|
@@ -0,0 +1,315 @@
|
|
|
|
|
+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);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|