Fix stop-app using a PID file instead of Actuator or JMX#15698
Fix stop-app using a PID file instead of Actuator or JMX#15698jamesfredley wants to merge 17 commits into
Conversation
The base profile stop-app command relied on JMX (via com.sun.tools.attach.VirtualMachine and the management-agent.jar that was removed in Java 9+) with an HTTP fallback to the Spring Boot Actuator /actuator/shutdown endpoint. The JMX path is broken on modern JDKs and the Actuator endpoint is disabled by default, so stop-app failed with a FileNotFoundException against /actuator/shutdown even though the application was running. In the interactive shell, run-app starts the application as an asynchronous Gradle bootRun build whose cancellation token was only reachable through the run-app command's ExecutionContext. This adds a RunningApplicationRegistry that tracks the running build's CancellationTokenSource so that stop-app can cancel it directly - the same mechanism CTRL-C already uses - without shutting down the CLI. - Add RunningApplicationRegistry to track running bootRun builds. - GradleUtil.wireCancellationSupport now returns the token source (created via the public GradleConnector.newCancellationTokenSource() instead of the internal DefaultCancellationTokenSource) and a new runBuildWithConsoleOutput overload registers and deregisters it. - GradleInvoker tracks only the bootRun task for stop support. - Rewrite stop-app to cancel the running build via the registry and wait for it to stop; remove the obsolete port/host flags. - run-app no longer enables the Actuator shutdown endpoint and its shutdown hook cancels via the registry directly. - Update the reference and getting-started docs. Fixes #13695 Assisted-by: opencode:claude-opus-4-8
There was a problem hiding this comment.
Pull request overview
Fixes a long-broken stop-app in the interactive Grails CLI by replacing the JMX-attach and Spring Boot Actuator /actuator/shutdown HTTP fallback (both of which fail on modern JDKs/default configurations) with a CLI-only registry that lets stop-app cancel the running bootRun Gradle Tooling API build directly via its CancellationTokenSource.
Changes:
- New
RunningApplicationRegistrytracks runningbootRuncancellation tokens;stop-app.groovyis rewritten to cancel via the registry andawaitStop, dropping the JMX/HTTP shutdown code and theport/hostflags. GradleUtil.wireCancellationSupportnow uses the publicGradleConnector.newCancellationTokenSource()and returns the token; a newrunBuildWithConsoleOutput(context, trackForStop, closure)overload registers/deregisters tokens around the build.GradleInvokeropts only thebootRun/:bootRuntask into tracking;run-app.groovydrops-Dgrails.management.endpoints.shutdown.enabled=trueand the shutdown hook callsRunningApplicationRegistry.stopAll(); reference and getting-started docs updated to match the new flow.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| grails-shell-cli/src/main/groovy/org/grails/cli/gradle/RunningApplicationRegistry.groovy | New registry of running app cancellation token sources with stop/await operations. |
| grails-shell-cli/src/test/groovy/org/grails/cli/gradle/RunningApplicationRegistrySpec.groovy | Spock spec covering register/deregister, stopAll, throw resilience, and awaitStop success/timeout. |
| grails-shell-cli/src/main/groovy/org/grails/cli/gradle/GradleUtil.groovy | Switches to public newCancellationTokenSource(), returns the token, and adds a trackForStop overload that registers/deregisters around the build. |
| grails-shell-cli/src/main/groovy/org/grails/cli/gradle/GradleInvoker.groovy | Enables stop tracking only for bootRun/:bootRun invocations. |
| grails-profiles/base/commands/stop-app.groovy | Rewritten to cancel via the registry and await shutdown; removes JMX and Actuator HTTP fallback and port/host flags. |
| grails-profiles/base/commands/run-app.groovy | Drops Actuator shutdown system property; shutdown hook now calls RunningApplicationRegistry.stopAll() directly. |
| grails-doc/src/en/ref/Command Line/stop-app.adoc | Documents the new CLI-only, same-session shutdown semantics and removes obsolete arguments. |
| grails-doc/src/en/guide/gettingStarted/runningAndDebuggingAnApplication.adoc | Updates the example output to match the new status messages. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- awaitStop now waits on a monitor that deregister() notifies, instead of polling every 100ms. The stop-app command reacts immediately when the cancelled build finishes and the timeout handling is more deterministic. - stopAll no longer silently swallows a failed cancel(); the failure is logged via GrailsConsole.verbose so it is visible during diagnosis. Assisted-by: opencode:claude-opus-4-8
There was a problem hiding this comment.
If the processes are forked or someone runs the grails command grails stop-app independently of the original run-app call, I don't see how this will work.
Why not just write the pid of the application in the RunApp gradle task and then check for that pid and call the OS equivalent of a graceful shutdown? i.e. on linux using kill -TERM <pid>
Replaces the in-memory RunningApplicationRegistry, which only worked within a single CLI JVM session, with a PID file plus OS signal so that stop-app can terminate an application started by run-app even when it was forked into a separate process or when stop-app is run from a different grails invocation. run-app now asks the forked bootRun application to write its PID via Spring Boot's ApplicationPidFileWriter, gated by the cli.pid.file system property so production runs are unaffected. stop-app reads build/run-app.pid and stops the process with ProcessHandle.destroy() (a graceful SIGTERM on Unix-like systems; best effort on Windows), guarding against stale and recycled PIDs, then removes the file. A stop marker lets a foreground run-app report a clean shutdown rather than a startup failure when its process is terminated, and an already-running guard prevents a second run-app from orphaning a running application. Assisted-by: claude-code:claude-4.8-opus
|
@jdaugherty thanks for the review - you were right, the in-memory registry only worked within a single CLI JVM, so it broke for forked processes and for an independent What changed (pushed in a53817c)Writing the PID (
Stopping by PID (
Because the contract is a file on disk, this now works for the two cases you called out: forked processes and Other details
I kept |
matrei
left a comment
There was a problem hiding this comment.
Got unexpected output from stop-app (see comment).
|
I was expecting us to change the bootRun task configuration in the gradle plugin to write out pid files to the build directory via a |
|
could just update the create app templates to have a config that turns the actuator on in development mode only. Last time I checked, jmx worked as well, but that was also an opt in. So I would say preferred approach would be traditional then pid fallback |
Set a default cli.pid.file on bootRun tasks so applications launched through bootRun write the same build/run-app.pid file that stop-app reads. Preserve the CLI-supplied PID file path when run-app passes one explicitly. Add TestKit coverage for the default bootRun PID path and the CLI override path. Assisted-by: opencode:gpt-5.5 oracle
Keep the stop-app reference focused on user-facing behavior and remove implementation details about the PID file, Actuator, JMX, and platform-specific shutdown mechanics. Assisted-by: opencode:gpt-5.5 oracle
Keep the legacy host and port options declared so callers receive an explicit error instead of having the options ignored. Reject them before mutating run-app state or attempting PID-file termination. Assisted-by: opencode:gpt-5.5 oracle
|
Updated the PR with three follow-up commits after the latest review:
Verification run locally:
I also updated the PR description to match the current design and replied to the review threads about docs and the interactive |
Resolve the run-app PID file from grails.cli.pid.file when it is supplied, and have stop-app use the same resolution path before falling back to build/run-app.pid. Assisted-by: opencode:gpt-5.5 oracle
Add configuration metadata for grails.cli.pid.file and include the Command Line section in the generated application properties ordering. Assisted-by: opencode:gpt-5.5 oracle
Let run-app and stop-app use grails.cli.pid.file from application configuration after command-line and CLI JVM system properties. Assisted-by: opencode:gpt-5.5 oracle
|
Completed the configuration metadata follow-up.
Verification:
|
92957ff to
4292d96
Compare
|
I'm was expected to see a Still getting this from | Shutting down application...
| Error Application not running. (Use --stacktrace to see the full trace) |
Address review feedback on the PID-file based stop-app: - Scope the PID system property as grails.cli.pid.file and set it directly on the bootRun task; GrailsApp reads the scoped name. - Configure the bootRun PID file via pluginManager.withPlugin and a CommandLineArgumentProvider (RunAppPidFileProvider) so the path is resolved lazily at execution time and stays configuration-cache safe, replacing the afterEvaluate plus configuration-time resolution. - Hard-code the PID file at build/run-app.pid; remove the run-app and stop-app command/system/config overrides so producer and consumer always agree on one location. - Drop the internal grails.cli.pid.file entry and Command Line group from the Spring configuration metadata and the doc ordering; it is internal CLI wiring, not user configuration. - Keep and document the run-app.stopping marker: stop-app signals only the forked application JVM, so bootRun exits non-zero and a foreground run-app needs the marker to tell a deliberate stop from a startup failure. Assisted-by: claude-code:claude-4.8-opus
|
Thanks for the thorough review, @jdaugherty and @matrei. Pushed 9a132a8 addressing the latest round of feedback. Summary of changes: Property scoping
Gradle plugin lifecycle
Hard-coded, single source of truth
Docs / metadata
Stop marker (kept, with rationale)
Tests
Verified
|
codeconsole
left a comment
There was a problem hiding this comment.
1 — Blocking: the exact failure matrei reported is unverified
Every automated test covers either the helper in isolation (RunningApplicationProcessSpec) or that bootRun supplies the arg (GrailsGradlePluginToolchainSpec). Nothing proves the end‑to‑end loop: that the forked app actually writes build/run-app.pid and that stop-app finds and kills it. matrei's repro is the real test and it is outstanding. Before merge, run create-app → run-app → confirm the PID file appears → stop-app, on Unix, in both interactive and separate‑terminal modes.
2 — Latent correctness: producer and consumer resolve "the build dir" two different ways
- Producer (Gradle plugin):
project.layout.buildDirectory.file('run-app.pid'). - Consumer (
run-app/stop-app): thebuildDirbinding, which isBuildSettings.TARGET_DIR = new File(BASE_DIR, System.getProperty('project.target.dir', 'build')).
These coincide for a default project but diverge if the project customizes its Gradle build directory (layout.buildDirectory / buildDir = …): the app writes the PID into the custom dir while stop-app looks under <base>/build → "Application not running." The whole design hinges on producer and consumer agreeing on one path, so this assumption deserves a guard, a test with a customized build dir, or at minimum a documented constraint. (Also a plausible explanation if matrei's environment had any build‑dir customization.)
3 — Already-running guard is racy (acknowledged by jdaugherty)
run-app checks isRunning(pidFile) before launch, but the forked app writes the PID asynchronously after startup. Two quick run-apps both pass the guard and both launch; with a hard‑coded port the second fails on a port conflict (acceptable), but with a dynamic/0 port you can orphan two apps and keep only one PID file. Fine to ship as best‑effort, but call the limitation out.
4 — Windows loses graceful shutdown
ProcessHandle.destroy() maps to TerminateProcess on Windows — not a graceful signal — so JVM shutdown hooks / Spring orderly shutdown do not run there. The PR documents Windows as "best effort," but the consequence (no graceful shutdown on Windows, directly contradicting jdaugherty's "TERM is critical… hooks get called") should be explicit. This is exactly the gap codeconsole's dev‑Actuator suggestion would have covered cross‑platform. Recommend shipping PID‑as‑primary and documenting the Windows limitation rather than blocking — but it is a real functional asymmetry.
5 — Interactive mode: the stop marker doesn't cover the async path
In interactive mode run-app calls gradle.async.bootRun and returns after the server is up; the marker → "Application stopped" branch lives in run-app's synchronous catch, which has already exited by the time stop-app kills the app. So when the background build future fails, nothing consumes the marker in interactive mode — verify that interactive stop-app doesn't surface a spurious Gradle build‑failure trace (that was the symptom in matrei's earlier transcript). The marker genuinely helps only the foreground grails run-app case.
6 — Test gaps (per the project's "every touched class must be covered" rule)
- No test that
GrailsApp.run()registersApplicationPidFileWriteronly whengrails.cli.pid.fileis set — the producer side ingrails-coreis untested (most worth adding). - The PID‑reuse guard
isProbablyReusedPidhas no test. STILL_RUNNINGand thedestroyForciblyfallback aren't exercised (sleepdies on SIGTERM).- The
stop-apphost/port fail‑fast and therun-appmarker branch are uncovered. - The TestKit test asserts the arg is supplied, not that a real app writes the file.
7 — Minor / polish
- The stop marker stores
System.currentTimeMillis(), butisStopRequestedonly checks existence — the timestamp is dead data (either honor it to ignore stale markers, or write an empty file). stop-appleaves the marker on disk afterSTOPPED(cleared only by the nextrun-appor a laterNOT_RUNNING); clearing it onSTOPPEDtoo would be tidier.- Behavior change worth a doc line: every
./gradlew bootRunnow writesbuild/run-app.pidand registers the PID writer, not just CLIrun-app. Benign/arguably useful, but new. - Agree with jdaugherty: the
--host/--portremoval is a breaking change that belongs in upgrade/release notes, with the command reference simply not documenting them.
Things verified as fine
- The earlier
RunningApplicationRegistryiteration is fully removed — no dangling references at the PR head. - PID‑reuse guard logic (
startInstant > pidFile.mtime + 5s⇒ recycled) is correct and also protects the already‑running check against stale PID files. - Stale PID files left by a normal (non‑
stop-app) exit are handled via liveness checks, so they don't wedgerun-app/stop-app. RunAppPidFileProvidermirrors the existingGrailsAppBaseDirProvider(lazyProvider,@Internalfields) and stays configuration‑cache safe.- Process model is consistent: the writer records the application JVM PID;
stop-appTERMs that JVM; the Gradle Tooling API build (parent) then reports a non‑zero child exit (hence the marker), while the daemon stays up.
Bottom line
Right approach, well executed, nearly there. Before merge:
- Re‑confirm matrei's end‑to‑end repro on Unix (interactive and separate‑terminal).
- Resolve or explicitly constrain the
buildDir‑vs‑layout.buildDirectorymismatch (Finding 2). - Document the Windows "not graceful" consequence (Finding 4).
- Ideally add a
GrailsAppproducer‑side test (Finding 6). - Close out Findings 5 and 7, and reply to the still‑open (dev‑Actuator) and jdaugherty (upgrade‑note placement) threads.
Follow-up to review feedback on the run-app/stop-app PID file design: - Add GrailsAppPidFileSpec covering the producer side: GrailsApp registers Spring Boot's ApplicationPidFileWriter only when grails.cli.pid.file is set. Extract GrailsApp.configureCliPidFileWriter() to make this testable. - Add a grails-web TestKit project (boot-run-pid-web) and assert the exact project build path, proving bootRun supplies build/run-app.pid for both grails-app and grails-web applications. - Add recycled-PID guard tests (isRunning and stop) for RunningApplicationProcess. - Make the run-app.stopping marker an empty presence-only file (it previously stored an unused timestamp) and clarify in comments that it is purely a foreground message classifier, unrelated to the graceful ProcessHandle.destroy() shutdown that always runs the JVM shutdown hooks on Unix. - Document the stop-app breaking change in the Grails 7 upgrade notes (removed --host/--port, Windows best-effort shutdown, bootRun now writes build/run-app.pid, and the default-build-directory constraint) and trim the command reference. Assisted-by: claude-code:claude-4.8-opus oracle
|
Pushed 18dd335 addressing the latest review round (thanks @codeconsole, @jdaugherty, @matrei). Summary of what changed and how each finding was handled. What changed in this commit
Verified locally (all green):
Response to the review findings1 - End-to-end loop (blocking). The wiring is now proven by automated tests on the producer side: 2 - build-dir mismatch. Resolved as a documented constraint (the pragmatic call rather than having the CLI parse Gradle state). The producer uses 3 - already-running guard is racy. Documented as best-effort in 4 - Windows not graceful. Documented in both 5 - interactive marker / async path. Confirmed and documented: in interactive mode 6 - test gaps. Added the highest-value ones: the 7 - polish. Dead timestamp removed (empty marker). The marker is deliberately not cleared on On the dev-Actuator alternative (@codeconsole)I kept the pure-PID approach rather than enabling Actuator/JMX in dev. It is what @jdaugherty asked for, it works for forked processes and for |
|
Verified on Linux — the full loop works, including the exact cross-invocation case matrei Faithful repro in a JDK-17 Debian container (the PR's own etc/bin/Dockerfile), against the locally-published [1] create-app myapp → Application created [2] run-app (non-interactive, separate proc) → app forks [3] ✅ build/run-app.pid appeared after ~28s: pid=4013 ← producer link (the one matrei saw fail) [4] stop-app (separate invocation = "second terminal") [5] App JVM gone after stop-app? yes |
|
Interactive mode is now verified too. Here's exactly what a user sees at the grails> prompt: |
There was a problem hiding this comment.
End-to-end verified on Linux. run-app writes build/run-app.pid, stop-app (same or separate invocation) gracefully SIGTERMs the app, reports Application stopped., and removes the file. Foreground and interactive paths both confirmed; Every finding from my reviews is now closed
The only cosmetic artifact is a single benign BUILD FAILED line in interactive mode that @jdaugherty already made a reasoned call to leave.
|
the Build Failed message means its not gracefully shutdown... |
that's not good then. |
stop-app (and Ctrl+C) terminate the forked application gracefully via SIGTERM/SIGINT, so the JVM runs its shutdown hooks and exits with a signal status code (143 for SIGTERM, 130 for SIGINT). JavaExec treats any non-zero exit as a build failure, so a normal, graceful stop was reported as BUILD FAILED. Configure bootRun to tolerate those signal-termination codes via a BootRunExitCodeVerifier doLast action - still failing the build for any other non-zero exit such as a genuine startup error - so a deliberate stop is reported as BUILD SUCCESSFUL. run-app reports the clean shutdown now that the build succeeds.
|
the problem with gradle is the app returns a 143 which results in BUILD FAILED but it is a graceful shutdown. if it returned an exit code of 0 like the actuator, there would be no issue. 143 is the correct exit code so BUILD FAILED can be addressed by handling 143 exits in the grails gradle plugin |
✅ All tests passed ✅🏷️ Commit: c526500 Learn more about TestLens at testlens.app. |
Fixes #13695
Problem
stop-appno longer works reliably on modern Grails setups. The old command tried to stop an app through JMX or the Spring Boot Actuator shutdown endpoint:run-app.The user-facing symptom is that
grails stop-appreportsApplication not runningwhile arun-appprocess is still active.What this PR does
run-appandstop-appnow share a project-local PID file contract:bootRunto pass-Dgrails.cli.pid.file=<build>/run-app.pidto the forked application JVM, via a lazyCommandLineArgumentProviderthat stays configuration-cache safe. This is the single source of the PID file path;run-appitself no longer passes it.GrailsApp.run()registers Spring Boot'sApplicationPidFileWriteronly whengrails.cli.pid.fileis present, so the forked development app writes its own PID tobuild/run-app.pid.stop-appreadsbuild/run-app.pidand stops the process withProcessHandle.destroy()- a gracefulSIGTERMon Unix-like systems, so JVM shutdown hooks and Spring's orderly shutdown run - falling back todestroyForcibly()if it does not exit within the timeout.Because the handoff is a file on disk,
stop-appworks when the app is forked by GradlebootRunand whengrails stop-appis run from a different CLI invocation or terminal than the one that started it.Safety and compatibility
RunningApplicationProcessrejects missing, malformed, non-positive, stale, and likely-recycled PIDs (it compares the live process start time against the PID file timestamp).run-app.stoppingmarker (an empty, presence-only file) lets a foreground, blockinggrails run-appreport a deliberate stop asApplication stoppedinstead of a startup failure. It is purely a message classifier - shutdown itself is always the gracefuldestroy()- and it is cleared at the start of eachrun-appso it cannot mask a genuine startup failure.run-apprefuses, best-effort, to start a second app when one is already running for the project.--hostand--portoptions ofstop-apphave been removed; the command now fails fast with a clear message if they are supplied. This is documented as a breaking change in the Grails 7 upgrade notes.build/directory; customizing the Gradle build directory (layout.buildDirectory) is not supported bystop-app(documented).grails.cli.pid.fileis only set on the CLI/GradlebootRundevelopment path.Main changes
RunningApplicationProcessingrails-shell-cli.ApplicationPidFileWriterinGrailsApp.run()(extracted asconfigureCliPidFileWriter()) whengrails.cli.pid.fileis present.bootRunPID-file setup inGrailsGradlePlugin(RunAppPidFileProvider) as the single source of the PID path.run-appandstop-appprofile commands for the PID-file lifecycle.stop-appcommand reference.Tests
GrailsAppPidFileSpec-GrailsAppregistersApplicationPidFileWriteronly whengrails.cli.pid.fileis set.GrailsGradlePluginToolchainSpec-bootRunsuppliesbuild/run-app.pid(exact path) for bothgrails-appandgrails-webapplications, and ignores a CLI-supplied path.RunningApplicationProcessSpec- PID read/parse, liveness, stale and recycled-PID guards, stop withdestroyForciblyfallback, and the marker lifecycle.Verification
./gradlew :grails-core:test --tests grails.boot.GrailsAppPidFileSpec./gradlew :grails-shell-cli:test --tests org.grails.cli.gradle.RunningApplicationProcessSpec./gradlew :grails-gradle-plugins:test --tests org.grails.gradle.plugin.core.GrailsGradlePluginToolchainSpec./gradlew :grails-profiles-base:compileProfile