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: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to