diff --git a/grails-doc/src/en/guide/gettingStarted/runningAndDebuggingAnApplication.adoc b/grails-doc/src/en/guide/gettingStarted/runningAndDebuggingAnApplication.adoc index 3e2835e24bc..bd142f4c1b9 100644 --- a/grails-doc/src/en/guide/gettingStarted/runningAndDebuggingAnApplication.adoc +++ b/grails-doc/src/en/guide/gettingStarted/runningAndDebuggingAnApplication.adoc @@ -39,8 +39,8 @@ $ grails grails> run-app | Grails application running at http://localhost:8080 in environment: development grails> stop-app -| Shutting down application... -| Application shutdown. +| Stopping application... +| Application stopped. grails> run-app | Grails application running at http://localhost:8080 in environment: development ---- diff --git a/grails-doc/src/en/ref/Command Line/stop-app.adoc b/grails-doc/src/en/ref/Command Line/stop-app.adoc index 94676b6c6ee..890e9ecf10c 100644 --- a/grails-doc/src/en/ref/Command Line/stop-app.adoc +++ b/grails-doc/src/en/ref/Command Line/stop-app.adoc @@ -25,9 +25,9 @@ under the License. === Purpose -Stops a running Grails application in an embedded servlet container. +Stops a Grails application that was started with the link:{commandLineRef}run-app.html[run-app] command in the same interactive CLI session. -NOTE: This command will work in development mode only. +NOTE: This command works in development mode only and must be run from the same interactive `grails` session that started the application with `run-app`. === Examples @@ -35,20 +35,17 @@ NOTE: This command will work in development mode only. [source,groovy] ---- -grails stop-app -grails stop-app --port=9090 --host=mywebsite +$ grails +grails> run-app +grails> stop-app ---- === Description -Arguments: +When an application is started with `run-app` in the interactive Grails shell, the application runs as an asynchronous Gradle `bootRun` build. The `stop-app` command cancels that build, which shuts down the running application without exiting the CLI. A subsequent `run-app` can then start the application again. -* `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. -Supported system properties: - -* `server.port` - Same as `port` argument. -* `server.address` - Same as `host` argument. +If no application started by `run-app` is running in the current session, `stop-app` reports that the application is not running. diff --git a/grails-profiles/base/commands/run-app.groovy b/grails-profiles/base/commands/run-app.groovy index 3ef3cccb2c6..e1a44786cb3 100644 --- a/grails-profiles/base/commands/run-app.groovy +++ b/grails-profiles/base/commands/run-app.groovy @@ -22,8 +22,6 @@ try { arguments << '--quiet' } - arguments << '-Dgrails.management.endpoints.shutdown.enabled=true' - arguments.addAll commandLine.remainingArgs Integer port = flag('port')?.toInteger() ?: config.getProperty('server.port', Integer) @@ -106,7 +104,7 @@ try { addShutdownHook { if(Boolean.getBoolean("run-app.running")) { try { - stopApp() + org.grails.cli.gradle.RunningApplicationRegistry.stopAll() } catch(e) { // ignore diff --git a/grails-profiles/base/commands/stop-app.groovy b/grails-profiles/base/commands/stop-app.groovy index 7ecfc1e4c3c..757665a21d1 100644 --- a/grails-profiles/base/commands/stop-app.groovy +++ b/grails-profiles/base/commands/stop-app.groovy @@ -1,101 +1,22 @@ -import javax.management.remote.JMXServiceURL -import javax.management.remote.JMXConnectorFactory -import javax.management.ObjectName -import org.grails.io.support.* -import groovy.jmx.GroovyMBean - description("Stops the running Grails application") { usage "grails stop-app" synonyms 'stop' - flag name:'port', description:"Specifies the port which the Grails application is running on (defaults to 8080 or 8443 for HTTPS)" - flag name:'host', description:"Specifies the host the Grails application is bound to" -} -System.setProperty("run-app.running", "false") -def getJMXLocalConnectorAddresses = {-> - final applicationMainClassName = MainClassFinder.findMainClass() - - if(applicationMainClassName) { - try { - final String CONNECTOR_ADDRESS = "com.sun.management.jmxremote.localConnectorAddress" - def VirtualMachine = getClass().classLoader.loadClass('com.sun.tools.attach.VirtualMachine') - return VirtualMachine.list() - .findAll { - it.displayName() == applicationMainClassName - } - .collect { desc -> - def vm = VirtualMachine.attach(desc.id()) - try { - def connectorAddress = vm.agentProperties.getProperty(CONNECTOR_ADDRESS) - if (connectorAddress == null) { - // Trying to load agent - def agent = [vm.systemProperties.getProperty("java.home"), "lib", "management-agent.jar"].join(File.separator) - vm.loadAgent(agent) - - connectorAddress = vm.agentProperties.getProperty(CONNECTOR_ADDRESS) - } - if (connectorAddress) { - return connectorAddress - } - } finally { - vm.detach() - } - }.findAll { it } - - } - catch(Throwable e) { - // fallback to REST request if JMX not available - } - } } +System.setProperty("run-app.running", "false") -def addresses = getJMXLocalConnectorAddresses() -if(addresses) { - JMXServiceURL url = new JMXServiceURL(addresses[0]) - def connector = JMXConnectorFactory.connect(url) - - try { - def server = connector.MBeanServerConnection +console.updateStatus "Stopping application..." - def objectName = server.queryNames(null,null).find { it.canonicalName.contains('name=shutdownEndpoint,type=Endpoint') } - def mbean = new GroovyMBean(server, objectName) - console.addStatus "Shutting down application..." - mbean.shutdown() - console.addStatus "Application shutdown." - return true +if (org.grails.cli.gradle.RunningApplicationRegistry.stopAll()) { + if (org.grails.cli.gradle.RunningApplicationRegistry.awaitStop(30000)) { + console.updateStatus "Application stopped." } - catch(e) { - console.addStatus "Application not found via JMX, attempting remote shutdown." + else { + console.updateStatus "Application shutdown requested; it may still be stopping." } - finally { - connector.close() - } -} - - -Integer port = flag('port')?.toInteger() ?: config.getProperty('server.port', Integer) ?: 8080 -String host = flag('host') ?: config.getProperty('server.address', String) ?: "localhost" -String contextPath = config.getProperty('server.context-path') ?: config.getProperty('server.contextPath') ?: "" -String managementPath = config.getProperty('management.endpoints.web.base-path') ?: config.getProperty('management.endpoints.web.basePath') ?: "/actuator" -console.updateStatus "Shutting down application..." -def url = new URL("http://$host:${port}${contextPath}${managementPath}/shutdown") -try { - def connection = url.openConnection() - connection.setRequestMethod("POST") - connection.doOutput = true - connection.connect() - console.updateStatus connection.content.text - while(isServerAvailable(host, port)) { - sleep 100 - } - console.updateStatus "Application shutdown." - return true - + return true } -catch (e) { - console.error "Application not running.", e - return false +else { + console.error "Application not running." + return false } - - - diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/gradle/GradleInvoker.groovy b/grails-shell-cli/src/main/groovy/org/grails/cli/gradle/GradleInvoker.groovy index 47d3264b7d4..cefc8a8bba5 100644 --- a/grails-shell-cli/src/main/groovy/org/grails/cli/gradle/GradleInvoker.groovy +++ b/grails-shell-cli/src/main/groovy/org/grails/cli/gradle/GradleInvoker.groovy @@ -45,7 +45,12 @@ class GradleInvoker { Object invokeMethod(String name, Object args) { Object[] argArray = (Object[]) args - GradleUtil.runBuildWithConsoleOutput(executionContext) { BuildLauncher buildLauncher -> + // Track only the long running application task launched by run-app so that + // stop-app can cancel it; this excludes transient builds such as compile or test + String taskName = name.split(' ')[0] + boolean trackForStop = taskName == 'bootRun' || taskName.endsWith(':bootRun') + + GradleUtil.runBuildWithConsoleOutput(executionContext, trackForStop) { BuildLauncher buildLauncher -> buildLauncher.forTasks(name.split(' ')) List arguments = [] arguments << "-Dgrails.env=${Environment.current.name}".toString() diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/gradle/GradleUtil.groovy b/grails-shell-cli/src/main/groovy/org/grails/cli/gradle/GradleUtil.groovy index b5567b398f5..828ed8d46fd 100644 --- a/grails-shell-cli/src/main/groovy/org/grails/cli/gradle/GradleUtil.groovy +++ b/grails-shell-cli/src/main/groovy/org/grails/cli/gradle/GradleUtil.groovy @@ -26,10 +26,10 @@ import groovy.transform.stc.SimpleType import org.gradle.tooling.BuildAction import org.gradle.tooling.BuildActionExecuter import org.gradle.tooling.BuildLauncher +import org.gradle.tooling.CancellationTokenSource import org.gradle.tooling.GradleConnector import org.gradle.tooling.LongRunningOperation import org.gradle.tooling.ProjectConnection -import org.gradle.tooling.internal.consumer.DefaultCancellationTokenSource import grails.build.logging.GrailsConsole import grails.io.support.SystemOutErrCapturer @@ -95,12 +95,28 @@ class GradleUtil { static void runBuildWithConsoleOutput(ExecutionContext context, @ClosureParams(value = SimpleType, options = 'org.gradle.tooling.BuildLauncher') Closure buildLauncherCustomizationClosure) { + // workaround for GROOVY-7211, static type checking problem when default parameters are used + runBuildWithConsoleOutput(context, false, buildLauncherCustomizationClosure) + } + + static void runBuildWithConsoleOutput(ExecutionContext context, boolean trackForStop, + @ClosureParams(value = SimpleType, options = 'org.gradle.tooling.BuildLauncher') Closure buildLauncherCustomizationClosure) { withProjectConnection(context.getBaseDir(), DEFAULT_SUPPRESS_OUTPUT) { ProjectConnection projectConnection -> BuildLauncher launcher = projectConnection.newBuild() setupConsoleOutput(context, launcher) - wireCancellationSupport(context, launcher) - buildLauncherCustomizationClosure.call(launcher) - launcher.run() + CancellationTokenSource cancellationTokenSource = wireCancellationSupport(context, launcher) + if (trackForStop) { + RunningApplicationRegistry.register(cancellationTokenSource) + } + try { + buildLauncherCustomizationClosure.call(launcher) + launcher.run() + } + finally { + if (trackForStop) { + RunningApplicationRegistry.deregister(cancellationTokenSource) + } + } } } @@ -136,11 +152,12 @@ class GradleUtil { return buildActionExecuter.run() } - static wireCancellationSupport(ExecutionContext context, BuildLauncher buildLauncher) { - DefaultCancellationTokenSource cancellationTokenSource = new DefaultCancellationTokenSource() + static CancellationTokenSource wireCancellationSupport(ExecutionContext context, BuildLauncher buildLauncher) { + CancellationTokenSource cancellationTokenSource = GradleConnector.newCancellationTokenSource() buildLauncher.withCancellationToken(cancellationTokenSource.token()) context.addCancelledListener({ cancellationTokenSource.cancel() }) + return cancellationTokenSource } } diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/gradle/RunningApplicationRegistry.groovy b/grails-shell-cli/src/main/groovy/org/grails/cli/gradle/RunningApplicationRegistry.groovy new file mode 100644 index 00000000000..ae8bb53a484 --- /dev/null +++ b/grails-shell-cli/src/main/groovy/org/grails/cli/gradle/RunningApplicationRegistry.groovy @@ -0,0 +1,139 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.cli.gradle + +import java.util.concurrent.ConcurrentHashMap + +import groovy.transform.CompileStatic + +import org.gradle.tooling.CancellationTokenSource + +import grails.build.logging.GrailsConsole + +/** + * Tracks the {@link CancellationTokenSource} of Grails applications started by the + * CLI via the {@code run-app} command (an asynchronous Gradle {@code bootRun} build). + * + *

This allows the {@code stop-app} command to cancel the underlying Gradle build + * without exiting the interactive CLI, providing a CLI only shutdown mechanism that + * does not rely on the Spring Boot Actuator shutdown endpoint or JMX.

+ * + *

A {@code run-app} build registers its token before the build runs and removes it + * when the build finishes (whether it completes normally, fails, or is cancelled). + * {@link #stopAll()} only requests cancellation; it never removes tokens directly so + * that the build remains responsible for its own lifecycle.

+ * + * @author Apache Grails Team + * @since 7.0.0 + */ +@CompileStatic +class RunningApplicationRegistry { + + private static final Set RUNNING = ConcurrentHashMap.newKeySet() + + // Monitor used to wake up awaitStop() the moment the last running build deregisters, + // instead of polling. Only deregister() can empty RUNNING, so only it needs to notify. + private static final Object MONITOR = new Object() + + private RunningApplicationRegistry() { + } + + /** + * Registers the cancellation token source of a running application. + * + * @param tokenSource the token source backing the running build + */ + static void register(CancellationTokenSource tokenSource) { + if (tokenSource != null) { + RUNNING.add(tokenSource) + } + } + + /** + * Removes a previously registered cancellation token source. This should be called + * by the build once it has finished, regardless of how it terminated. + * + * @param tokenSource the token source to remove + */ + static void deregister(CancellationTokenSource tokenSource) { + if (tokenSource != null) { + RUNNING.remove(tokenSource) + synchronized (MONITOR) { + MONITOR.notifyAll() + } + } + } + + /** + * @return {@code true} if at least one application started via {@code run-app} is running + */ + static boolean isApplicationRunning() { + !RUNNING.isEmpty() + } + + /** + * Requests cancellation of every running application. The registered token sources are + * not removed here; each running build removes its own token when it terminates. + * + * @return {@code true} if at least one running application was found and cancellation requested + */ + static boolean stopAll() { + if (RUNNING.isEmpty()) { + return false + } + // Snapshot to avoid surprises if a build deregisters concurrently while we iterate + List tokenSources = new ArrayList<>(RUNNING) + for (CancellationTokenSource tokenSource : tokenSources) { + try { + tokenSource.cancel() + } + catch (Throwable e) { + GrailsConsole.getInstance().verbose("Failed to request cancellation of a running application: ${e.message}") + } + } + return true + } + + /** + * Waits up to the given timeout for all running applications to terminate, i.e. for the + * cancelled builds to finish tearing down and deregister their token sources. + * + * @param timeoutMillis the maximum time to wait in milliseconds + * @return {@code true} if all applications stopped within the timeout, {@code false} otherwise + */ + static boolean awaitStop(long timeoutMillis) { + long deadline = System.currentTimeMillis() + timeoutMillis + synchronized (MONITOR) { + while (!RUNNING.isEmpty()) { + long remaining = deadline - System.currentTimeMillis() + if (remaining <= 0) { + return false + } + try { + MONITOR.wait(remaining) + } + catch (InterruptedException e) { + Thread.currentThread().interrupt() + return RUNNING.isEmpty() + } + } + return true + } + } +} diff --git a/grails-shell-cli/src/test/groovy/org/grails/cli/gradle/RunningApplicationRegistrySpec.groovy b/grails-shell-cli/src/test/groovy/org/grails/cli/gradle/RunningApplicationRegistrySpec.groovy new file mode 100644 index 00000000000..54d0d610077 --- /dev/null +++ b/grails-shell-cli/src/test/groovy/org/grails/cli/gradle/RunningApplicationRegistrySpec.groovy @@ -0,0 +1,142 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.cli.gradle + +import org.gradle.tooling.CancellationTokenSource +import spock.lang.Specification + +/** + * Tests for {@link RunningApplicationRegistry}, the CLI only mechanism that allows + * stop-app to cancel a running run-app build. + */ +class RunningApplicationRegistrySpec extends Specification { + + List created = [] + + private CancellationTokenSource newToken() { + CancellationTokenSource token = Mock(CancellationTokenSource) + created << token + token + } + + void cleanup() { + // Defensively clear the static registry so feature methods do not pollute each other + created.each { RunningApplicationRegistry.deregister(it) } + created.clear() + } + + void "isApplicationRunning reflects registered tokens"() { + expect: "no application is running initially" + !RunningApplicationRegistry.isApplicationRunning() + + when: "a token is registered" + CancellationTokenSource token = newToken() + RunningApplicationRegistry.register(token) + + then: "an application is reported as running" + RunningApplicationRegistry.isApplicationRunning() + + when: "the token is deregistered" + RunningApplicationRegistry.deregister(token) + + then: "no application is running" + !RunningApplicationRegistry.isApplicationRunning() + } + + void "register and deregister ignore null tokens"() { + when: + RunningApplicationRegistry.register(null) + + then: + !RunningApplicationRegistry.isApplicationRunning() + + when: + RunningApplicationRegistry.deregister(null) + + then: + noExceptionThrown() + } + + void "stopAll returns false when no applications are running"() { + expect: + !RunningApplicationRegistry.stopAll() + } + + void "stopAll requests cancellation of all registered tokens without removing them"() { + given: "two registered tokens" + CancellationTokenSource token1 = newToken() + CancellationTokenSource token2 = newToken() + RunningApplicationRegistry.register(token1) + RunningApplicationRegistry.register(token2) + + when: "all applications are stopped" + boolean result = RunningApplicationRegistry.stopAll() + + then: "cancellation is requested on each token" + result + 1 * token1.cancel() + 1 * token2.cancel() + + and: "tokens remain registered until the build deregisters them" + RunningApplicationRegistry.isApplicationRunning() + } + + void "stopAll keeps cancelling remaining tokens when one throws"() { + given: + CancellationTokenSource failing = newToken() + CancellationTokenSource healthy = newToken() + RunningApplicationRegistry.register(failing) + RunningApplicationRegistry.register(healthy) + + when: + boolean result = RunningApplicationRegistry.stopAll() + + then: "a failure cancelling one token does not stop the others" + result + 1 * failing.cancel() >> { throw new RuntimeException("boom") } + 1 * healthy.cancel() + } + + void "awaitStop returns true once all tokens are deregistered"() { + given: + CancellationTokenSource token = newToken() + RunningApplicationRegistry.register(token) + + when: "the token is deregistered shortly after the wait begins" + Thread.start { + sleep 200 + RunningApplicationRegistry.deregister(token) + } + boolean stopped = RunningApplicationRegistry.awaitStop(5000) + + then: + stopped + !RunningApplicationRegistry.isApplicationRunning() + } + + void "awaitStop returns false when tokens are not deregistered within the timeout"() { + given: + CancellationTokenSource token = newToken() + RunningApplicationRegistry.register(token) + + expect: + !RunningApplicationRegistry.awaitStop(300) + RunningApplicationRegistry.isApplicationRunning() + } +}