Skip to content

Fix stop-app in the interactive CLI without Actuator or JMX#15698

Open
jamesfredley wants to merge 3 commits into
7.0.xfrom
fix/stop-app-cli
Open

Fix stop-app in the interactive CLI without Actuator or JMX#15698
jamesfredley wants to merge 3 commits into
7.0.xfrom
fix/stop-app-cli

Conversation

@jamesfredley
Copy link
Copy Markdown
Contributor

Fixes #13695

Problem

stop-app in the interactive Grails shell does not work. The base profile stop-app command tries two mechanisms, both of which fail on a modern setup:

  1. JMX via com.sun.tools.attach.VirtualMachine, loading management-agent.jar - that jar was removed in Java 9+, and the code uses javax.*. This silently fails on JDK 17/21.
  2. HTTP fallback that POSTs to the Spring Boot Actuator /actuator/shutdown endpoint - disabled by default, so it throws FileNotFoundException (HTTP 404), reporting "Application not running" even though it is.

Enabling the Actuator shutdown endpoint is not an acceptable fix: it is intrusive and affects production for the sake of a development-time stop-app.

Approach (CLI only, no Actuator, no JMX)

In the interactive shell, run-app starts the application as an asynchronous Gradle bootRun build. That build already has a Gradle Tooling API CancellationTokenSource - it is what CTRL-C uses to stop the app. The problem is that the token was only reachable through the run-app command's ExecutionContext, so stop-app (a separate ExecutionContext in the same CLI JVM) had no handle to it.

This PR introduces a small shared registry so stop-app can cancel the running build directly, without POOL.shutdownNow() (which would kill the executor and block re-running) and without exiting the CLI.

Changes

  • New RunningApplicationRegistry (grails-shell-cli) - tracks the CancellationTokenSource of running run-app builds. stopAll() only requests cancellation; the build deregisters its own token when it finishes. awaitStop(timeout) waits for the build to tear down.
  • GradleUtil - wireCancellationSupport now returns the token source, created via the public GradleConnector.newCancellationTokenSource() instead of the internal DefaultCancellationTokenSource. A new runBuildWithConsoleOutput(context, trackForStop, closure) overload registers/deregisters the token around the build.
  • GradleInvoker - tracks only the bootRun task (matches bootRun or :bootRun), so transient builds (compile, test, console) are not affected.
  • stop-app.groovy - rewritten to cancel via the registry and wait for shutdown; obsolete port/host flags removed.
  • run-app.groovy - no longer passes -Dgrails.management.endpoints.shutdown.enabled=true (only existed to enable the Actuator endpoint); the JVM shutdown hook now calls RunningApplicationRegistry.stopAll() directly instead of re-invoking the stop-app command.
  • Docs - updated stop-app reference and the getting-started running/debugging guide.

Tests

  • RunningApplicationRegistrySpec covers register/deregister, stopAll requesting cancellation without removing tokens, resilience when a token throws, and awaitStop success/timeout.
  • ./gradlew :grails-shell-cli:test --tests "org.grails.cli.gradle.RunningApplicationRegistrySpec" passes.

Manual verification still recommended

The forked application JVM is torn down by Gradle when the bootRun build is cancelled (the same path CTRL-C uses). A manual run of grails run-app -> grails stop-app -> grails run-app on JDK 21 (Windows and Linux) is recommended before merge to confirm no orphaned process and that the port is freed.

Notes

  • This is a draft for review of the approach on 7.0.x. The linked issue is milestoned for 8.0.0-M3; opening here first per discussion.

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
@jamesfredley jamesfredley marked this pull request as ready for review May 29, 2026 04:34
Copilot AI review requested due to automatic review settings May 29, 2026 04:34
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 RunningApplicationRegistry tracks running bootRun cancellation tokens; stop-app.groovy is rewritten to cancel via the registry and awaitStop, dropping the JMX/HTTP shutdown code and the port/host flags.
  • GradleUtil.wireCancellationSupport now uses the public GradleConnector.newCancellationTokenSource() and returns the token; a new runBuildWithConsoleOutput(context, trackForStop, closure) overload registers/deregisters tokens around the build.
  • GradleInvoker opts only the bootRun/:bootRun task into tracking; run-app.groovy drops -Dgrails.management.endpoints.shutdown.enabled=true and the shutdown hook calls RunningApplicationRegistry.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
@jamesfredley jamesfredley self-assigned this May 29, 2026
@jamesfredley jamesfredley moved this to In Progress in Apache Grails May 29, 2026
@jamesfredley jamesfredley added this to the grails:7.0.12 milestone May 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

Fix stop-app in grails cli

2 participants