jamesward / sbt-reload   0.0.7

GitHub
Scala versions: 3.x
sbt plugins: 2.x

sbt-reload

An sbt 2.x plugin that auto-restarts your Scala application on source changes. Like sbt-revolver, but built on sbt 2.x primitives.

Install

Add to project/plugins.sbt:

addSbtPlugin("com.jamesward" % "sbt-reload" % "<version>")

No other configuration needed — the plugin auto-activates on all JVM projects.

Usage

Watch mode

./sbt ~runReload

For a Test-scoped main:

./sbt ~Test/runReload

This will:

  1. Compile and start your app in a forked JVM
  2. Watch for source changes
  3. On change: stop the running app, recompile, restart
  4. Press Enter to exit watch mode (stops the app)

Pausing and resuming from another client

Because sbt 2.x runs a persistent server shared by every connected client, one client can pause and later resume another client's running ~runReload. While a scope is paused, runReload invocations for that project/config keep the current fork running and do not restart it, even when sources change:

./sbt reloadPause         # pause Compile-scoped runReload
./sbt Test/reloadPause     # pause Test-scoped runReload
./sbt reloadResume        # resume it

This is useful when a second client (for example an AI agent) is driving edits and wants to make several changes without the watching ~runReload restarting the app on each one. The agent calls reloadPause, makes its edits, then calls reloadResume.

Semantics:

  • Pause/resume are scoped per project and per config, exactly like runReload. Issuing them from an aggregate root pauses/resumes every aggregated subproject's scope.
  • While paused, source changes are recompiled but the fork is not restarted, so the running app keeps serving old bytecode until you resume.
  • After reloadResume, the next runReload (the next ~runReload trigger, or a manual invocation) restarts the fork if its inputs changed while paused. Resuming does not itself force a restart — it re-enables the normal change-driven restart.
  • Pause state is cleared automatically when the ~runReload watch exits (watchOnTermination) and on sbt reload/exit (onUnload).

Checking reloader status

reloadStatus reports, for a scope (project + config), whether a runReload fork is currently running and whether it is paused:

./sbt reloadStatus        # Compile scope
./sbt Test/reloadStatus   # Test scope

It prints one of:

  • not running — no runReload fork is alive for this scope
  • running — a fork is alive and not paused
  • running (paused) — a fork is alive but paused via reloadPause

Like reloadOutput and reloadPause/reloadResume, it reads the shared sbt server state, so it reflects a fork or pause started by any connected client.

Watching output from another client (e.g. an AI agent)

sbt 2.x runs a persistent server that multiple clients can connect to. When one client runs ~runReload, its forked app's output is also captured to a per-config file so a different client can view it read-only:

./sbt reloadOutput        # Compile-scoped runReload output
./sbt Test/reloadOutput   # Test-scoped runReload output

reloadOutput prints whatever the running app has emitted since the last call and returns immediately (it does not stream/block, so it never starves the shared server's command loop). An agent can poll it after triggering a rebuild to see what the app printed. The capture file is truncated on each restart, and reloadOutput resets accordingly — so the first output of a freshly restarted fork is shown in full, even if it is longer than what the previous fork emitted.

reloadOutput is project-aware, keyed off the live background-job service rather than its own task axis. When the project you invoke it in has its own running runReload fork (e.g. myapp/reloadOutput, or bare reloadOutput in a single-project build), it shows only that project's output. Only when the invoking scope has no fork of its own — typically an aggregate root that just aggregates subprojects — does it fall back to reporting every running fork in the config. So in a multi-project build you can still run reloadOutput from the aggregate root and see whichever subproject's fork is running, without a subproject-scoped call dumping every other subproject's output. When more than one fork is reported (the aggregate-root fallback), each line is prefixed with the project id. The config axis is always honored: reloadOutput shows Compile forks, Test/reloadOutput shows Test forks.

When no fork has produced anything since the last call, reloadOutput prints a single reloadOutput: no new output line — not one line per running fork. This keeps a streaming ~reloadOutput quiet (it only prints real output) while still giving a manual one-shot poll a single confirmation that there was nothing new.

Do not run ~reloadOutput. Use the one-shot reloadOutput (poll it after triggering a rebuild); do not run it under sbt's watch.

sbt has a limitation where two concurrent continuous (~) builds on one server share a single file-watch repository, and after the first triggered rebuild one of the two watch sessions stops receiving further file-change events (the session started first goes deaf). This is not specific to this plugin — it reproduces with two plain ~compile sessions and no plugin involved — but running ~reloadOutput alongside ~runReload is a natural way to hit it, and the casualty is usually ~runReload, which silently stops restarting on edits.

The intended pattern for "watch + observe from another client": run one ~runReload watch, and from the other client poll the one-shot reloadOutput (it is a non-blocking poll by design — see above). A single ~ session restarts reliably on every change, and each one-shot reloadOutput call returns whatever the app has emitted since the last poll.

Multi-project builds

runReload is scoped per project and per config, so each <project>/<config>/runReload manages its own forked JVM and its own capture file. This has two consequences:

  • Aggregated ~runReload (run from a project that aggregates others) restarts only the subproject(s) whose inputs actually changed. Each scope keeps a content fingerprint of its classpath, run / mainClass, and runReloadArgs; if a scope's fingerprint is unchanged and its fork is still alive, that scope takes a no-op path instead of restarting.
  • Stopping is isolated to the matching scope. Exiting ~runReload stops only that scope's fork (matched by project + config), not other subprojects'. reload/exit stop every runReload fork across the build.

Configuration

Main class

Uses run / mainClass by default. Override if needed:

run / mainClass := Some("com.example.MyApp")

Or for Test scope:

Test / run / mainClass := Some("com.example.TestServer")

JVM options

run / javaOptions ++= Seq("-Xmx512m", "-Dconfig.file=dev.conf")

App arguments

runReloadArgs := Seq("--port", "8080")

Environment variables

run / envVars := Map("DATABASE_URL" -> "jdbc:postgresql://localhost/dev")

How it works

  • Uses sbt's BackgroundJobService to manage the forked process lifecycle
  • Stops prior runReload jobs before starting a new one (single-instance per project/config by default)
  • On compile failure, the running app keeps serving — no restart until the build succeeds
  • On watch exit (Enter or client disconnect), only the forked process for the exiting scope is terminated via watchOnTermination, matched by project + config so other subprojects' forks keep running
  • On sbt reload or exit, cleanup runs via onUnload, stopping every runReload fork across the build
  • reloadOutput is fed by runReload, which wraps the fork's background logger in a tee that writes each line to target/reload/<config>-output.log (under sbt 2.x's per-project output target, not <base>/target). The live ~runReload view is unaffected because the tee still forwards to the original logger. reloadOutput reads new bytes since its last call (line-aligned), and resets to the start of the file whenever runReload restarts the fork (which truncates the capture file), so it is a non-blocking poll rather than a stream.

Requirements

  • sbt 2.0.0+

License

Apache-2.0