You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@grails.apache.org by "jdaugherty (via GitHub)" <gi...@apache.org> on 2026/10/06 02:54:53 UTC

[PR] Add a startup progress page and startup report [grails-core]

jdaugherty opened a new pull request, #16536:
URL: https://github.com/apache/grails-core/pull/16536

   ## The problem
   
   A Grails application can take a long time to start: plugins contribute their bean definitions, GORM builds its session factory, GSPs compile, `BootStrap` seeds data, and database migrations run. For all of that time `./gradlew bootRun` leaves port 8080 closed. A developer who opens the application gets a refused connection and keeps refreshing, with no idea how far the start has got, which part is slow, or, when it fails, why. The failure only shows up in the console, and a DevTools restart leaves the same gap.
   
   ## What this adds
   
   With the new `grails-startup-progress` module, the application's port answers from the moment the application context is prepared:
   
   - **A progress page.** A browser opening the application sees the stage the start has reached (preparing the context, loading plugins and bean definitions, creating beans, starting the web server, running plugin startup and `BootStrap`), how many beans have been created, the bean being created now, and the slowest beans so far, timed without their dependencies so the actual culprit stands out. The page reloads into the address it was opened at once the application is ready, which is **after `BootStrap` has run**, not merely once Tomcat is listening.
   - **Failures on the page.** If the start fails, the page shows the exception and stack trace, including failures in `BootStrap` and while the web server starts, and stays open to follow the next start. The process still exits as it always has.
   - **Progress from your own code.** `grails.boot.StartupTask` lets applications and plugins report long startup work, such as loading reference data, as "n of m, now on X". It records through Spring's own `ApplicationStartup`, so it costs nothing when nothing records the start, and Spring Boot Actuator's `startup` endpoint sees it too. The database migration plugins (Hibernate 5 and 7) use it to show how many change sets are left while `updateOnStart` runs.
   - **A startup report.** Once running, the application serves a report of how long each stage took, matching Spring Boot's own `Started ... in` time, at `/__grails/startup`, as a page or, to `Accept: application/json`, as data for keeping track of startup time in CI.
   - **Opening a browser.** `grails.startup.progress.openBrowser` opens a browser on the page as the start begins. `bootRun` passes any `grails.startup.progress.*` Gradle property to the application, so a developer can turn it on for every project once in `~/.gradle/gradle.properties`, or for one run with `./gradlew bootRun -Pgrails.startup.progress.openBrowser`.
   
   ### With the configuration cache
   
   This pairs with the configuration cache work (#16528). Once `bootRun` can reuse its configuration, the time from `./gradlew bootRun` to the application JVM running drops to little more than JVM startup. Together, `bootRun` effectively puts a server on the port almost immediately, with the start's progress shown in the browser rather than the port staying closed until the application is fully up.
   
   ## How it works
   
   - A run listener binds the port with the JDK's built-in HTTP server when the application context is prepared. There are no new third-party dependencies.
   - A `SmartLifecycle` one phase before Spring Boot's web server start/stop lifecycle stops that server, so Tomcat binds the port as it always has. The handoff showed no refused connections at 300 ms polling.
   - A filter inside the application then carries the progress on through plugin startup and `BootStrap`. It takes only the progress paths and real browser page loads (`Sec-Fetch-Mode: navigate`), so API calls, and a `BootStrap` calling its own application, reach the application exactly as before.
   - Both servers answer through one shared responder. Once the application is ready, the page's last poll is answered as ready rather than reaching the application, so nothing new is logged.
   - The page is not served for a WAR deployment, a random port, SSL, or a port already in use; in that last case the web server reports the port in use exactly as it did.
   
   ## Security
   
   The details (stages, bean names, failures, Grails version) are shown only to a browser signed in with the address the application logs as it starts, `?grailsStartupToken=…`. The token is 256 random bits, made once per JVM, and only ever written to the log, so seeing the details takes the same access as reading the log. Opening the address sets an `HttpOnly`, `SameSite=Strict` cookie and redirects to the address without the token. A browser opened with `openBrowser` is signed in already. Anyone else sees only the progress bar: the details are left out of the HTML and the JSON on the server rather than hidden in the page. The page sends a restrictive Content-Security-Policy, and everything dynamic is written as text, never as markup.
   
   ## Adopting it
   
   The module is opt-in. It is not part of `grails-dependencies-starter-web`. Add the dependency, or select the new `grails-startup-progress` Forge feature (Development Tools; web and REST API applications only, since a plugin's dependency would reach every application using it):
   
   ```groovy
   implementation 'org.apache.grails:grails-startup-progress'
   ```
   
   | Setting | Default | |
   |---|---|---|
   | `grails.startup.progress.enabled` | development mode | serve the progress page |
   | `grails.startup.progress.showDetails` | development mode | show the details to a signed-in browser |
   | `grails.startup.progress.openBrowser` | `false` | open a browser on the page as the start begins |
   | `grails.startup.progress.browserCommand` | the OS's own | the command that opens the browser |
   | `grails.startup.progress.statusPath` | `/__grails/startup-progress` | where the page polls |
   | `grails.startup.progress.endpoint.enabled` | development mode | serve the startup report |
   | `grails.startup.progress.endpoint.path` | `/__grails/startup` | where the report is served |
   
   All are in the module's `spring-configuration-metadata.json` and the Application Properties reference. The guide covers the feature under *Running and Debugging an Application → Watching the Application Start*, with a section on reporting progress from your own code.
   
   `grails run-app` used to treat "the port accepts a connection" as "the application is running", which the progress page would have satisfied at once. It now waits for an answer without the startup phase header, falling back to the connection check for ports that do not speak plain HTTP, such as SSL.
   
   ## Trying it
   
   `grails-test-examples/startup-progress` has three deliberately slow beans and a slow, task-reporting `BootStrap`:
   
   ```
   ./gradlew :grails-test-examples-startup-progress:bootRun -Pgrails.startup.progress.openBrowser
   ./gradlew :grails-test-examples-startup-progress:bootRun --args='--startup.demo.fail=bootstrap'   # or =bean
   ```
   
   The application logs the signed-in address for the progress page as it starts, and for the startup report once it is running.
   
   ## Testing
   
   Run locally:
   
   - `grails-startup-progress`: `StartupProgressSpec`, 25 features against a real embedded Tomcat. They cover:
     - the handoff and every phase, the sign-in and what anonymous clients receive
     - each failure path, the report as a page and as JSON, and the configurable paths
     - `openBrowser` and the token on a random port
     - that Actuator's `BufferingApplicationStartup` is preserved
     - that the total time matches Spring Boot's logged `Started ... in` time with a slow runner
   - `grails-core`: `StartupTaskSpec`.
   - `grails-data-hibernate5-dbmigration` and `grails-data-hibernate7-dbmigration`: full unit suites, including a new spec that runs `GrailsLiquibase` against H2 and checks the reported change sets, the context-filtered total, and that a migration callback's own change listener still works.
   - `grails-gradle-plugins`: `GrailsGradlePluginToolchainSpec`, including property forwarding to `bootRun`.
   - `grails-shell-cli`: a new `ServerInteractionSpec`.
   - `grails-forge-core`: a new `GrailsStartupProgressSpec`, plus `GrailsBaseSpec`, `BaseAvailableFeaturesSpec`, `FeatureOperationsSpec` and `CreateAppCommandSpec`.
   - `grails-test-examples-startup-progress`: an integration test that starts the real Grails application and checks the page reports bean creation and `BootStrap`, and hands over only once `BootStrap` has seeded its data.
   - `codeStyle` for every touched module, plus `rat`, `validateRepositoryConventions` and `:grails-doc:publishGuide`.
   - Checked by hand in Chrome against the example application: both themes, anonymous and signed-in views, a `BootStrap` failure, the running task, and the report.
   
   Not run locally: the full test suite and the full `aggregateViolations` gate, which CI runs.
   
   One flaky test to watch: `does not answer on the port while starting when the page is not configured` failed once in about 16 full runs and did not recur in 14 reruns. It had passed, unchanged, through every earlier run.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Add a startup progress page and startup report [grails-core]

Posted by "codeconsole (via GitHub)" <gi...@apache.org>.
codeconsole commented on code in PR #16536:
URL: https://github.com/apache/grails-core/pull/16536#discussion_r4198426221


##########
grails-startup-progress/src/main/java/org/apache/grails/startup/StartupProgressServer.java:
##########
@@ -0,0 +1,149 @@
+/*
+ *  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.apache.grails.startup;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Holds the application's port until the embedded web server is about to take it over, answering every
+ * request on it with the {@link StartupProgressResponder}: the progress page for a browser, the status
+ * the page polls for, and a plain {@code 503} for anything else.
+ */
+final class StartupProgressServer {
+
+    private final InetSocketAddress address;
+
+    private final StartupProgressResponder responder;
+
+    private HttpServer server;
+
+    private ExecutorService executor;
+
+    private volatile boolean running;
+
+    StartupProgressServer(InetAddress address, int port, StartupProgressResponder responder) {
+        this.address = new InetSocketAddress(address, port);
+        this.responder = responder;
+    }
+
+    /**
+     * Binds the port and starts answering. A stopped server can be started again.
+     *
+     * @throws IOException when the port cannot be bound, typically because something else holds it
+     */
+    void start() throws IOException {
+        if (running) {
+            return;
+        }
+        HttpServer httpServer = HttpServer.create(address, 0);
+        ExecutorService pool = Executors.newFixedThreadPool(2, runnable -> {
+            Thread thread = new Thread(runnable, "grails-startup-progress");
+            thread.setDaemon(true);
+            return thread;
+        });
+        httpServer.createContext("/", this::handle);
+        httpServer.setExecutor(pool);
+        httpServer.start();

Review Comment:
   `HttpServer.start()` creates its `HTTP-Dispatcher` thread without setting the daemon flag, so the thread inherits it from the caller. Here the caller is the main thread, so the dispatcher is non-daemon; the daemon thread factory only covers the handler pool. On JDK 21, a started `HttpServer` keeps the JVM running after `main` returns.
   
   That matters for any run that binds the port but never reaches the hand-off lifecycle, `started()` or `failed()`. `SpringApplication.handleRunFailure` returns early on an `AbandonedRunException` without calling the run listeners' `failed`, and Spring Boot's AOT processor throws exactly that from `contextLoaded`. So with `grails.startup.progress.enabled: true` set at the top level of `application.yml` (the guide describes turning it on in production), `processAot` binds the port and never exits.
   
   Could the server be started from a daemon thread, so the dispatcher can never keep the JVM running?



##########
grails-startup-progress/src/main/java/org/apache/grails/startup/StartupProgressResponder.java:
##########
@@ -0,0 +1,212 @@
+/*
+ *  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.apache.grails.startup;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+/**
+ * Decides how the startup progress answers a request, and answers it, for both of the servers that answer
+ * for it: the one that holds the application's port until the web server starts, and the filter inside the
+ * application once the web server has the port. Each hands its requests over as an {@link Exchange}.
+ *
+ * <p>The two differ only in which requests they take. The server that holds the port before the web server
+ * starts takes every request, since nothing else could answer one. The filter takes the startup progress's
+ * own paths and, while the application starts and the progress page is served, browser page loads; every
+ * other request, such as an API call or a request the application makes to itself from {@code BootStrap},
+ * reaches the application as it always has.</p>
+ *
+ * <p>A request taken is answered the same way by either:</p>
+ * <ul>
+ *     <li>at the status path, with the status the progress page polls for, or once the application is ready,
+ *     with only that it is ready</li>
+ *     <li>at the report path, with the report as data to a client asking for JSON, and once the application is
+ *     ready, with the report page</li>
+ *     <li>when it carries the token from the log, by signing the browser in and sending it on to the address
+ *     without the token</li>
+ *     <li>otherwise, while the application starts, with the progress page for a browser and a plain
+ *     {@code 503} for anything else</li>
+ * </ul>
+ */
+final class StartupProgressResponder {
+
+    /** Which requests a server takes, beyond the startup progress's own paths. */
+    enum Takes {
+
+        /** Every request, as the server that holds the port before the web server starts does. */
+        EVERY_REQUEST,
+
+        /** Browser page loads while the application starts, as the filter does when the progress page is served. */
+        PAGE_LOADS,
+
+        /** None, as the filter does when only the report is served. */
+        OWN_PATHS_ONLY
+    }
+
+    /** A request and its response, as a server that answers for the startup progress hands them over. */
+    interface Exchange {
+
+        String method();
+
+        /** The path asked for, undecoded, with the context path. */
+        String path();
+
+        /** The query, undecoded, or {@code null} when there is none. */
+        String query();
+
+        String header(String name);
+
+        List<String> headers(String name);
+
+        void setHeader(String name, String value);
+
+        /**
+         * Sends the status, headers and body, all of it before returning.
+         *
+         * @param contentType the type of the body, or {@code null} when there is no body
+         */
+        void send(int status, String contentType, byte[] body) throws IOException;
+    }
+
+    private static final byte[] NO_BODY = new byte[0];
+
+    private final StartupProgress progress;
+
+    private final StartupProgressPage page;
+
+    private final StartupAccess access;
+
+    private final String statusPath;
+
+    private final String reportPath;
+
+    /**
+     * @param statusPath the full path the progress page polls
+     * @param reportPath the full path of the startup report, or {@code null} when it is not served
+     */
+    StartupProgressResponder(StartupProgress progress, StartupProgressPage page, StartupAccess access, String statusPath, String reportPath) {
+        this.progress = progress;
+        this.page = page;
+        this.access = access;
+        this.statusPath = statusPath;
+        this.reportPath = reportPath;
+    }
+
+    /**
+     * Answers the request if the server takes it, and says whether it did. A request left unanswered is the
+     * application's.
+     */
+    boolean respond(Exchange exchange, Takes takes) throws IOException {
+        String path = exchange.path();
+        boolean starting = progress.isReporting();
+        boolean status = path.equals(statusPath);
+        boolean report = path.equals(reportPath);
+        if (status && !starting) {
+            // a page still polling once the application is ready is told so, rather than the poll reaching an
+            // application that has no such path and logs every request it cannot map
+            sendReady(exchange);
+            return true;
+        }
+        boolean everyRequest = takes == Takes.EVERY_REQUEST;
+        if ((everyRequest || starting || report) && signIn(exchange)) {
+            return true;
+        }
+        boolean pageLoad = isPageLoad(exchange);
+        if (!everyRequest && !report && !(starting && (status || takes == Takes.PAGE_LOADS && pageLoad))) {
+            return false;
+        }
+        boolean showDetails = access.showsDetails(exchange.headers("Cookie"));
+        boolean signInForDetails = access.isSignInRequired() && !showDetails;
+        if (status) {
+            StartupProgress.Status current = progress.report(showDetails, signInForDetails);
+            exchange.setHeader(StartupProgress.PHASE_HEADER, current.phase().name());
+            send(exchange, 200, "application/json", current.json());
+            progress.delivered(current);
+        }
+        else if (report && StartupProgressPage.wantsJson(exchange.header("Accept"), exchange.query())) {
+            send(exchange, 200, "application/json", progress.snapshot(showDetails, signInForDetails).json());
+        }
+        else if (report && !starting) {
+            setPageHeaders(exchange);
+            send(exchange, 200, "text/html", page.renderReport(progress.snapshot(showDetails, signInForDetails).json(), showDetails, signInForDetails));
+        }
+        else {
+            sendStarting(exchange, pageLoad, showDetails, signInForDetails);
+        }
+        return true;
+    }
+
+    /**
+     * Whether the request is a browser loading a page, which browsers say with the fetch metadata headers
+     * they send on every navigation. A client that does not send them, such as an HTTP client in application
+     * code, is never mistaken for one.
+     */
+    private static boolean isPageLoad(Exchange exchange) {

Review Comment:
   Browsers only send `Sec-Fetch-*` headers to potentially trustworthy URLs; the Fetch Metadata algorithm returns before setting them otherwise. That means only `localhost`, `127.0.0.1` and HTTPS get them.
   
   A developer may open the app over plain HTTP by machine name or LAN address, for example a VM, WSL, a phone, or `http://devbox:8080`. That navigation carries no fetch metadata. Once the filter has the port, this returns `false` and the page load reaches the application mid-`BootStrap`. The guide says the opposite: "A browser that opens the application while `BootStrap` runs is shown the progress page too". The JDK server phase doesn't have this gap, because `sendStarting` falls back to `Accept: text/html`.
   
   When `Sec-Fetch-Mode` is absent, could this fall back to a header only browsers send on navigations, such as `Upgrade-Insecure-Requests: 1`? HTTP clients called from `BootStrap` would still be let through. If not, the guide should say the page is only shown during `BootStrap` on `localhost` and HTTPS.



##########
grails-startup-progress/src/main/java/org/apache/grails/startup/StartupProgressRunListener.java:
##########
@@ -0,0 +1,339 @@
+/*
+ *  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.apache.grails.startup;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.time.Duration;
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.SpringApplicationRunListener;
+import org.springframework.boot.WebApplicationType;
+import org.springframework.boot.context.properties.bind.Bindable;
+import org.springframework.boot.context.properties.bind.Binder;
+import org.springframework.boot.web.server.context.WebServerApplicationContext;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.util.StringUtils;
+
+import grails.util.Environment;
+
+/**
+ * Serves a progress page on the application's port from the moment the application context is
+ * prepared until the application is ready, so a browser pointed at a starting application shows how
+ * far the start has got instead of failing to connect, and why it failed if it does.
+ *
+ * <p>The page polls a status path. Until the web server starts, a small server on the JDK's built-in
+ * HTTP server answers it; that server is stopped in the lifecycle phase just before the web server's,
+ * and from then on a filter inside the application answers the same path, and serves the page to
+ * browsers loading one, through the plugin startup hooks and {@code BootStrap} classes Grails runs after
+ * the web server starts. Once the application is ready the filter answers the poll with that, and the
+ * page reloads.</p>
+ *
+ * <p>The page is only served for a servlet application starting its own embedded web server on a fixed
+ * port without SSL, and only when {@code grails.startup.progress.enabled} allows it, which by default it
+ * does in development mode. If the port cannot be bound the application starts exactly as it would
+ * without the page.</p>
+ *
+ * <p>When {@code grails.startup.progress.openBrowser} is set, a browser is opened on the page as soon as it
+ * is served, or on the application once it is ready when the page is not served.</p>
+ *
+ * <p>When {@code grails.startup.progress.endpoint.enabled} allows it, which by default it does in
+ * development mode, the application also serves a report of its start for as long as it runs, at
+ * {@code grails.startup.progress.endpoint.path}. The report does not need the progress page, so it is
+ * served wherever the application starts its own embedded web server.</p>
+ *
+ * @since 8.1
+ */
+public class StartupProgressRunListener implements SpringApplicationRunListener {
+
+    private static final Logger LOG = LoggerFactory.getLogger(StartupProgressRunListener.class);
+
+    private static final String PROPERTIES_PREFIX = "grails.startup.progress";
+
+    private static final int DEFAULT_PORT = 8080;
+
+    /** How long a failed start waits for a watching page to be told why, before it carries on failing. */
+    private static final long FAILURE_DELIVERY_TIMEOUT_MILLIS = 2000;
+
+    private final SpringApplication application;
+
+    /** When the run of the application began, which the times in the report are measured from. */
+    private final long runStartNanos = System.nanoTime();
+
+    private StartupProgress progress;
+
+    private StartupAccess access;
+
+    /** The full path of the startup report, while it is served. */
+    private String reportPath;
+
+    private StartupProgressServer server;
+
+    private InetAddress address;
+
+    private String contextPath = "";
+
+    private boolean ssl;
+
+    /** The command that opens a browser, while one is still to be opened for this start. */
+    private List<String> browserCommand;
+
+    public StartupProgressRunListener(SpringApplication application, String[] args) {
+        this.application = application;
+    }
+
+    @Override
+    public void contextPrepared(ConfigurableApplicationContext context) {
+        if (application.getWebApplicationType() != WebApplicationType.SERVLET ||
+                !(context instanceof WebServerApplicationContext) ||
+                !StartupProgressFilter.startsEmbeddedServer(context)) {
+            return;
+        }
+        Binder binder = Binder.get(context.getEnvironment());
+        StartupProgressProperties properties;
+        try {
+            properties = binder.bindOrCreate(PROPERTIES_PREFIX, StartupProgressProperties.class);
+        }
+        catch (RuntimeException ex) {
+            // nothing else reads these settings, so a mistake in them is said here or not at all
+            LOG.warn("Not serving startup progress: the {} settings could not be read: {}", PROPERTIES_PREFIX, ex.getMessage());
+            return;
+        }
+        int port;
+        try {
+            port = binder.bind("server.port", Integer.class).orElse(DEFAULT_PORT);
+            address = binder.bind("server.address", InetAddress.class).orElse(null);
+            contextPath = contextPath(binder.bind("server.servlet.context-path", String.class).orElse(""));
+            ssl = isSslEnabled(binder);
+        }
+        catch (RuntimeException ex) {
+            // the web server reports a bad server.* setting far more usefully than this page could
+            LOG.debug("Not serving startup progress: the server settings could not be read", ex);
+            return;
+        }
+        if (properties.isOpenBrowser()) {
+            browserCommand = properties.getBrowserCommand();
+        }
+        boolean pageEnabled = properties.isEnabledOrDefault();
+        boolean reportEnabled = properties.getEndpoint().isEnabledOrDefault();
+        if (!pageEnabled && !reportEnabled) {
+            return;
+        }
+
+        String applicationName = applicationName(binder);
+        StartupProgress startupProgress = new StartupProgress(applicationName, Environment.getGrailsVersion(), runStartNanos);
+        StartupAccess startupAccess = new StartupAccess(properties.isShowDetailsOrDefault(), port);
+        String statusPath = path(properties.getStatusPath(), StartupProgressProperties.DEFAULT_STATUS_PATH, "startup progress status");
+        StartupProgressPage page = new StartupProgressPage(applicationName, statusPath, Environment.getGrailsVersion());
+        reportPath = reportEnabled ? path(properties.getEndpoint().getPath(), StartupProgressProperties.Endpoint.DEFAULT_PATH, "startup report") : null;
+        if (statusPath.equals(reportPath)) {
+            // a page polling there would take the report for the application answering, and reload without end
+            LOG.warn("Not serving the startup report at {}: it is the path the startup progress page polls", reportPath);
+            reportPath = null;
+            reportEnabled = false;
+        }
+        StartupProgressResponder responder = new StartupProgressResponder(startupProgress, page, startupAccess, statusPath, reportPath);
+        boolean servingPage = pageEnabled && servePage(responder, port);
+        if (!servingPage && !reportEnabled) {
+            reportPath = null;
+            return;
+        }
+        progress = startupProgress;
+        access = startupAccess;
+
+        context.setApplicationStartup(StartupProgressApplicationStartup.create(context.getApplicationStartup(), startupProgress, context.getBeanFactory()));
+        ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
+        // the lifecycle beans live as long as the context, so they hold what they act on rather than this listener
+        StartupProgressServer pageServer = server;
+        WebServerApplicationContext webContext = (WebServerApplicationContext) context;
+        beanFactory.registerSingleton("grailsStartupProgressHandOff", StartupProgressLifecycle.beforeWebServer(() -> {
+            startupProgress.startingWebServer();
+            if (pageServer != null) {
+                pageServer.stop();
+            }
+        }));
+        beanFactory.registerSingleton("grailsStartupProgressInitializing", StartupProgressLifecycle.afterWebServer(() -> {
+            startupProgress.initializing();
+            if (webContext.getWebServer() != null) {
+                // a random port is only known now, and names the cookie that signs a browser in to the report
+                startupAccess.usePort(webContext.getWebServer().getPort());
+            }
+        }));
+        beanFactory.registerSingleton("grailsStartupProgressFilter",
+                StartupProgressFilter.registration(responder, servingPage));
+        if (servingPage) {
+            LOG.info("Startup progress is shown at {} until the application is ready", uri("http", port, "/", signInQuery()));
+            openBrowser("http", port, signInQuery());
+        }
+    }
+
+    /**
+     * Serves the progress page until the web server takes the port over, and says whether it is served.
+     */
+    private boolean servePage(StartupProgressResponder responder, int port) {
+        if (port <= 0) {
+            LOG.debug("Not serving startup progress: server.port {} is not a fixed port", port);
+            return false;
+        }
+        if (ssl) {
+            LOG.debug("Not serving startup progress: the web server uses SSL");
+            return false;
+        }
+        StartupProgressServer progressServer = new StartupProgressServer(address, port, responder);

Review Comment:
   A related case: CRaC with `-Dspring.context.checkpoint=onRefresh`. Spring takes that checkpoint in `DefaultLifecycleProcessor.onRefresh()`, before it starts any lifecycle bean. `grailsStartupProgressHandOff` hasn't run yet at that point, so the progress server's listening socket is still open and the checkpoint fails with `CheckpointOpenSocketException`. It's the same open-socket problem we had with MongoDB in #16495.
   
   It only happens with the page enabled outside development mode. Still, skipping the page when `SpringProperties.getProperty("spring.context.checkpoint")` is `onRefresh` costs one line.



##########
grails-data-hibernate5/dbmigration/src/test/groovy/org/grails/plugins/databasemigration/liquibase/GrailsLiquibaseStartupTaskSpec.groovy:
##########
@@ -0,0 +1,199 @@
+/*
+ *  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.plugins.databasemigration.liquibase
+
+import java.sql.Connection
+import javax.sql.DataSource
+
+import liquibase.Liquibase
+import liquibase.changelog.ChangeSet
+import liquibase.changelog.DatabaseChangeLog
+import liquibase.changelog.visitor.AbstractChangeExecListener
+import liquibase.database.Database
+import org.h2.jdbcx.JdbcDataSource
+import spock.lang.Specification
+
+import org.springframework.boot.context.metrics.buffering.BufferingApplicationStartup
+import org.springframework.context.support.GenericApplicationContext
+import org.springframework.core.metrics.StartupStep
+
+import grails.boot.StartupTask
+
+/**
+ * A database update reports its progress as a {@link StartupTask} when the application's start is recorded,
+ * as it is when the startup progress page is shown, and runs exactly as before when it is not.
+ */
+class GrailsLiquibaseStartupTaskSpec extends Specification {
+
+    BufferingApplicationStartup recorder = new BufferingApplicationStartup(1000)
+
+    GenericApplicationContext context
+
+    void setup() {
+        context = new GenericApplicationContext()
+        context.applicationStartup = recorder
+        context.refresh()
+    }
+
+    void cleanup() {
+        context.close()
+    }
+
+    void 'an update reports each change set it runs as an item of a startup task'() {
+        given:
+        DataSource dataSource = newDatabase()
+
+        when: 'the database is updated for the production context'
+        update(dataSource, 'dataSource')
+
+        then: 'a task says what it does and how many change sets it will run, leaving out the test-only one'
+        StartupStep task = taskStep()
+        tags(task) == [(StartupTask.DESCRIPTION_TAG): 'Running database migrations', (StartupTask.TOTAL_TAG): '2']
+
+        and: 'each change set it ran is an item of the task, in the order they ran'
+        items(task) == ['create-book', 'create-author']
+
+        and: 'the database was updated'
+        tables(dataSource).containsAll(['BOOK', 'AUTHOR'])
+        !tables(dataSource).contains('SHELF')
+    }
+
+    void 'an update with nothing left to run reports a task with no items'() {
+        given: 'a database already up to date'
+        DataSource dataSource = newDatabase()
+        update(dataSource, 'dataSource')
+        recorder.drainBufferedTimeline()
+
+        when:
+        update(dataSource, 'dataSource')
+
+        then:
+        StartupStep task = taskStep()
+        tags(task)[StartupTask.TOTAL_TAG] == '0'
+        items(task).empty
+    }
+
+    void 'an update of another data source says which data source it updates'() {
+        when:
+        update(newDatabase(), 'dataSource_reports')
+
+        then:
+        tags(taskStep())[StartupTask.DESCRIPTION_TAG] == 'Running database migrations on dataSource_reports'
+    }
+
+    void 'an update nothing records reports nothing, and updates the database as before'() {
+        given: 'a context whose start nothing records'
+        GenericApplicationContext unrecorded = new GenericApplicationContext()
+        unrecorded.refresh()
+        DataSource dataSource = newDatabase()
+
+        when:
+        update(dataSource, 'dataSource', unrecorded)
+
+        then: 'no task is reported anywhere'
+        taskStep() == null

Review Comment:
   `taskStep()` reads `recorder`, but the `unrecorded` context never records into it, so this can't fail. Deleting the `isRecorded` guard in `startTask` would leave the whole spec green.
   
   To make it bite, the callbacks could capture the listener in `onStartMigration` and assert that none was installed. Alternatively, count how often the change log is read.
   
   There's also no case with a failing change set. Moving `task?.close()` out of the `finally`, or dropping `runFailed`, would go unnoticed. The hibernate7 copy has the same gaps.



##########
grails-test-examples/startup-progress/src/integration-test/groovy/startupprogress/StartupProgressPageSpec.groovy:
##########
@@ -0,0 +1,107 @@
+/*
+ *  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 startupprogress
+
+import java.util.concurrent.TimeUnit
+
+import spock.lang.Specification
+
+import org.springframework.context.ConfigurableApplicationContext
+
+import grails.boot.GrailsApp
+
+/**
+ * Starts the application the way {@code bootRun} does, on a port of its own with the progress page turned
+ * on, and follows the start the way the progress page does: polling the status until it says the
+ * application is ready.
+ */
+class StartupProgressPageSpec extends Specification {
+
+    private static final String STATUS_PATH = '/__grails/startup-progress'
+
+    ConfigurableApplicationContext context
+
+    void cleanup() {
+        context?.close()
+    }
+
+    void 'a browser opening the application while it starts sees progress until BootStrap has run'() {
+        given: 'an application whose beans and BootStrap take their time'
+        int port = freePort()
+        Throwable failure = null
+        Thread runner = Thread.start('startup-progress-app') {
+            try {
+                context = GrailsApp.run(Application,
+                        "--server.port=${port}".toString(),
+                        '--grails.startup.progress.enabled=true',
+                        '--startup.demo.delay.searchIndex=2s',
+                        '--startup.demo.bootstrapDelay=3s')
+            }
+            catch (Throwable ex) {
+                failure = ex
+            }
+        }
+
+        when: 'the status is polled until it says the application is ready'
+        Set<String> phases = new LinkedHashSet<>()
+        long deadline = System.nanoTime() + TimeUnit.MINUTES.toNanos(2)
+        while (!phases.contains('READY') && System.nanoTime() < deadline) {
+            Map response = get(port, STATUS_PATH)
+            if (response?.phase) {
+                phases << response.phase
+            }
+            Thread.sleep(50)
+        }
+        runner.join(TimeUnit.MINUTES.toMillis(1))
+
+        then: 'the page was told about the beans being created and about BootStrap running'
+        failure == null
+        phases.contains('CREATING_BEANS')
+        phases.contains('INITIALIZING')
+
+        and: 'by the time the page was told the application is ready, BootStrap had finished'
+        phases.contains('READY')
+        get(port, '/').body.contains('BootStrap seeded 3 catalog items')

Review Comment:
   This check runs after `runner.join(...)` on line 71. By then `GrailsApp.run` has returned and `BootStrap` has finished, whenever the page was told. So it would still pass if `READY` were reported right after the web server started, which is the regression this block's label describes.
   
   To prove the ordering, fetch `/` inside the loop at the moment the first `READY` status comes back, before the join, and assert the seeded text there.



##########
grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/io/ServerInteraction.groovy:
##########
@@ -47,17 +47,36 @@ trait ServerInteraction {
     }
 
     /**
-     * Returns true if the server is available
+     * Returns true if the server is available, which is once the application's web server answers on the port.
+     * An application that uses the {@code grails-startup-progress} module answers on the port before its web
+     * server starts, marking each response with the {@code Grails-Startup-Phase} header, so a response that
+     * carries it means the application is still starting. Once the web server has the port, it answers this
+     * request itself, even while {@code BootStrap} runs, as it does for an application without that module.
      *
      * @param host The host
      * @param port The port
      */
     boolean isServerAvailable(String host = 'localhost', int port = 8080) {
         try {
-            new Socket(host, port)
-            return true
+            new Socket(host, port).close()
         } catch (e) {
             return false
         }
+        HttpURLConnection connection = null
+        try {
+            connection = (HttpURLConnection) URI.create("http://${host}:${port}/").toURL().openConnection()
+            connection.requestMethod = 'HEAD'
+            connection.instanceFollowRedirects = false
+            connection.connectTimeout = 1000
+            connection.readTimeout = 2000
+            connection.responseCode
+            String phase = connection.getHeaderField('Grails-Startup-Phase')
+            return phase == null || phase == 'READY'
+        } catch (e) {

Review Comment:
   A minor one: a `ConnectException` lands here too and is reported as available. The socket on line 61 can connect to the progress server just before it stops, either at the hand-off or when `failed()` releases the port. The `HEAD` is then refused and `run-app` stops waiting before the web server has the port. Catching `ConnectException` first and returning `false` would keep this fallback for the SSL case it's meant for.
   
   While you're here, `openConnection(Proxy.NO_PROXY)` on line 67 would stop a JVM-wide proxy setting from routing this localhost probe through a proxy. That would happen with an `http.nonProxyHosts` that doesn't list `localhost`, and the proxy's answer has no phase header.



##########
grails-data-hibernate5/dbmigration/src/main/groovy/org/grails/plugins/databasemigration/liquibase/GrailsLiquibase.groovy:
##########
@@ -82,25 +86,66 @@ class GrailsLiquibase extends SpringLiquibase {
 
     @Override
     protected void performUpdate(Liquibase liquibase) throws LiquibaseException {
-        if (!applicationContext.containsBean('migrationCallbacks')) {
-            super.performUpdate(liquibase)
-            return
-        }
+        // begun before the migration callbacks run, so a callback that sets a change listener of its own replaces the
+        // one that reports each change set, and works as it did before the update was reported
+        StartupTask task = startTask(liquibase)
+        try {
+            if (!applicationContext.containsBean('migrationCallbacks')) {
+                super.performUpdate(liquibase)
+                return
+            }
+
+            def database = liquibase.database
+            def migrationCallbacks = applicationContext.getBean('migrationCallbacks')
+
+            if (migrationCallbacks.metaClass.respondsTo(migrationCallbacks, 'beforeStartMigration')) {
+                migrationCallbacks.invokeMethod('beforeStartMigration', [database] as Object[])
+            }
+            if (migrationCallbacks.metaClass.respondsTo(migrationCallbacks, 'onStartMigration')) {
+                migrationCallbacks.invokeMethod('onStartMigration', [database, liquibase, changeLog] as Object[])
+            }
 
-        def database = liquibase.database
-        def migrationCallbacks = applicationContext.getBean('migrationCallbacks')
+            super.performUpdate(liquibase)
 
-        if (migrationCallbacks.metaClass.respondsTo(migrationCallbacks, 'beforeStartMigration')) {
-            migrationCallbacks.invokeMethod('beforeStartMigration', [database] as Object[])
+            if (migrationCallbacks.metaClass.respondsTo(migrationCallbacks, 'afterMigrations')) {
+                migrationCallbacks.invokeMethod('afterMigrations', [database] as Object[])
+            }
         }
-        if (migrationCallbacks.metaClass.respondsTo(migrationCallbacks, 'onStartMigration')) {
-            migrationCallbacks.invokeMethod('onStartMigration', [database, liquibase, changeLog] as Object[])
+        finally {
+            task?.close()
         }
+    }
 
-        super.performUpdate(liquibase)
+    /**
+     * Begins a {@link StartupTask} for the update when anything records the application's start, such as the
+     * startup progress page, which then shows how many change sets are left and the one being run. Returns
+     * {@code null} when nothing records the start, since knowing how many change sets there are takes one more
+     * read of the change log and the database.
+     */
+    private StartupTask startTask(Liquibase liquibase) {
+        if (!StartupTask.isRecorded(applicationContext)) {
+            return null
+        }
+        StartupTask task = StartupTask.start(applicationContext, migrationDescription(), pendingChangeSets(liquibase))
+        liquibase.changeExecListener = new StartupTaskChangeExecListener(task)

Review Comment:
   This replaces the listener instead of adding to it. That has two side effects whenever the start is recorded, which includes Actuator's `BufferingApplicationStartup` as well as the progress page:
   
   - **A callback's own listener knocks this one out.** If `migrationCallbacks.onStartMigration` sets its own listener (the case the comment on lines 89-90 describes), this listener is gone, but the task has already been tagged with the total. The signed-in page shows "0 of 2" for the whole update, and the report ends with "Running database migrations (0 of 2)" although both ran. `a change listener a migration callback sets of its own takes the place of the one that reports each change set` asserts only the total tag, so it doesn't catch this.
   - **A configured listener class is ignored.** Once a listener instance is passed, Liquibase 4.27's `ChangeExecListenerCommandStep` uses it and never builds the one configured with `liquibase.command.changeExecListenerClass`. That configured listener silently stops running whenever the start is recorded.
   
   `Liquibase` has no getter for the current listener. `createLiquibase` (line 59) could return a `Liquibase` that overrides `setChangeExecListener` and combines what it's given with the reporting listener, for example in a `DefaultChangeExecListener`. The callback's listener and the progress count would then both work. The hibernate7 copy has the same issue.



##########
grails-doc/src/en/guide/gettingStarted/runningAndDebuggingAnApplication.adoc:
##########
@@ -74,3 +74,189 @@ For debugging a Grails app, you have two options. You can either right-click on
 $ ./gradlew bootRun --debug-jvm
 
 For more information on the `bootRun` command, please refer to the link:{commandLineRef}bootRun.html[bootRun section of the Grails reference guide].
+
+[[startupProgress]]
+=== Watching the Application Start
+
+The `grails-startup-progress` module shows how far an application has got as it starts. Add it to the application's `build.gradle`, or select the `grails-startup-progress` feature when generating the application:
+
+[source,groovy]
+----
+dependencies {
+    implementation 'org.apache.grails:grails-startup-progress'
+}
+----
+
+With it, in development mode, which is the `development` environment run from the project directory as `./gradlew bootRun` does, Grails answers on the application's port as soon as the application begins to start, rather than leaving the port closed until the embedded server is ready. Opening the application in a browser while it starts shows a progress page with:
+
+* the stage the start has reached: preparing the application context, loading plugins and bean definitions, creating beans, starting the web server, and running plugin startup hooks and `BootStrap` classes
+* how many of the application's beans have been created, and which bean is being created now
+* the beans that have taken longest to create so far, not counting the beans they depend on, which is usually the quickest way to find out why a start is slow
+
+The page reloads the address it was opened at once the application is ready, which is after `BootStrap` has finished, not merely once the web server is listening. A browser that opens the application while `BootStrap` runs is shown the progress page too, rather than pages from an application that has not finished starting.
+
+Until the web server is listening, any request that is not for a web page, such as a call to a REST endpoint, gets a `503 Service Unavailable` response with a `Retry-After` header. Once it is listening, such requests reach the application as they always have, including requests the application makes to itself from `BootStrap`.
+
+If the start fails, the page shows the exception and its stack trace and stays open. Start the application again and the page follows the new start and reloads when it is ready.
+
+The stages, the beans, the exception and the Grails version are details of the application's internals, so the page shows them only to a browser signed in with the address the application logs as it starts:
+
+[source,console]
+----
+Startup progress is shown at http://localhost:8080/?grailsStartupToken=… until the application is ready
+----
+
+Opening that address once signs the browser in for as long as the application runs, through restarts by Spring Boot DevTools, and takes the token back out of the address bar. A browser opened by the application, as described below, is signed in already. Anyone else reaching the port sees only the progress bar: the details are left out of the page and out of its data, rather than hidden in them. The sign-in is a cookie, so it covers every tab of the browser that signed in. The token is made afresh each time the JVM starts and is only ever written to the log, so seeing the details takes the same access as reading the log.
+
+Until the embedded server takes the port over, the page is served by the HTTP server built into the JDK, so it changes nothing about how the application itself serves requests. In the interactive shell, `grails run-app` keeps waiting while the page holds the port, and stops waiting once the embedded server has taken the port over, as it does for an application without the module, so it does not wait for `BootStrap`. The page is not served when the application:
+
+* is deployed as a WAR to a servlet container
+* uses a random port (`server.port: 0`) or SSL
+* finds its port already in use, in which case the application fails to start exactly as it would without the page
+
+These settings control the page:
+
+[source,yaml]
+----
+grails:
+    startup:
+        progress:
+            enabled: false       # whether to serve the page; by default, only in development mode

Review Comment:
   Someone who copies this block just to set `openBrowser` turns the page, the details and the report off in development. These three settings have no default of `false`: left unset, development mode decides. Could `enabled`, `showDetails` and `endpoint.enabled` be shown commented out, as unset, with the comment explaining the default?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Add a startup progress page and startup report [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16536:
URL: https://github.com/apache/grails-core/pull/16536#issuecomment-6009455553

   ## 🚨 All tests passed but jobs failed 🚨
   
   ### Failed Jobs without Test Failures
   
   ❌ [CI / Build Grails-Core Rerunning all Tasks \(Ubuntu JDK 21 shard 0\)](https://github.com/apache/grails-core/actions/runs/37409231647/job/112093843548?pr=16536)
   
   🏷️ Commit: 70b4ab40ebee7c502a19e0e99c9a020b21e847ef
   ▶️ Tests:  109428 executed
   🟡 Checks: 83/89 completed
   
   ### Rerun Controls
   
   Click the checkbox to trigger a rerun:
   
   🔲 **Rerun jobs**
   
   ---
   _Learn more about TestLens at [testlens.app/docs](https://testlens.app/docs/features/pr-comment/)._
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Add a startup progress page and startup report [grails-core]

Posted by "gsartori (via GitHub)" <gi...@apache.org>.
gsartori commented on PR #16536:
URL: https://github.com/apache/grails-core/pull/16536#issuecomment-6012396209

   This is very useful, I vote to make it a standard Grails feature


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Add a startup progress page and startup report [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on code in PR #16536:
URL: https://github.com/apache/grails-core/pull/16536#discussion_r4199897646


##########
grails-doc/src/en/guide/gettingStarted/runningAndDebuggingAnApplication.adoc:
##########
@@ -74,3 +74,189 @@ For debugging a Grails app, you have two options. You can either right-click on
 $ ./gradlew bootRun --debug-jvm
 
 For more information on the `bootRun` command, please refer to the link:{commandLineRef}bootRun.html[bootRun section of the Grails reference guide].
+
+[[startupProgress]]
+=== Watching the Application Start
+
+The `grails-startup-progress` module shows how far an application has got as it starts. Add it to the application's `build.gradle`, or select the `grails-startup-progress` feature when generating the application:
+
+[source,groovy]
+----
+dependencies {
+    implementation 'org.apache.grails:grails-startup-progress'
+}
+----
+
+With it, in development mode, which is the `development` environment run from the project directory as `./gradlew bootRun` does, Grails answers on the application's port as soon as the application begins to start, rather than leaving the port closed until the embedded server is ready. Opening the application in a browser while it starts shows a progress page with:
+
+* the stage the start has reached: preparing the application context, loading plugins and bean definitions, creating beans, starting the web server, and running plugin startup hooks and `BootStrap` classes
+* how many of the application's beans have been created, and which bean is being created now
+* the beans that have taken longest to create so far, not counting the beans they depend on, which is usually the quickest way to find out why a start is slow
+
+The page reloads the address it was opened at once the application is ready, which is after `BootStrap` has finished, not merely once the web server is listening. A browser that opens the application while `BootStrap` runs is shown the progress page too, rather than pages from an application that has not finished starting.
+
+Until the web server is listening, any request that is not for a web page, such as a call to a REST endpoint, gets a `503 Service Unavailable` response with a `Retry-After` header. Once it is listening, such requests reach the application as they always have, including requests the application makes to itself from `BootStrap`.
+
+If the start fails, the page shows the exception and its stack trace and stays open. Start the application again and the page follows the new start and reloads when it is ready.
+
+The stages, the beans, the exception and the Grails version are details of the application's internals, so the page shows them only to a browser signed in with the address the application logs as it starts:
+
+[source,console]
+----
+Startup progress is shown at http://localhost:8080/?grailsStartupToken=… until the application is ready
+----
+
+Opening that address once signs the browser in for as long as the application runs, through restarts by Spring Boot DevTools, and takes the token back out of the address bar. A browser opened by the application, as described below, is signed in already. Anyone else reaching the port sees only the progress bar: the details are left out of the page and out of its data, rather than hidden in them. The sign-in is a cookie, so it covers every tab of the browser that signed in. The token is made afresh each time the JVM starts and is only ever written to the log, so seeing the details takes the same access as reading the log.
+
+Until the embedded server takes the port over, the page is served by the HTTP server built into the JDK, so it changes nothing about how the application itself serves requests. In the interactive shell, `grails run-app` keeps waiting while the page holds the port, and stops waiting once the embedded server has taken the port over, as it does for an application without the module, so it does not wait for `BootStrap`. The page is not served when the application:
+
+* is deployed as a WAR to a servlet container
+* uses a random port (`server.port: 0`) or SSL
+* finds its port already in use, in which case the application fails to start exactly as it would without the page
+
+These settings control the page:
+
+[source,yaml]
+----
+grails:
+    startup:
+        progress:
+            enabled: false       # whether to serve the page; by default, only in development mode

Review Comment:
   Done: the three are shown commented out, with a sentence above the block saying they have no fixed default.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Add a startup progress page and startup report [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16536:
URL: https://github.com/apache/grails-core/pull/16536#issuecomment-6025043755

   ## 🚨 All tests passed but jobs failed 🚨
   
   ### Failed Jobs without Test Failures
   
   ❌ [Coverage / Coverage - grails-core \(ubuntu-24.04\)](https://github.com/apache/grails-core/actions/runs/37523465661/job/112474437956?pr=16536)
   
   🏷️ Commit: f6ab42d2458c41d1e87a4d5b48abb3104b2a6a16
   ▶️ Tests:  80008 executed
   🟡 Checks: 46/89 completed
   
   ### Rerun Controls
   
   Click the checkbox to trigger a rerun:
   
   🔲 **Rerun jobs**
   
   ---
   _Learn more about TestLens at [testlens.app/docs](https://testlens.app/docs/features/pr-comment/)._
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Add a startup progress page and startup report [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on PR #16536:
URL: https://github.com/apache/grails-core/pull/16536#issuecomment-6026833920

   With @codeconsole's comment and @gsartori being a committer, I'm going to take that as a double approver. This is an optional library so merging this.


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Add a startup progress page and startup report [grails-core]

Posted by "kico-itexto (via GitHub)" <gi...@apache.org>.
kico-itexto commented on PR #16536:
URL: https://github.com/apache/grails-core/pull/16536#issuecomment-6014200629

   Awesome!
   
   One of the issues I face when I teach Grails is this slowness feeling when the application starts on development mode. This will help a lot!


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Add a startup progress page and startup report [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty merged PR #16536:
URL: https://github.com/apache/grails-core/pull/16536


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Add a startup progress page and startup report [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on code in PR #16536:
URL: https://github.com/apache/grails-core/pull/16536#discussion_r4199895758


##########
grails-startup-progress/src/main/java/org/apache/grails/startup/StartupProgressServer.java:
##########
@@ -0,0 +1,149 @@
+/*
+ *  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.apache.grails.startup;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Holds the application's port until the embedded web server is about to take it over, answering every
+ * request on it with the {@link StartupProgressResponder}: the progress page for a browser, the status
+ * the page polls for, and a plain {@code 503} for anything else.
+ */
+final class StartupProgressServer {
+
+    private final InetSocketAddress address;
+
+    private final StartupProgressResponder responder;
+
+    private HttpServer server;
+
+    private ExecutorService executor;
+
+    private volatile boolean running;
+
+    StartupProgressServer(InetAddress address, int port, StartupProgressResponder responder) {
+        this.address = new InetSocketAddress(address, port);
+        this.responder = responder;
+    }
+
+    /**
+     * Binds the port and starts answering. A stopped server can be started again.
+     *
+     * @throws IOException when the port cannot be bound, typically because something else holds it
+     */
+    void start() throws IOException {
+        if (running) {
+            return;
+        }
+        HttpServer httpServer = HttpServer.create(address, 0);
+        ExecutorService pool = Executors.newFixedThreadPool(2, runnable -> {
+            Thread thread = new Thread(runnable, "grails-startup-progress");
+            thread.setDaemon(true);
+            return thread;
+        });
+        httpServer.createContext("/", this::handle);
+        httpServer.setExecutor(pool);
+        httpServer.start();

Review Comment:
   Done: the server is now started from a daemon thread, so the dispatcher is a daemon as well; the handler pool already was. `StartupProgressSpec` has a case that abandons the run from `contextLoaded` the way the AOT processor does and checks that every thread answering on the port is a daemon.



##########
grails-startup-progress/src/main/java/org/apache/grails/startup/StartupProgressRunListener.java:
##########
@@ -0,0 +1,339 @@
+/*
+ *  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.apache.grails.startup;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.time.Duration;
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.SpringApplicationRunListener;
+import org.springframework.boot.WebApplicationType;
+import org.springframework.boot.context.properties.bind.Bindable;
+import org.springframework.boot.context.properties.bind.Binder;
+import org.springframework.boot.web.server.context.WebServerApplicationContext;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.util.StringUtils;
+
+import grails.util.Environment;
+
+/**
+ * Serves a progress page on the application's port from the moment the application context is
+ * prepared until the application is ready, so a browser pointed at a starting application shows how
+ * far the start has got instead of failing to connect, and why it failed if it does.
+ *
+ * <p>The page polls a status path. Until the web server starts, a small server on the JDK's built-in
+ * HTTP server answers it; that server is stopped in the lifecycle phase just before the web server's,
+ * and from then on a filter inside the application answers the same path, and serves the page to
+ * browsers loading one, through the plugin startup hooks and {@code BootStrap} classes Grails runs after
+ * the web server starts. Once the application is ready the filter answers the poll with that, and the
+ * page reloads.</p>
+ *
+ * <p>The page is only served for a servlet application starting its own embedded web server on a fixed
+ * port without SSL, and only when {@code grails.startup.progress.enabled} allows it, which by default it
+ * does in development mode. If the port cannot be bound the application starts exactly as it would
+ * without the page.</p>
+ *
+ * <p>When {@code grails.startup.progress.openBrowser} is set, a browser is opened on the page as soon as it
+ * is served, or on the application once it is ready when the page is not served.</p>
+ *
+ * <p>When {@code grails.startup.progress.endpoint.enabled} allows it, which by default it does in
+ * development mode, the application also serves a report of its start for as long as it runs, at
+ * {@code grails.startup.progress.endpoint.path}. The report does not need the progress page, so it is
+ * served wherever the application starts its own embedded web server.</p>
+ *
+ * @since 8.1
+ */
+public class StartupProgressRunListener implements SpringApplicationRunListener {
+
+    private static final Logger LOG = LoggerFactory.getLogger(StartupProgressRunListener.class);
+
+    private static final String PROPERTIES_PREFIX = "grails.startup.progress";
+
+    private static final int DEFAULT_PORT = 8080;
+
+    /** How long a failed start waits for a watching page to be told why, before it carries on failing. */
+    private static final long FAILURE_DELIVERY_TIMEOUT_MILLIS = 2000;
+
+    private final SpringApplication application;
+
+    /** When the run of the application began, which the times in the report are measured from. */
+    private final long runStartNanos = System.nanoTime();
+
+    private StartupProgress progress;
+
+    private StartupAccess access;
+
+    /** The full path of the startup report, while it is served. */
+    private String reportPath;
+
+    private StartupProgressServer server;
+
+    private InetAddress address;
+
+    private String contextPath = "";
+
+    private boolean ssl;
+
+    /** The command that opens a browser, while one is still to be opened for this start. */
+    private List<String> browserCommand;
+
+    public StartupProgressRunListener(SpringApplication application, String[] args) {
+        this.application = application;
+    }
+
+    @Override
+    public void contextPrepared(ConfigurableApplicationContext context) {
+        if (application.getWebApplicationType() != WebApplicationType.SERVLET ||
+                !(context instanceof WebServerApplicationContext) ||
+                !StartupProgressFilter.startsEmbeddedServer(context)) {
+            return;
+        }
+        Binder binder = Binder.get(context.getEnvironment());
+        StartupProgressProperties properties;
+        try {
+            properties = binder.bindOrCreate(PROPERTIES_PREFIX, StartupProgressProperties.class);
+        }
+        catch (RuntimeException ex) {
+            // nothing else reads these settings, so a mistake in them is said here or not at all
+            LOG.warn("Not serving startup progress: the {} settings could not be read: {}", PROPERTIES_PREFIX, ex.getMessage());
+            return;
+        }
+        int port;
+        try {
+            port = binder.bind("server.port", Integer.class).orElse(DEFAULT_PORT);
+            address = binder.bind("server.address", InetAddress.class).orElse(null);
+            contextPath = contextPath(binder.bind("server.servlet.context-path", String.class).orElse(""));
+            ssl = isSslEnabled(binder);
+        }
+        catch (RuntimeException ex) {
+            // the web server reports a bad server.* setting far more usefully than this page could
+            LOG.debug("Not serving startup progress: the server settings could not be read", ex);
+            return;
+        }
+        if (properties.isOpenBrowser()) {
+            browserCommand = properties.getBrowserCommand();
+        }
+        boolean pageEnabled = properties.isEnabledOrDefault();
+        boolean reportEnabled = properties.getEndpoint().isEnabledOrDefault();
+        if (!pageEnabled && !reportEnabled) {
+            return;
+        }
+
+        String applicationName = applicationName(binder);
+        StartupProgress startupProgress = new StartupProgress(applicationName, Environment.getGrailsVersion(), runStartNanos);
+        StartupAccess startupAccess = new StartupAccess(properties.isShowDetailsOrDefault(), port);
+        String statusPath = path(properties.getStatusPath(), StartupProgressProperties.DEFAULT_STATUS_PATH, "startup progress status");
+        StartupProgressPage page = new StartupProgressPage(applicationName, statusPath, Environment.getGrailsVersion());
+        reportPath = reportEnabled ? path(properties.getEndpoint().getPath(), StartupProgressProperties.Endpoint.DEFAULT_PATH, "startup report") : null;
+        if (statusPath.equals(reportPath)) {
+            // a page polling there would take the report for the application answering, and reload without end
+            LOG.warn("Not serving the startup report at {}: it is the path the startup progress page polls", reportPath);
+            reportPath = null;
+            reportEnabled = false;
+        }
+        StartupProgressResponder responder = new StartupProgressResponder(startupProgress, page, startupAccess, statusPath, reportPath);
+        boolean servingPage = pageEnabled && servePage(responder, port);
+        if (!servingPage && !reportEnabled) {
+            reportPath = null;
+            return;
+        }
+        progress = startupProgress;
+        access = startupAccess;
+
+        context.setApplicationStartup(StartupProgressApplicationStartup.create(context.getApplicationStartup(), startupProgress, context.getBeanFactory()));
+        ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
+        // the lifecycle beans live as long as the context, so they hold what they act on rather than this listener
+        StartupProgressServer pageServer = server;
+        WebServerApplicationContext webContext = (WebServerApplicationContext) context;
+        beanFactory.registerSingleton("grailsStartupProgressHandOff", StartupProgressLifecycle.beforeWebServer(() -> {
+            startupProgress.startingWebServer();
+            if (pageServer != null) {
+                pageServer.stop();
+            }
+        }));
+        beanFactory.registerSingleton("grailsStartupProgressInitializing", StartupProgressLifecycle.afterWebServer(() -> {
+            startupProgress.initializing();
+            if (webContext.getWebServer() != null) {
+                // a random port is only known now, and names the cookie that signs a browser in to the report
+                startupAccess.usePort(webContext.getWebServer().getPort());
+            }
+        }));
+        beanFactory.registerSingleton("grailsStartupProgressFilter",
+                StartupProgressFilter.registration(responder, servingPage));
+        if (servingPage) {
+            LOG.info("Startup progress is shown at {} until the application is ready", uri("http", port, "/", signInQuery()));
+            openBrowser("http", port, signInQuery());
+        }
+    }
+
+    /**
+     * Serves the progress page until the web server takes the port over, and says whether it is served.
+     */
+    private boolean servePage(StartupProgressResponder responder, int port) {
+        if (port <= 0) {
+            LOG.debug("Not serving startup progress: server.port {} is not a fixed port", port);
+            return false;
+        }
+        if (ssl) {
+            LOG.debug("Not serving startup progress: the web server uses SSL");
+            return false;
+        }
+        StartupProgressServer progressServer = new StartupProgressServer(address, port, responder);

Review Comment:
   Done: the page is not served when `spring.context.checkpoint` is `onRefresh`, read through `SpringProperties` as `DefaultLifecycleProcessor` reads it; the report is still served. Covered in `StartupProgressSpec`, and the guide lists it with the other cases the page is not served in.



##########
grails-startup-progress/src/main/java/org/apache/grails/startup/StartupProgressResponder.java:
##########
@@ -0,0 +1,212 @@
+/*
+ *  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.apache.grails.startup;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+/**
+ * Decides how the startup progress answers a request, and answers it, for both of the servers that answer
+ * for it: the one that holds the application's port until the web server starts, and the filter inside the
+ * application once the web server has the port. Each hands its requests over as an {@link Exchange}.
+ *
+ * <p>The two differ only in which requests they take. The server that holds the port before the web server
+ * starts takes every request, since nothing else could answer one. The filter takes the startup progress's
+ * own paths and, while the application starts and the progress page is served, browser page loads; every
+ * other request, such as an API call or a request the application makes to itself from {@code BootStrap},
+ * reaches the application as it always has.</p>
+ *
+ * <p>A request taken is answered the same way by either:</p>
+ * <ul>
+ *     <li>at the status path, with the status the progress page polls for, or once the application is ready,
+ *     with only that it is ready</li>
+ *     <li>at the report path, with the report as data to a client asking for JSON, and once the application is
+ *     ready, with the report page</li>
+ *     <li>when it carries the token from the log, by signing the browser in and sending it on to the address
+ *     without the token</li>
+ *     <li>otherwise, while the application starts, with the progress page for a browser and a plain
+ *     {@code 503} for anything else</li>
+ * </ul>
+ */
+final class StartupProgressResponder {
+
+    /** Which requests a server takes, beyond the startup progress's own paths. */
+    enum Takes {
+
+        /** Every request, as the server that holds the port before the web server starts does. */
+        EVERY_REQUEST,
+
+        /** Browser page loads while the application starts, as the filter does when the progress page is served. */
+        PAGE_LOADS,
+
+        /** None, as the filter does when only the report is served. */
+        OWN_PATHS_ONLY
+    }
+
+    /** A request and its response, as a server that answers for the startup progress hands them over. */
+    interface Exchange {
+
+        String method();
+
+        /** The path asked for, undecoded, with the context path. */
+        String path();
+
+        /** The query, undecoded, or {@code null} when there is none. */
+        String query();
+
+        String header(String name);
+
+        List<String> headers(String name);
+
+        void setHeader(String name, String value);
+
+        /**
+         * Sends the status, headers and body, all of it before returning.
+         *
+         * @param contentType the type of the body, or {@code null} when there is no body
+         */
+        void send(int status, String contentType, byte[] body) throws IOException;
+    }
+
+    private static final byte[] NO_BODY = new byte[0];
+
+    private final StartupProgress progress;
+
+    private final StartupProgressPage page;
+
+    private final StartupAccess access;
+
+    private final String statusPath;
+
+    private final String reportPath;
+
+    /**
+     * @param statusPath the full path the progress page polls
+     * @param reportPath the full path of the startup report, or {@code null} when it is not served
+     */
+    StartupProgressResponder(StartupProgress progress, StartupProgressPage page, StartupAccess access, String statusPath, String reportPath) {
+        this.progress = progress;
+        this.page = page;
+        this.access = access;
+        this.statusPath = statusPath;
+        this.reportPath = reportPath;
+    }
+
+    /**
+     * Answers the request if the server takes it, and says whether it did. A request left unanswered is the
+     * application's.
+     */
+    boolean respond(Exchange exchange, Takes takes) throws IOException {
+        String path = exchange.path();
+        boolean starting = progress.isReporting();
+        boolean status = path.equals(statusPath);
+        boolean report = path.equals(reportPath);
+        if (status && !starting) {
+            // a page still polling once the application is ready is told so, rather than the poll reaching an
+            // application that has no such path and logs every request it cannot map
+            sendReady(exchange);
+            return true;
+        }
+        boolean everyRequest = takes == Takes.EVERY_REQUEST;
+        if ((everyRequest || starting || report) && signIn(exchange)) {
+            return true;
+        }
+        boolean pageLoad = isPageLoad(exchange);
+        if (!everyRequest && !report && !(starting && (status || takes == Takes.PAGE_LOADS && pageLoad))) {
+            return false;
+        }
+        boolean showDetails = access.showsDetails(exchange.headers("Cookie"));
+        boolean signInForDetails = access.isSignInRequired() && !showDetails;
+        if (status) {
+            StartupProgress.Status current = progress.report(showDetails, signInForDetails);
+            exchange.setHeader(StartupProgress.PHASE_HEADER, current.phase().name());
+            send(exchange, 200, "application/json", current.json());
+            progress.delivered(current);
+        }
+        else if (report && StartupProgressPage.wantsJson(exchange.header("Accept"), exchange.query())) {
+            send(exchange, 200, "application/json", progress.snapshot(showDetails, signInForDetails).json());
+        }
+        else if (report && !starting) {
+            setPageHeaders(exchange);
+            send(exchange, 200, "text/html", page.renderReport(progress.snapshot(showDetails, signInForDetails).json(), showDetails, signInForDetails));
+        }
+        else {
+            sendStarting(exchange, pageLoad, showDetails, signInForDetails);
+        }
+        return true;
+    }
+
+    /**
+     * Whether the request is a browser loading a page, which browsers say with the fetch metadata headers
+     * they send on every navigation. A client that does not send them, such as an HTTP client in application
+     * code, is never mistaken for one.
+     */
+    private static boolean isPageLoad(Exchange exchange) {

Review Comment:
   Done: without `Sec-Fetch-Mode`, `Upgrade-Insecure-Requests: 1` now counts as a page load; with it, the fetch metadata decides as before. The spec sends a page load with only that header while the startup hook runs and gets the progress page.



##########
grails-data-hibernate5/dbmigration/src/main/groovy/org/grails/plugins/databasemigration/liquibase/GrailsLiquibase.groovy:
##########
@@ -82,25 +86,66 @@ class GrailsLiquibase extends SpringLiquibase {
 
     @Override
     protected void performUpdate(Liquibase liquibase) throws LiquibaseException {
-        if (!applicationContext.containsBean('migrationCallbacks')) {
-            super.performUpdate(liquibase)
-            return
-        }
+        // begun before the migration callbacks run, so a callback that sets a change listener of its own replaces the
+        // one that reports each change set, and works as it did before the update was reported
+        StartupTask task = startTask(liquibase)
+        try {
+            if (!applicationContext.containsBean('migrationCallbacks')) {
+                super.performUpdate(liquibase)
+                return
+            }
+
+            def database = liquibase.database
+            def migrationCallbacks = applicationContext.getBean('migrationCallbacks')
+
+            if (migrationCallbacks.metaClass.respondsTo(migrationCallbacks, 'beforeStartMigration')) {
+                migrationCallbacks.invokeMethod('beforeStartMigration', [database] as Object[])
+            }
+            if (migrationCallbacks.metaClass.respondsTo(migrationCallbacks, 'onStartMigration')) {
+                migrationCallbacks.invokeMethod('onStartMigration', [database, liquibase, changeLog] as Object[])
+            }
 
-        def database = liquibase.database
-        def migrationCallbacks = applicationContext.getBean('migrationCallbacks')
+            super.performUpdate(liquibase)
 
-        if (migrationCallbacks.metaClass.respondsTo(migrationCallbacks, 'beforeStartMigration')) {
-            migrationCallbacks.invokeMethod('beforeStartMigration', [database] as Object[])
+            if (migrationCallbacks.metaClass.respondsTo(migrationCallbacks, 'afterMigrations')) {
+                migrationCallbacks.invokeMethod('afterMigrations', [database] as Object[])
+            }
         }
-        if (migrationCallbacks.metaClass.respondsTo(migrationCallbacks, 'onStartMigration')) {
-            migrationCallbacks.invokeMethod('onStartMigration', [database, liquibase, changeLog] as Object[])
+        finally {
+            task?.close()
         }
+    }
 
-        super.performUpdate(liquibase)
+    /**
+     * Begins a {@link StartupTask} for the update when anything records the application's start, such as the
+     * startup progress page, which then shows how many change sets are left and the one being run. Returns
+     * {@code null} when nothing records the start, since knowing how many change sets there are takes one more
+     * read of the change log and the database.
+     */
+    private StartupTask startTask(Liquibase liquibase) {
+        if (!StartupTask.isRecorded(applicationContext)) {
+            return null
+        }
+        StartupTask task = StartupTask.start(applicationContext, migrationDescription(), pendingChangeSets(liquibase))
+        liquibase.changeExecListener = new StartupTaskChangeExecListener(task)

Review Comment:
   Done: `createLiquibase` now returns a `Liquibase` whose `setChangeExecListener` adds to its `DefaultChangeExecListener` instead of replacing it, and which creates the listener configured with `liquibase.command.changeExecListenerClass` the way the update command does, since Liquibase skips that once an instance is passed. The callback's listener, the configured class and the progress count all run now, each covered in the spec, and the hibernate7 copy is the same.



##########
grails-data-hibernate5/dbmigration/src/test/groovy/org/grails/plugins/databasemigration/liquibase/GrailsLiquibaseStartupTaskSpec.groovy:
##########
@@ -0,0 +1,199 @@
+/*
+ *  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.plugins.databasemigration.liquibase
+
+import java.sql.Connection
+import javax.sql.DataSource
+
+import liquibase.Liquibase
+import liquibase.changelog.ChangeSet
+import liquibase.changelog.DatabaseChangeLog
+import liquibase.changelog.visitor.AbstractChangeExecListener
+import liquibase.database.Database
+import org.h2.jdbcx.JdbcDataSource
+import spock.lang.Specification
+
+import org.springframework.boot.context.metrics.buffering.BufferingApplicationStartup
+import org.springframework.context.support.GenericApplicationContext
+import org.springframework.core.metrics.StartupStep
+
+import grails.boot.StartupTask
+
+/**
+ * A database update reports its progress as a {@link StartupTask} when the application's start is recorded,
+ * as it is when the startup progress page is shown, and runs exactly as before when it is not.
+ */
+class GrailsLiquibaseStartupTaskSpec extends Specification {
+
+    BufferingApplicationStartup recorder = new BufferingApplicationStartup(1000)
+
+    GenericApplicationContext context
+
+    void setup() {
+        context = new GenericApplicationContext()
+        context.applicationStartup = recorder
+        context.refresh()
+    }
+
+    void cleanup() {
+        context.close()
+    }
+
+    void 'an update reports each change set it runs as an item of a startup task'() {
+        given:
+        DataSource dataSource = newDatabase()
+
+        when: 'the database is updated for the production context'
+        update(dataSource, 'dataSource')
+
+        then: 'a task says what it does and how many change sets it will run, leaving out the test-only one'
+        StartupStep task = taskStep()
+        tags(task) == [(StartupTask.DESCRIPTION_TAG): 'Running database migrations', (StartupTask.TOTAL_TAG): '2']
+
+        and: 'each change set it ran is an item of the task, in the order they ran'
+        items(task) == ['create-book', 'create-author']
+
+        and: 'the database was updated'
+        tables(dataSource).containsAll(['BOOK', 'AUTHOR'])
+        !tables(dataSource).contains('SHELF')
+    }
+
+    void 'an update with nothing left to run reports a task with no items'() {
+        given: 'a database already up to date'
+        DataSource dataSource = newDatabase()
+        update(dataSource, 'dataSource')
+        recorder.drainBufferedTimeline()
+
+        when:
+        update(dataSource, 'dataSource')
+
+        then:
+        StartupStep task = taskStep()
+        tags(task)[StartupTask.TOTAL_TAG] == '0'
+        items(task).empty
+    }
+
+    void 'an update of another data source says which data source it updates'() {
+        when:
+        update(newDatabase(), 'dataSource_reports')
+
+        then:
+        tags(taskStep())[StartupTask.DESCRIPTION_TAG] == 'Running database migrations on dataSource_reports'
+    }
+
+    void 'an update nothing records reports nothing, and updates the database as before'() {
+        given: 'a context whose start nothing records'
+        GenericApplicationContext unrecorded = new GenericApplicationContext()
+        unrecorded.refresh()
+        DataSource dataSource = newDatabase()
+
+        when:
+        update(dataSource, 'dataSource', unrecorded)
+
+        then: 'no task is reported anywhere'
+        taskStep() == null

Review Comment:
   Done: the callbacks now record how many listeners were set before theirs, through `getDefaultChangeExecListener()`, so the unrecorded case asserts none was installed. There is also a case with a failing change set, where the callbacks' listener checks at `runFailed` that the failed item had already been ended while the task was still open, and the task is asserted to have ended afterwards.



##########
grails-test-examples/startup-progress/src/integration-test/groovy/startupprogress/StartupProgressPageSpec.groovy:
##########
@@ -0,0 +1,107 @@
+/*
+ *  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 startupprogress
+
+import java.util.concurrent.TimeUnit
+
+import spock.lang.Specification
+
+import org.springframework.context.ConfigurableApplicationContext
+
+import grails.boot.GrailsApp
+
+/**
+ * Starts the application the way {@code bootRun} does, on a port of its own with the progress page turned
+ * on, and follows the start the way the progress page does: polling the status until it says the
+ * application is ready.
+ */
+class StartupProgressPageSpec extends Specification {
+
+    private static final String STATUS_PATH = '/__grails/startup-progress'
+
+    ConfigurableApplicationContext context
+
+    void cleanup() {
+        context?.close()
+    }
+
+    void 'a browser opening the application while it starts sees progress until BootStrap has run'() {
+        given: 'an application whose beans and BootStrap take their time'
+        int port = freePort()
+        Throwable failure = null
+        Thread runner = Thread.start('startup-progress-app') {
+            try {
+                context = GrailsApp.run(Application,
+                        "--server.port=${port}".toString(),
+                        '--grails.startup.progress.enabled=true',
+                        '--startup.demo.delay.searchIndex=2s',
+                        '--startup.demo.bootstrapDelay=3s')
+            }
+            catch (Throwable ex) {
+                failure = ex
+            }
+        }
+
+        when: 'the status is polled until it says the application is ready'
+        Set<String> phases = new LinkedHashSet<>()
+        long deadline = System.nanoTime() + TimeUnit.MINUTES.toNanos(2)
+        while (!phases.contains('READY') && System.nanoTime() < deadline) {
+            Map response = get(port, STATUS_PATH)
+            if (response?.phase) {
+                phases << response.phase
+            }
+            Thread.sleep(50)
+        }
+        runner.join(TimeUnit.MINUTES.toMillis(1))
+
+        then: 'the page was told about the beans being created and about BootStrap running'
+        failure == null
+        phases.contains('CREATING_BEANS')
+        phases.contains('INITIALIZING')
+
+        and: 'by the time the page was told the application is ready, BootStrap had finished'
+        phases.contains('READY')
+        get(port, '/').body.contains('BootStrap seeded 3 catalog items')

Review Comment:
   Done: the loop now fetches `/` the moment the first `READY` status comes back, before the join, and the assertion is on that body.



##########
grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/io/ServerInteraction.groovy:
##########
@@ -47,17 +47,36 @@ trait ServerInteraction {
     }
 
     /**
-     * Returns true if the server is available
+     * Returns true if the server is available, which is once the application's web server answers on the port.
+     * An application that uses the {@code grails-startup-progress} module answers on the port before its web
+     * server starts, marking each response with the {@code Grails-Startup-Phase} header, so a response that
+     * carries it means the application is still starting. Once the web server has the port, it answers this
+     * request itself, even while {@code BootStrap} runs, as it does for an application without that module.
      *
      * @param host The host
      * @param port The port
      */
     boolean isServerAvailable(String host = 'localhost', int port = 8080) {
         try {
-            new Socket(host, port)
-            return true
+            new Socket(host, port).close()
         } catch (e) {
             return false
         }
+        HttpURLConnection connection = null
+        try {
+            connection = (HttpURLConnection) URI.create("http://${host}:${port}/").toURL().openConnection()
+            connection.requestMethod = 'HEAD'
+            connection.instanceFollowRedirects = false
+            connection.connectTimeout = 1000
+            connection.readTimeout = 2000
+            connection.responseCode
+            String phase = connection.getHeaderField('Grails-Startup-Phase')
+            return phase == null || phase == 'READY'
+        } catch (e) {

Review Comment:
   Done, a little differently: the check now sends the HEAD over the socket it connects with, a plain socket, rather than connecting twice, which removes the window between the two connects and keeps a JVM proxy out of it. A port that refuses is not available, and a port that accepts but does not speak HTTP still is. `ServerInteractionSpec` has a live proxy answering without the header while the real port still answers as the progress page.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Add a startup progress page and startup report [grails-core]

Posted by "codecov[bot] (via GitHub)" <gi...@apache.org>.
codecov[bot] commented on PR #16536:
URL: https://github.com/apache/grails-core/pull/16536#issuecomment-6009307585

   ## [Codecov](https://app.codecov.io/gh/apache/grails-core/pull/16536?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) Report
   :x: Patch coverage is `83.85093%` with `130 lines` in your changes missing coverage. Please review.
   :white_check_mark: Project coverage is 60.6692%. Comparing base ([`c16730e`](https://app.codecov.io/gh/apache/grails-core/commit/c16730edd916ba4b6a3216a48683a45de35fa781?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)) to head ([`70b4ab4`](https://app.codecov.io/gh/apache/grails-core/commit/70b4ab40ebee7c502a19e0e99c9a020b21e847ef?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)).
   :warning: Report is 14 commits behind head on 8.1.x.
   
   | [Files with missing lines](https://app.codecov.io/gh/apache/grails-core/pull/16536?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | Patch % | Lines |
   |---|---|---|
   | [...che/grails/startup/StartupProgressRunListener.java](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&filepath=grails-startup-progress%2Fsrc%2Fmain%2Fjava%2Forg%2Fapache%2Fgrails%2Fstartup%2FStartupProgressRunListener.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLXN0YXJ0dXAtcHJvZ3Jlc3Mvc3JjL21haW4vamF2YS9vcmcvYXBhY2hlL2dyYWlscy9zdGFydHVwL1N0YXJ0dXBQcm9ncmVzc1J1bkxpc3RlbmVyLmphdmE=) | 75.7143% | [17 Missing and 17 partials :warning: ](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
   | [...ava/org/apache/grails/startup/StartupProgress.java](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&filepath=grails-startup-progress%2Fsrc%2Fmain%2Fjava%2Forg%2Fapache%2Fgrails%2Fstartup%2FStartupProgress.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLXN0YXJ0dXAtcHJvZ3Jlc3Mvc3JjL21haW4vamF2YS9vcmcvYXBhY2hlL2dyYWlscy9zdGFydHVwL1N0YXJ0dXBQcm9ncmVzcy5qYXZh) | 87.9167% | [11 Missing and 18 partials :warning: ](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
   | [...ils/startup/StartupProgressApplicationStartup.java](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&filepath=grails-startup-progress%2Fsrc%2Fmain%2Fjava%2Forg%2Fapache%2Fgrails%2Fstartup%2FStartupProgressApplicationStartup.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLXN0YXJ0dXAtcHJvZ3Jlc3Mvc3JjL21haW4vamF2YS9vcmcvYXBhY2hlL2dyYWlscy9zdGFydHVwL1N0YXJ0dXBQcm9ncmVzc0FwcGxpY2F0aW9uU3RhcnR1cC5qYXZh) | 80.7692% | [10 Missing and 10 partials :warning: ](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
   | [...org/apache/grails/startup/StartupProgressPage.java](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&filepath=grails-startup-progress%2Fsrc%2Fmain%2Fjava%2Forg%2Fapache%2Fgrails%2Fstartup%2FStartupProgressPage.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLXN0YXJ0dXAtcHJvZ3Jlc3Mvc3JjL21haW4vamF2YS9vcmcvYXBhY2hlL2dyYWlscy9zdGFydHVwL1N0YXJ0dXBQcm9ncmVzc1BhZ2UuamF2YQ==) | 68.2927% | [8 Missing and 5 partials :warning: ](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
   | [...java/org/apache/grails/startup/StartupBrowser.java](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&filepath=grails-startup-progress%2Fsrc%2Fmain%2Fjava%2Forg%2Fapache%2Fgrails%2Fstartup%2FStartupBrowser.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLXN0YXJ0dXAtcHJvZ3Jlc3Mvc3JjL21haW4vamF2YS9vcmcvYXBhY2hlL2dyYWlscy9zdGFydHVwL1N0YXJ0dXBCcm93c2VyLmphdmE=) | 57.1429% | [8 Missing and 1 partial :warning: ](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
   | [.../java/org/apache/grails/startup/StartupAccess.java](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&filepath=grails-startup-progress%2Fsrc%2Fmain%2Fjava%2Forg%2Fapache%2Fgrails%2Fstartup%2FStartupAccess.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLXN0YXJ0dXAtcHJvZ3Jlc3Mvc3JjL21haW4vamF2YS9vcmcvYXBhY2hlL2dyYWlscy9zdGFydHVwL1N0YXJ0dXBBY2Nlc3MuamF2YQ==) | 81.5790% | [1 Missing and 6 partials :warning: ](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
   | [...e/plugin/core/GrailsStartupProgressProvider.groovy](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&filepath=grails-gradle%2Fplugins%2Fsrc%2Fmain%2Fgroovy%2Forg%2Fgrails%2Fgradle%2Fplugin%2Fcore%2FGrailsStartupProgressProvider.groovy&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLWdyYWRsZS9wbHVnaW5zL3NyYy9tYWluL2dyb292eS9vcmcvZ3JhaWxzL2dyYWRsZS9wbHVnaW4vY29yZS9HcmFpbHNTdGFydHVwUHJvZ3Jlc3NQcm92aWRlci5ncm9vdnk=) | 0.0000% | [6 Missing :warning: ](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
   | [...ache/grails/startup/StartupProgressProperties.java](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&filepath=grails-startup-progress%2Fsrc%2Fmain%2Fjava%2Forg%2Fapache%2Fgrails%2Fstartup%2FStartupProgressProperties.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLXN0YXJ0dXAtcHJvZ3Jlc3Mvc3JjL21haW4vamF2YS9vcmcvYXBhY2hlL2dyYWlscy9zdGFydHVwL1N0YXJ0dXBQcm9ncmVzc1Byb3BlcnRpZXMuamF2YQ==) | 90.3226% | [3 Missing :warning: ](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
   | [...pache/grails/startup/StartupProgressResponder.java](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&filepath=grails-startup-progress%2Fsrc%2Fmain%2Fjava%2Forg%2Fapache%2Fgrails%2Fstartup%2FStartupProgressResponder.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLXN0YXJ0dXAtcHJvZ3Jlc3Mvc3JjL21haW4vamF2YS9vcmcvYXBhY2hlL2dyYWlscy9zdGFydHVwL1N0YXJ0dXBQcm9ncmVzc1Jlc3BvbmRlci5qYXZh) | 95.6522% | [0 Missing and 3 partials :warning: ](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
   | [...rails/gradle/plugin/core/GrailsGradlePlugin.groovy](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&filepath=grails-gradle%2Fplugins%2Fsrc%2Fmain%2Fgroovy%2Forg%2Fgrails%2Fgradle%2Fplugin%2Fcore%2FGrailsGradlePlugin.groovy&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLWdyYWRsZS9wbHVnaW5zL3NyYy9tYWluL2dyb292eS9vcmcvZ3JhaWxzL2dyYWRsZS9wbHVnaW4vY29yZS9HcmFpbHNHcmFkbGVQbHVnaW4uZ3Jvb3Z5) | 0.0000% | [2 Missing :warning: ](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
   | ... and [2 more](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | |
   
   <details><summary>Additional details and impacted files</summary>
   
   
   
   [![Impacted file tree graph](https://app.codecov.io/gh/apache/grails-core/pull/16536/graphs/tree.svg?width=650&height=150&src=pr&token=qJrryhpk7u&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)
   
   ```diff
   @@                Coverage Diff                 @@
   ##                8.1.x     #16536        +/-   ##
   ==================================================
   + Coverage     60.5020%   60.6692%   +0.1672%     
   - Complexity      26110      26348       +238     
   ==================================================
     Files            2212       2225        +13     
     Lines          110193     110997       +804     
     Branches        20060      20171       +111     
   ==================================================
   + Hits            66669      67341       +672     
   - Misses          34534      34604        +70     
   - Partials         8990       9052        +62     
   ```
   
   | [Files with missing lines](https://app.codecov.io/gh/apache/grails-core/pull/16536?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | Coverage Δ | |
   |---|---|---|
   | [...-core/src/main/groovy/grails/boot/StartupTask.java](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&filepath=grails-core%2Fsrc%2Fmain%2Fgroovy%2Fgrails%2Fboot%2FStartupTask.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLWNvcmUvc3JjL21haW4vZ3Jvb3Z5L2dyYWlscy9ib290L1N0YXJ0dXBUYXNrLmphdmE=) | `100.0000% <100.0000%> (ø)` | |
   | [...pache/grails/startup/StartupProgressLifecycle.java](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&filepath=grails-startup-progress%2Fsrc%2Fmain%2Fjava%2Forg%2Fapache%2Fgrails%2Fstartup%2FStartupProgressLifecycle.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLXN0YXJ0dXAtcHJvZ3Jlc3Mvc3JjL21haW4vamF2YS9vcmcvYXBhY2hlL2dyYWlscy9zdGFydHVwL1N0YXJ0dXBQcm9ncmVzc0xpZmVjeWNsZS5qYXZh) | `100.0000% <100.0000%> (ø)` | |
   | [...rails/gradle/plugin/core/GrailsGradlePlugin.groovy](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&filepath=grails-gradle%2Fplugins%2Fsrc%2Fmain%2Fgroovy%2Forg%2Fgrails%2Fgradle%2Fplugin%2Fcore%2FGrailsGradlePlugin.groovy&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLWdyYWRsZS9wbHVnaW5zL3NyYy9tYWluL2dyb292eS9vcmcvZ3JhaWxzL2dyYWRsZS9wbHVnaW4vY29yZS9HcmFpbHNHcmFkbGVQbHVnaW4uZ3Jvb3Z5) | `29.2282% <0.0000%> (-0.0963%)` | :arrow_down: |
   | [...g/apache/grails/startup/StartupProgressFilter.java](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&filepath=grails-startup-progress%2Fsrc%2Fmain%2Fjava%2Forg%2Fapache%2Fgrails%2Fstartup%2FStartupProgressFilter.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLXN0YXJ0dXAtcHJvZ3Jlc3Mvc3JjL21haW4vamF2YS9vcmcvYXBhY2hlL2dyYWlscy9zdGFydHVwL1N0YXJ0dXBQcm9ncmVzc0ZpbHRlci5qYXZh) | `93.9394% <93.9394%> (ø)` | |
   | [...g/apache/grails/startup/StartupProgressServer.java](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&filepath=grails-startup-progress%2Fsrc%2Fmain%2Fjava%2Forg%2Fapache%2Fgrails%2Fstartup%2FStartupProgressServer.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLXN0YXJ0dXAtcHJvZ3Jlc3Mvc3JjL21haW4vamF2YS9vcmcvYXBhY2hlL2dyYWlscy9zdGFydHVwL1N0YXJ0dXBQcm9ncmVzc1NlcnZlci5qYXZh) | `95.6522% <95.6522%> (ø)` | |
   | [...ache/grails/startup/StartupProgressProperties.java](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&filepath=grails-startup-progress%2Fsrc%2Fmain%2Fjava%2Forg%2Fapache%2Fgrails%2Fstartup%2FStartupProgressProperties.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLXN0YXJ0dXAtcHJvZ3Jlc3Mvc3JjL21haW4vamF2YS9vcmcvYXBhY2hlL2dyYWlscy9zdGFydHVwL1N0YXJ0dXBQcm9ncmVzc1Byb3BlcnRpZXMuamF2YQ==) | `90.3226% <90.3226%> (ø)` | |
   | [...pache/grails/startup/StartupProgressResponder.java](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&filepath=grails-startup-progress%2Fsrc%2Fmain%2Fjava%2Forg%2Fapache%2Fgrails%2Fstartup%2FStartupProgressResponder.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLXN0YXJ0dXAtcHJvZ3Jlc3Mvc3JjL21haW4vamF2YS9vcmcvYXBhY2hlL2dyYWlscy9zdGFydHVwL1N0YXJ0dXBQcm9ncmVzc1Jlc3BvbmRlci5qYXZh) | `95.6522% <95.6522%> (ø)` | |
   | [...e/plugin/core/GrailsStartupProgressProvider.groovy](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&filepath=grails-gradle%2Fplugins%2Fsrc%2Fmain%2Fgroovy%2Forg%2Fgrails%2Fgradle%2Fplugin%2Fcore%2FGrailsStartupProgressProvider.groovy&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLWdyYWRsZS9wbHVnaW5zL3NyYy9tYWluL2dyb292eS9vcmcvZ3JhaWxzL2dyYWRsZS9wbHVnaW4vY29yZS9HcmFpbHNTdGFydHVwUHJvZ3Jlc3NQcm92aWRlci5ncm9vdnk=) | `0.0000% <0.0000%> (ø)` | |
   | [.../java/org/apache/grails/startup/StartupAccess.java](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&filepath=grails-startup-progress%2Fsrc%2Fmain%2Fjava%2Forg%2Fapache%2Fgrails%2Fstartup%2FStartupAccess.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLXN0YXJ0dXAtcHJvZ3Jlc3Mvc3JjL21haW4vamF2YS9vcmcvYXBhY2hlL2dyYWlscy9zdGFydHVwL1N0YXJ0dXBBY2Nlc3MuamF2YQ==) | `81.5790% <81.5790%> (ø)` | |
   | [...java/org/apache/grails/startup/StartupBrowser.java](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree&filepath=grails-startup-progress%2Fsrc%2Fmain%2Fjava%2Forg%2Fapache%2Fgrails%2Fstartup%2FStartupBrowser.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLXN0YXJ0dXAtcHJvZ3Jlc3Mvc3JjL21haW4vamF2YS9vcmcvYXBhY2hlL2dyYWlscy9zdGFydHVwL1N0YXJ0dXBCcm93c2VyLmphdmE=) | `57.1429% <57.1429%> (ø)` | |
   | ... and [4 more](https://app.codecov.io/gh/apache/grails-core/pull/16536?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | |
   
   ... and [3 files with indirect coverage changes](https://app.codecov.io/gh/apache/grails-core/pull/16536/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)
   </details>
   <details><summary> :rocket: New features to boost your workflow: </summary>
   
   - :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
   - :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
   </details>


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org