Skip to content

Fix stop-app using a PID file instead of Actuator or JMX#15698

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

Fix stop-app using a PID file instead of Actuator or JMX#15698
jamesfredley wants to merge 17 commits into
7.0.xfrom
fix/stop-app-cli

Conversation

@jamesfredley

@jamesfredley jamesfredley commented May 29, 2026

Copy link
Copy Markdown
Contributor

Fixes #13695

Problem

stop-app no longer works reliably on modern Grails setups. The old command tried to stop an app through JMX or the Spring Boot Actuator shutdown endpoint:

  • JMX depends on attach/management-agent behavior that is not reliable on current JDKs.
  • Actuator shutdown is disabled by default and should not need to be enabled just so a development CLI command can stop run-app.

The user-facing symptom is that grails stop-app reports Application not running while a run-app process is still active.

What this PR does

run-app and stop-app now share a project-local PID file contract:

  1. The Grails Gradle plugin configures bootRun to pass -Dgrails.cli.pid.file=<build>/run-app.pid to the forked application JVM, via a lazy CommandLineArgumentProvider that stays configuration-cache safe. This is the single source of the PID file path; run-app itself no longer passes it.
  2. GrailsApp.run() registers Spring Boot's ApplicationPidFileWriter only when grails.cli.pid.file is present, so the forked development app writes its own PID to build/run-app.pid.
  3. stop-app reads build/run-app.pid and stops the process with ProcessHandle.destroy() - a graceful SIGTERM on Unix-like systems, so JVM shutdown hooks and Spring's orderly shutdown run - falling back to destroyForcibly() if it does not exit within the timeout.

Because the handoff is a file on disk, stop-app works when the app is forked by Gradle bootRun and when grails stop-app is run from a different CLI invocation or terminal than the one that started it.

Safety and compatibility

  • RunningApplicationProcess rejects missing, malformed, non-positive, stale, and likely-recycled PIDs (it compares the live process start time against the PID file timestamp).
  • A run-app.stopping marker (an empty, presence-only file) lets a foreground, blocking grails run-app report a deliberate stop as Application stopped instead of a startup failure. It is purely a message classifier - shutdown itself is always the graceful destroy() - and it is cleared at the start of each run-app so it cannot mask a genuine startup failure.
  • run-app refuses, best-effort, to start a second app when one is already running for the project.
  • The --host and --port options of stop-app have 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.
  • On Windows, termination is best-effort and a graceful shutdown is not guaranteed (documented).
  • The PID file follows the conventional build/ directory; customizing the Gradle build directory (layout.buildDirectory) is not supported by stop-app (documented).
  • Normally deployed applications are unaffected, because grails.cli.pid.file is only set on the CLI/Gradle bootRun development path.

Main changes

  • Replaced the old JMX/Actuator stop path with RunningApplicationProcess in grails-shell-cli.
  • Registered ApplicationPidFileWriter in GrailsApp.run() (extracted as configureCliPidFileWriter()) when grails.cli.pid.file is present.
  • Added the bootRun PID-file setup in GrailsGradlePlugin (RunAppPidFileProvider) as the single source of the PID path.
  • Updated the run-app and stop-app profile commands for the PID-file lifecycle.
  • Documented the change in the Grails 7 upgrade notes and trimmed the stop-app command reference.

Tests

  • GrailsAppPidFileSpec - GrailsApp registers ApplicationPidFileWriter only when grails.cli.pid.file is set.
  • GrailsGradlePluginToolchainSpec - bootRun supplies build/run-app.pid (exact path) for both grails-app and grails-web applications, and ignores a CLI-supplied path.
  • RunningApplicationProcessSpec - PID read/parse, liveness, stale and recycled-PID guards, stop with destroyForcibly fallback, 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

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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
Comment thread grails-profiles/base/commands/stop-app.groovy Outdated

@jdaugherty jdaugherty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
@jamesfredley

Copy link
Copy Markdown
Contributor Author

@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 grails stop-app. I've reworked the PR to your suggestion: write the application PID during run-app and have stop-app signal that PID directly.

What changed (pushed in a53817c)

Writing the PID (run-app side)

  • The forked bootRun application writes its own PID via Spring Boot's ApplicationPidFileWriter to build/run-app.pid. It's registered in GrailsApp.run() only when the cli.pid.file system property is present, so normally deployed apps are unaffected.
  • run-app passes that path as -Dgrails.cli.pid.file=<abs path>; the grails. prefix is stripped by GrailsGradlePlugin when it forwards properties into the forked JVM (same mechanism already used for grails.server.port -> server.port), so no Gradle plugin change was needed.

Stopping by PID (stop-app side)

  • New RunningApplicationProcess helper reads build/run-app.pid and stops the process via ProcessHandle.of(pid).destroy() - i.e. a graceful SIGTERM on Unix-like systems (JVM shutdown hooks / Spring orderly shutdown run), falling back to destroyForcibly() if it doesn't exit within the timeout. On Windows there is no graceful signal equivalent, so termination there is best effort (documented).
  • Includes a stale/recycled-PID guard (compares the live process startInstant against the PID file mtime) so it never kills an unrelated process that happens to have reused the id.

Because the contract is a file on disk, this now works for the two cases you called out: forked processes and grails stop-app run independently of the original run-app (including from a different terminal).

Other details

  • A small run-app.stopping marker lets a foreground run-app (blocked on bootRun) report a clean "Application stopped" instead of a startup failure when its process is killed by SIGTERM (non-zero exit). It's cleared at the start of each run-app so it can't mask a genuine startup failure.
  • A guard prevents a second run-app from orphaning an already-running app.
  • Reverted the GradleUtil/GradleInvoker cancellation-token changes and removed RunningApplicationRegistry; the JMX/Actuator code stays gone.
  • New RunningApplicationProcessSpec (13 tests: missing/malformed/non-positive/stale PID, live-process stop, marker lifecycle) plus updated docs.

I kept kill -TERM-equivalent semantics exactly as you described. Let me know if you'd prefer the PID file somewhere other than build/, or a different policy for the Windows best-effort path.

@matrei matrei left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Got unexpected output from stop-app (see comment).

Comment thread grails-doc/src/en/ref/Command Line/stop-app.adoc Outdated
Comment thread grails-profiles/base/commands/stop-app.groovy
@jamesfredley jamesfredley changed the title Fix stop-app in the interactive CLI without Actuator or JMX Fix stop-app using a PID file instead of Actuator or JMX Jun 4, 2026
@jdaugherty

Copy link
Copy Markdown
Contributor

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 doFirst { } (and if multiple are running, we could force a failure since it will fail with a port conflict anyhow). Then your stop app just reads that - Scott made a change to support running in different directories (usually work trees) so I think there's already a standardized way to access the build directory. I think the helper is BuildSettings or Settings.groovy.

@codeconsole

Copy link
Copy Markdown
Contributor

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
@jamesfredley

Copy link
Copy Markdown
Contributor Author

Updated the PR with three follow-up commits after the latest review:

  • e9afa4d11b - Configures bootRun in the Grails Gradle plugin to provide the default cli.pid.file at build/run-app.pid, while preserving the explicit CLI-supplied PID path from run-app. Added TestKit coverage for both paths.
  • ff748be2f7 - Simplifies the stop-app command docs to focus on user-facing behavior instead of PID/JMX/Actuator internals.
  • d9a96cf89a - Keeps legacy --host and --port declared but fails fast before any run-app state, stop marker, PID lookup, or process termination is touched.

Verification run locally:

  • ./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:compileProfile
  • Manual Groovy driver stopped a real sleep process via RunningApplicationProcess.stop() and treated a malformed PID file as NOT_RUNNING.

I also updated the PR description to match the current design and replied to the review threads about docs and the interactive run-app/stop-app failure.

@jamesfredley jamesfredley requested a review from jdaugherty June 11, 2026 00:29
@jamesfredley jamesfredley dismissed jdaugherty’s stale review June 11, 2026 00:29

Requested changes completed

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
@jamesfredley

Copy link
Copy Markdown
Contributor Author

Completed the configuration metadata follow-up.

  • Added grails.cli.pid.file to the Spring configuration metadata with default build/run-app.pid.
  • Added the Command Line metadata group to the generated application properties ordering.
  • Made run-app and stop-app honor the same configured PID path from command-line system properties, CLI JVM system properties, or application config.
  • Added PID path precedence and custom stop-path coverage.

Verification:

  • ./gradlew :grails-shell-cli:test --tests org.grails.cli.gradle.RunningApplicationProcessSpec
  • ./gradlew :grails-core:test --tests grails.dev.commands.ConfigReportCommandSpec

@matrei

matrei commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

I'm was expected to see a run-app.pid file generated in the build directory when running run-app, but I cannot see that it is generated. Am I missing something?

Still getting this from stop-app:

| Shutting down application...
| Error Application not running. (Use --stacktrace to see the full trace)

Comment thread grails-core/src/main/groovy/grails/boot/GrailsApp.groovy Outdated
Comment thread grails-core/src/main/resources/META-INF/spring-configuration-metadata.json Outdated
Comment thread grails-profiles/base/commands/run-app.groovy Outdated
Comment thread grails-profiles/base/commands/run-app.groovy
Comment thread grails-profiles/base/commands/run-app.groovy
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
@jamesfredley

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review, @jdaugherty and @matrei. Pushed 9a132a8 addressing the latest round of feedback. Summary of changes:

Property scoping

  • The PID system property is now grails.cli.pid.file (was the unscoped cli.pid.file). It is read in GrailsApp.run() and set directly on the bootRun task by GrailsGradlePlugin under the same scoped name.

Gradle plugin lifecycle

  • configureBootRunPidFile no longer uses afterEvaluate; it uses project.pluginManager.withPlugin('org.springframework.boot') + tasks.withType(BootRun).configureEach.
  • The PID path is resolved lazily at execution time through a CommandLineArgumentProvider (RunAppPidFileProvider), mirroring the existing GrailsAppBaseDirProvider in the same file. This keeps the build-directory resolution out of configuration time and stays configuration-cache safe.

Hard-coded, single source of truth

  • The PID file is hard-coded to build/run-app.pid. Removed the command-line / system-property / application-config override variants from run-app, stop-app, and RunningApplicationProcess, so the producer (bootRun) and the consumers (run-app's already-running guard and stop-app) always agree on one location.
  • run-app no longer passes -Dgrails.cli.pid.file; the Gradle plugin is the only place that configures it.

Docs / metadata

  • Removed the internal grails.cli.pid.file property and the Command Line group from spring-configuration-metadata.json (and the matching grails-doc ordering). It is internal CLI/Gradle wiring, not user configuration.

Stop marker (kept, with rationale)

  • Kept the run-app.stopping marker. stop-app calls ProcessHandle.destroy() on only the forked application JVM, not the Gradle process, so the bootRun build exits with a non-zero child status (e.g. 143 on Unix) rather than cancelling like a Ctrl+C of the whole build. A foreground / separate-terminal run-app blocked on that build needs the marker to report a clean stop instead of a startup failure. This is now documented in stop-app.groovy and RunningApplicationProcess.

Tests

  • GrailsGradlePluginToolchainSpec: verifies bootRun supplies the default build/run-app.pid and ignores a CLI-supplied path (hard-coded).
  • RunningApplicationProcessSpec: trimmed the now-removed override tests; retained PID read / liveness / stop / marker coverage.

Verified

  • ./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-core:compileGroovy :grails-profiles-base:compileProfile
  • ./gradlew :grails-doc:generateConfigReference

Comment thread grails-doc/src/en/ref/Command Line/stop-app.adoc
Comment thread grails-profiles/base/commands/run-app.groovy

@codeconsole codeconsole left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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-apprun-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): the buildDir binding, which is BuildSettings.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() registers ApplicationPidFileWriter only when grails.cli.pid.file is set — the producer side in grails-core is untested (most worth adding).
  • The PID‑reuse guard isProbablyReusedPid has no test.
  • STILL_RUNNING and the destroyForcibly fallback aren't exercised (sleep dies on SIGTERM).
  • The stop-app host/port fail‑fast and the run-app marker 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(), but isStopRequested only checks existence — the timestamp is dead data (either honor it to ignore stale markers, or write an empty file).
  • stop-app leaves the marker on disk after STOPPED (cleared only by the next run-app or a later NOT_RUNNING); clearing it on STOPPED too would be tidier.
  • Behavior change worth a doc line: every ./gradlew bootRun now writes build/run-app.pid and registers the PID writer, not just CLI run-app. Benign/arguably useful, but new.
  • Agree with jdaugherty: the --host/--port removal is a breaking change that belongs in upgrade/release notes, with the command reference simply not documenting them.

Things verified as fine

  • The earlier RunningApplicationRegistry iteration 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 wedge run-app/stop-app.
  • RunAppPidFileProvider mirrors the existing GrailsAppBaseDirProvider (lazy Provider, @Internal fields) and stays configuration‑cache safe.
  • Process model is consistent: the writer records the application JVM PID; stop-app TERMs 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:

  1. Re‑confirm matrei's end‑to‑end repro on Unix (interactive and separate‑terminal).
  2. Resolve or explicitly constrain the buildDir‑vs‑layout.buildDirectory mismatch (Finding 2).
  3. Document the Windows "not graceful" consequence (Finding 4).
  4. Ideally add a GrailsApp producer‑side test (Finding 6).
  5. 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
@jamesfredley

Copy link
Copy Markdown
Contributor Author

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

  • Producer-side test - new GrailsAppPidFileSpec proves GrailsApp registers Spring Boot's ApplicationPidFileWriter only when grails.cli.pid.file is set (and not when it is absent/blank). Extracted GrailsApp.configureCliPidFileWriter() to make this directly testable.
  • End-to-end producer coverage - the TestKit specs now assert the exact resolved path equals <projectDir>/build/run-app.pid, and a new grails-web test project (boot-run-pid-web) proves bootRun supplies the PID arg for a generated-style web app, not just grails-app.
  • Recycled-PID guard tests - added isRunning/stop tests for RunningApplicationProcess that backdate the PID file so the live process started after it, exercising isProbablyReusedPid.
  • Marker cleanup - run-app.stopping is now an empty, presence-only file (it previously stored an unused System.currentTimeMillis()), with comments clarifying it is purely a foreground message classifier, unrelated to the graceful ProcessHandle.destroy() shutdown.
  • Docs - moved the --host/--port removal into a dedicated Grails 7 upgrade note (12.27 stop-app no longer uses JMX or the Actuator shutdown endpoint) that also documents the Windows best-effort shutdown, the build/run-app.pid behavior, and the default-build-directory constraint. The command reference was trimmed accordingly.

Verified locally (all green):

  • :grails-core:test --tests grails.boot.GrailsAppPidFileSpec
  • :grails-shell-cli:test --tests org.grails.cli.gradle.RunningApplicationProcessSpec
  • :grails-gradle-plugins:test --tests org.grails.gradle.plugin.core.GrailsGradlePluginToolchainSpec
  • :grails-profiles-base:compileProfile

Response to the review findings

1 - End-to-end loop (blocking). The wiring is now proven by automated tests on the producer side: bootRun supplies -Dgrails.cli.pid.file=<build>/run-app.pid for both grails-app and grails-web apps, and GrailsApp registers the writer when that property is present. org.apache.grails.gradle.grails-web extends GrailsGradlePlugin, which applies the Spring Boot plugin programmatically before configureBootRunPidFile, so withPlugin('org.springframework.boot') always fires. Spring Boot's ApplicationPidFileWriter writes on ApplicationPreparedEvent, which fires during a normal startup. @matrei - your earlier transcript was on the pre-rework commit; would you mind re-running on Linux against this commit? Repro: create-app -> cd app -> grails run-app, confirm build/run-app.pid appears, then grails stop-app from the same or a separate terminal.

2 - build-dir mismatch. Resolved as a documented constraint (the pragmatic call rather than having the CLI parse Gradle state). The producer uses project.layout.buildDirectory and the consumer uses BuildSettings.TARGET_DIR (<projectDir>/build); these coincide for the default build directory, and customizing the Gradle build directory is explicitly called out as unsupported in the plugin comment and the upgrade note.

3 - already-running guard is racy. Documented as best-effort in run-app.groovy; with the default fixed port a second run-app simply fails on a port conflict.

4 - Windows not graceful. Documented in both stop-app.adoc and the upgrade note: Linux/macOS shut down gracefully (SIGTERM), Windows is best-effort and graceful shutdown is not guaranteed.

5 - interactive marker / async path. Confirmed and documented: in interactive mode run-app returns after the server is up, so the marker only helps a foreground grails run-app. The marker comments now say exactly this. Fully suppressing the background build-failure line in interactive mode would require a Tooling-API build cancellation tied to stop-app - i.e. the in-memory registry that was removed earlier at @jdaugherty's request - so I left it out by design; the app is genuinely stopped and the line is cosmetic.

6 - test gaps. Added the highest-value ones: the GrailsApp producer-side registration test, the recycled-PID guard, and exact-path/grails-web TestKit coverage. STILL_RUNNING/destroyForcibly are intentionally not unit-tested - a process that survives both SIGTERM and SIGKILL within the timeout cannot be simulated portably. The stop-app --host/--port fail-fast and the run-app marker branch live in the profile command scripts (CLI integration surface); the underlying RunningApplicationProcess they delegate to is covered.

7 - polish. Dead timestamp removed (empty marker). The marker is deliberately not cleared on STOPPED: a foreground run-app blocked on the build may not have observed the terminated process yet, so clearing it there would race that check - it is cleared at the start of the next run-app instead (now commented). The "every bootRun writes build/run-app.pid" behavior change and the --host/--port removal are both in the upgrade note.

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 grails stop-app run from a separate invocation/terminal without enabling a shutdown endpoint, and it avoids depending on attach/management-agent behavior. The Windows graceful-shutdown gap is the one place the Actuator route would have been better cross-platform; that limitation is now documented rather than blocking.

@codeconsole

Copy link
Copy Markdown
Contributor

Verified on Linux — the full loop works, including the exact cross-invocation case matrei
reported as broken.

Faithful repro in a JDK-17 Debian container (the PR's own etc/bin/Dockerfile), against the locally-published
7.0.12-SNAPSHOT stack:

[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)
App JVM (pid=4013) alive? yes → Grails banner, :bootRun 85% EXECUTING

[4] stop-app (separate invocation = "second terminal")
| Stopping application...
| Application stopped. exit 0 ← consumer link

[5] App JVM gone after stop-app? yes
pid file removed
RESULT=PASS

@codeconsole

Copy link
Copy Markdown
Contributor

Interactive mode is now verified too. Here's exactly what a user sees at the grails> prompt:

  grails> run-app
    ... <===> 85% EXECUTING :bootRun
    Grails application running at http://localhost:8080 in environment: development
    grails>                                    ← prompt returns (async), app is up
          (build/run-app.pid present: -rw-r--r-- 4 bytes)   ← PID written while running

  grails> stop-app
    BUILD FAILED in 7s                         ← benign: the background bootRun build ends (app JVM got SIGTERM)
    | Application stopped.                      ← clean stop message
          (build/run-app.pid → removed)
  grails> exit

@codeconsole codeconsole left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@jdaugherty

Copy link
Copy Markdown
Contributor

the Build Failed message means its not gracefully shutdown...

@codeconsole

Copy link
Copy Markdown
Contributor

the Build Failed message means its not gracefully shutdown...

that's not good then.

@codeconsole codeconsole self-requested a review June 18, 2026 20:29
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.
@codeconsole

Copy link
Copy Markdown
Contributor

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

@testlens-app

testlens-app Bot commented Jun 19, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: c526500
▶️ Tests: 40767 executed
⚪️ Checks: 33/33 completed


Learn more about TestLens at testlens.app.

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

5 participants