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 13 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
| mbean.shutdown() | ||
| console.addStatus "Application shutdown." | ||
| return true | ||
| if (org.grails.cli.gradle.RunningApplicationRegistry.stopAll()) { |
There was a problem hiding this comment.
Doesn't this only worked for non-forked processes?
There was a problem hiding this comment.
Correct - that was the core limitation. The previous version cancelled the in-memory Gradle build token, which only existed in the one CLI JVM, so it couldn't reach a forked app or an independent grails stop-app.
This is now fixed in a53817c: the forked bootRun app writes its own PID to build/run-app.pid (Spring Boot ApplicationPidFileWriter), and stop-app reads that file and stops the process with ProcessHandle.destroy() (graceful SIGTERM on Unix, best effort on Windows). Because the handoff is a file on disk, it works for forked processes and when stop-app is run from a different invocation/terminal than run-app. Details in the PR comment above.
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).
|
|
||
| * `port` - Specifies the port which the Grails application is running on (defaults to 8080 for HTTP or 8443 for HTTPS) | ||
| * `host` - Specifies the host the Grails application is bound to | ||
| This is a CLI only mechanism: it does not require the Spring Boot Actuator shutdown endpoint to be enabled, nor does it rely on JMX. |
There was a problem hiding this comment.
I don't think we need to explain this in the documentation.
There was a problem hiding this comment.
Agreed. I simplified the command reference so it now describes only the user-facing behavior: interactive/separate invocation usage, current-project run-app scope, unsupported host/port options, and the not-running result. The PID/Actuator/JMX implementation explanation is removed.
There was a problem hiding this comment.
Tried it out and got this:
mattias@mr-p920:~/Projects/tmp/grails-core/grails-shell-cli/build/install/grails-shell-cli$ ./bin/grails-shell-cli create-app myapp
Resolving dependencies...
| Application created at /home/mattias/Projects/tmp/grails-core/grails-shell-cli/build/install/grails-shell-cli/myapp
mattias@mr-p920:~/Projects/tmp/grails-core/grails-shell-cli/build/install/grails-shell-cli$ cd myapp
| Resolving Dependencies. Please wait...
| Starting interactive mode...
| Enter a command name to run. Use TAB for completion:
grails> run-app
| Running application.....
> <
> ____ _ _ <
> / ___|_ __ __ _(_) |___ <
> | | _| '__/ _` | | / __| <
> | |_| | | | (_| | | \__ \ <
> \____|_| \__,_|_|_|___/ <
> https://grails.apache.org <
> <
Grails application running at http://localhost:8080 in environment: development
<=======<=======<===========--> 85% EXECUTING [26s]
> :bootRun
grails> stop-app
| Shutti<===========--> 85% EXECUTING [1m 39s]
| Error Application not running. (Use --stacktrace to see the full trace)There was a problem hiding this comment.
Thanks for trying it. The failure path should be addressed now: bootRun itself now gets a default cli.pid.file under the Gradle build directory, and run-app still passes the explicit CLI path. That means the forked app writes the PID file stop-app reads, including from interactive mode and from a separate CLI invocation. I also added TestKit coverage for both the default bootRun PID path and the CLI override path.
|
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. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Assisted-by: opencode:gpt-5.5 oracle Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
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. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Assisted-by: opencode:gpt-5.5 oracle Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
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. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Assisted-by: opencode:gpt-5.5 oracle Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
|
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 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
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 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
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 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
|
Completed the configuration metadata follow-up.
Verification:
|
🚨 TestLens detected 38 failed tests 🚨Here is what you can do:
Test Summary
🏷️ Commit: 92957ff Test Failures (first 10 of 38)BookControllerSpec (:grails-test-examples-hibernate5-grails-hibernate:integrationTest in CI / Functional Tests (17))BookFongoSpec (:grails-test-examples-mongodb-hibernate5:test in CI / Functional Tests (17))BookUnitSpec (:grails-test-examples-mongodb-base:test in CI / Functional Tests (17))ChildPreferenceInheritedConfigSpec (:grails-test-examples-geb:integrationTest in CI / Functional Tests (17))ContainerFileDetectorAnnotationSpec (:grails-test-examples-geb:integrationTest in CI / Functional Tests (17))ContainerFileDetectorDefaultSpec (:grails-test-examples-geb:integrationTest in CI / Functional Tests (17))ContainerFileDetectorSpockSpec (:grails-test-examples-geb:integrationTest in CI / Functional Tests (17))DownloadSupportSpec (:grails-test-examples-geb:integrationTest in CI / Functional Tests (17))EndToEndSpec (:grails-test-examples-gsp-sitemesh3:integrationTest in CI / Functional Tests (17))EndToEndSpec (:grails-test-examples-gsp-layout:integrationTest in CI / Functional Tests (17))Muted Tests (first 20 of 38)Select tests to mute in this pull request:
Reuse successful test results:
Click the checkbox to trigger a rerun:
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 failed user-facing result is that
grails stop-appcan reportApplication not runningwhile arun-appprocess is still active.What this PR now does
This PR makes
run-appandstop-appshare a project-local PID file contract:bootRunis configured by the Grails Gradle plugin to setcli.pid.fileto<build>/run-app.pidby default.run-appstill passes an explicitgrails.cli.pid.filevalue so the CLI and forked app agree on the same PID file path.GrailsApp.run()registers Spring Boot'sApplicationPidFileWriteronly whencli.pid.fileis present.stop-appreads that PID file and stops the process withProcessHandle.destroy(), falling back todestroyForcibly()if it does not exit within the timeout.Because the handoff is on disk,
stop-appworks when the app is forked by GradlebootRunand whengrails stop-appis run from a different CLI invocation or terminal.Safety and compatibility
run-app.stoppingmarker lets a foregroundrun-appreport a deliberate stop asApplication stoppedinstead of as a startup failure.run-appclears stale stop markers before startup and refuses to start a second app when one is already running for the project.stop-app --hostandstop-app --portare retained as declared options but now fail fast with a clear unsupported-options message before any PID or run-app state is touched.cli.pid.fileis only used when supplied by the CLI/Gradle development path.Main changes
RunningApplicationProcessingrails-shell-cli.ApplicationPidFileWriterregistration inGrailsApp.run()whencli.pid.fileis present.bootRunPID-file setup inGrailsGradlePluginusingproject.layout.buildDirectory.file('run-app.pid'), while preserving an explicit CLI-supplied path.run-appandstop-appscripts for the PID-file lifecycle.stop-appdocs to describe user-facing behavior and removed internal implementation explanation.Verification
./gradlew :grails-gradle-plugins:test --tests org.grails.gradle.plugin.core.GrailsGradlePluginToolchainSpec./gradlew :grails-shell-cli:test --tests org.grails.cli.gradle.RunningApplicationProcessSpec./gradlew :grails-profiles-base:compileProfilesleepprocess throughRunningApplicationProcess.stop()and handled a malformed PID file asNOT_RUNNING.