diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/makingChangesToADeployedApplication.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/makingChangesToADeployedApplication.adoc index b7114e06911..0a034c6f80e 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/makingChangesToADeployedApplication.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/makingChangesToADeployedApplication.adoc @@ -46,8 +46,10 @@ There are also some system properties to control GSP reloading: |=== |Name|Description|Default |grails.gsp.enable.reload|system property for enabling the GSP reload mode (alternative to adding it in the file-based application configuration| -|grails.gsp.reload.interval|interval between checking the lastmodified time of the gsp source file, unit is milliseconds|5000 -|grails.gsp.reload.granularity|the number of milliseconds leeway to give before deciding a file is out of date. this is needed because different roundings usually cause a 1000ms difference in lastmodified times|1000 +|grails.gsp.reload.interval|interval between checks of the gsp source file, unit is milliseconds|5000 +|grails.gsp.reload.granularity|the number of milliseconds leeway to give before deciding a file is out of date. this is needed because different roundings usually cause a 1000ms difference in lastmodified times. Applies only to pages compared by modification time — see below|2000 |=== GSP reloading is supported for precompiled GSPs since Grails 1.3.5. + +A precompiled GSP records a checksum of the page it was compiled from, and is reloaded when the source no longer matches that checksum. Comparing content rather than modification times means a page that was copied or checked out afresh — and so carries a new modification time but the same content — is not needlessly recompiled, and an edit is detected however close together two writes fall. Pages compiled at runtime are compared the same way. A page precompiled by a version of Grails earlier than 8.0.0 carries no checksum, and is still compared by modification time. diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index a33dc7de530..a9e7f26df0e 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -2354,3 +2354,41 @@ resolved unambiguously before can become ambiguous. Removing the packages from `grails.spring.bean.packages`, or the stray classes from those packages, restores the previous set of beans. + + +==== 45. Precompiled GSPs Record a Checksum Instead of a Modification Time + +A precompiled GSP used to bake the modification time of its `.gsp` source into the generated class, as a +`LAST_MODIFIED` constant. Git stores no modification times, so every fresh clone or CI checkout gave each +source a new one, and the generated classes differed on every checkout even when the sources were +byte-for-byte identical. Because `LAST_MODIFIED` was a compile-time constant it was part of the class's ABI, +so the difference survived even Gradle's compile-classpath normalization and every task downstream of a jar +containing precompiled GSPs missed the build cache. + +Pages compiled by Grails 8.0.0 and later record a `SOURCE_CHECKSUM` of the page source instead, and +`LAST_MODIFIED` is emitted as `0`. Identical sources now compile to identical bytes on every machine. + +Runtime reloading, described in +<>, is unaffected: a +precompiled page is reloaded when its source no longer matches the recorded checksum. Detection is now more +accurate than it was, because a page that was merely copied or checked out afresh is no longer treated as +changed, and an edit is caught however close together two writes fall. Pages precompiled by an earlier +version carry no checksum and continue to be compared by modification time. + +Pages compiled at runtime record a checksum as well, so development-mode reloading changes in two ways. +An edit is now picked up whenever the source's modification time or length moves, which any ordinary save +does; previously a save landing within `grails.gsp.reload.granularity` milliseconds of the recorded time went +unnoticed until a later edit moved it clear of that window. In the other direction, a page whose modification +time moves without its content changing is no longer recompiled, so touching a file — or a build step copying +views into place — no longer reloads pages that did not actually change. If you were using `touch` to force a +page to recompile, edit the page instead. + +One public API changes behaviour: `GroovyPageMetaInfo.getLastModified()` returns `0` for pages precompiled +by Grails 8.0.0 or later, since no modification time is recorded for them. Use `getSourceChecksum()` to +identify the source a page was compiled from. + +One cross-version note: `LAST_MODIFIED = 0` only means "nothing recorded" to a runtime that understands the +checksum. An application still running an earlier Grails that consumes a plugin precompiled by 8.0.0 or later +evaluates the old condition instead, so with GSP reloading enabled every such page is declared stale on its +first check and recompiled from source at runtime. The behaviour is reload-only and self-correcting, but it +is worth knowing before mixing versions. diff --git a/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPageMetaInfo.java b/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPageMetaInfo.java index f8f50c4f16f..9e030de8318 100644 --- a/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPageMetaInfo.java +++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPageMetaInfo.java @@ -76,6 +76,8 @@ public class GroovyPageMetaInfo implements GrailsApplicationAware { private Class pageClass; private Constructor pageClassConstructor; private long lastModified; + private String sourceChecksum; + private volatile SourceStamp checksumStamp; private InputStream groovySource; private String contentType; private int[] lineNumbers; @@ -128,7 +130,15 @@ public GroovyPageMetaInfo(Class pageClass) { } contentType = (String) ReflectionUtils.getField(ReflectionUtils.findField(pageClass, GroovyPageParser.CONSTANT_NAME_CONTENT_TYPE), null); jspTags = (Map) ReflectionUtils.getField(ReflectionUtils.findField(pageClass, GroovyPageParser.CONSTANT_NAME_JSP_TAGS), null); + // Read unguarded, unlike SOURCE_CHECKSUM below: LAST_MODIFIED has been emitted by every version of the + // compiler, so a page class without it cannot exist. That makes the constant permanent ABI which must + // keep being emitted even though GroovyPageCompiler now always writes 0 -- pages precompiled by earlier + // versions still carry a real timestamp and are still compared by it. lastModified = (Long) ReflectionUtils.getField(ReflectionUtils.findField(pageClass, GroovyPageParser.CONSTANT_NAME_LAST_MODIFIED), null); + Field sourceChecksumField = ReflectionUtils.findField(pageClass, GroovyPageParser.CONSTANT_NAME_SOURCE_CHECKSUM); + if (sourceChecksumField != null) { + sourceChecksum = (String) ReflectionUtils.getField(sourceChecksumField, null); + } expressionCodecName = (String) ReflectionUtils.getField(ReflectionUtils.findField(pageClass, GroovyPageParser.CONSTANT_NAME_EXPRESSION_CODEC), null); staticCodecName = (String) ReflectionUtils.getField(ReflectionUtils.findField(pageClass, GroovyPageParser.CONSTANT_NAME_STATIC_CODEC), null); outCodecName = (String) ReflectionUtils.getField(ReflectionUtils.findField(pageClass, GroovyPageParser.CONSTANT_NAME_OUT_CODEC), null); @@ -315,6 +325,12 @@ public void setPageClass(Class pageClass) { initializePluginPath(); } + /** + * @return the modification time of the source this page was compiled from, or {@code 0} if none was + * recorded. Pages precompiled by Grails 8.0.0 or later always report {@code 0}: the compiler records a + * checksum of the source instead, so that identical sources compile to identical bytes. Use + * {@link #getSourceChecksum()} to identify the source such a page was compiled from. + */ public long getLastModified() { return lastModified; } @@ -323,6 +339,22 @@ public void setLastModified(long lastModified) { this.lastModified = lastModified; } + /** + * @return the checksum of the GSP source this page was compiled from, or {@code null} if none was recorded + * @since 8.0.0 + */ + public String getSourceChecksum() { + return this.sourceChecksum; + } + + /** + * @param sourceChecksum the checksum of the GSP source this page was compiled from + * @since 8.0.0 + */ + public void setSourceChecksum(String sourceChecksum) { + this.sourceChecksum = sourceChecksum; + } + public InputStream getGroovySource() { return groovySource; } @@ -404,6 +436,87 @@ public void applyLastModifiedFromResource(Resource resource) { this.lastModified = establishLastModified(resource); } + /** + * The modification time and length a page's source had when it last matched the recorded checksum. + */ + private record SourceStamp(long lastModified, long contentLength) { + } + + /** + * Decides whether the given source still hashes to the checksum this page recorded. + *

+ * Hashing means reading the page in full, so the modification time and length observed the last time the + * two matched are kept, and the read is skipped while neither has moved. That returns the steady-state + * cost of a reload-enabled application to one stat per page per check interval, which is what the + * timestamp comparison used to cost. + *

+ * Note what role the timestamp plays here: it is a fast path for skipping work, never the thing that + * decides staleness. Anything that moves it without changing the page -- a fresh checkout, a copy, a + * touch -- costs one hash and then correctly reports no change, where the old comparison reported the + * page stale. The one edit this misses is an edit preserving both the modification time and the exact + * length, still a narrower gap than the {@code grails.gsp.reload.granularity} window it replaces. + * + * @param resource the source to compare against the recorded checksum + * @return true if the source no longer matches + */ + private boolean hasSourceChecksumChanged(Resource resource) { + SourceStamp stamp = readSourceStamp(resource); + if (stamp != null && stamp.equals(this.checksumStamp)) { + return false; + } + String currentChecksum = establishChecksum(resource); + if (currentChecksum == null) { + return false; + } + if (this.sourceChecksum.equals(currentChecksum)) { + this.checksumStamp = stamp; + return false; + } + return true; + } + + /** + * @param resource the Resource to stamp + * @return its modification time and length, or null if either could not be read -- in which case the + * caller must hash rather than assume the source is unchanged + */ + private SourceStamp readSourceStamp(Resource resource) { + long modified = establishLastModified(resource); + if (modified <= 0) { + return null; + } + try { + long length = resource.contentLength(); + return length >= 0 ? new SourceStamp(modified, length) : null; + } + catch (IOException e) { + return null; + } + } + + /** + * Attempts to checksum the given resource. If it cannot be read, {@code null} is returned, which is + * treated the same way an unobtainable modification time is: the page is left alone rather than + * reloaded on the strength of a failed read. + * + * @param resource the Resource to digest + * @return the checksum, or null if it could not be established + */ + private String establishChecksum(Resource resource) { + if (resource == null) { + return null; + } + try { + return GroovyPageParser.checksumOf(resource.getContentAsByteArray()); + } + catch (IOException e) { + if (LOG.isDebugEnabled()) { + LOG.debug("Unable to checksum GSP source [" + resource + "], leaving the compiled page in place", e); + } + return null; + } + } + /** * Attempts to establish what the last modified date of the given resource is. If the last modified date cannot * be etablished -1 is returned @@ -474,10 +587,27 @@ public Resource checkIfReloadableResourceHasChanged(final PrivilegedAction 0 && Math.abs(currentLastmodified - lastModified) > LASTMODIFIED_CHECK_GRANULARITY) { + if (currentLastmodified > 0 && lastModified != 0 && + Math.abs(currentLastmodified - lastModified) > LASTMODIFIED_CHECK_GRANULARITY) { return resource; } } diff --git a/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPagesTemplateEngine.java b/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPagesTemplateEngine.java index 1599ffaf210..c9702e9ec96 100644 --- a/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPagesTemplateEngine.java +++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPagesTemplateEngine.java @@ -64,7 +64,6 @@ import grails.config.Settings; import grails.core.GrailsApplication; import grails.core.GrailsClass; -import grails.io.IOUtils; import grails.util.CacheEntry; import grails.util.Environment; import grails.util.GrailsUtil; @@ -583,8 +582,13 @@ protected GroovyPageMetaInfo buildPageMetaInfo(InputStream inputStream, Resource GroovyPageParser parser; String path = getPathForResource(res); + // Buffer the raw bytes rather than decoding straight off the stream, so the page can be checksummed + // exactly as it is stored. The checksum has to be taken over the stored bytes, not over gspSource or + // the decorated source below, because the runtime re-reads the resource raw when checking staleness. + byte[] gspBytes; try { - String gspSource = IOUtils.toString(inputStream, getGspEncoding()); + gspBytes = inputStream.readAllBytes(); + String gspSource = new String(gspBytes, getGspEncoding()); parser = new GroovyPageParser(name, path, path, decorateGroovyPageSource(new StringBuilder(gspSource)).toString(), grailsApplication != null ? grailsApplication.getConfig() : null); } @@ -597,6 +601,11 @@ protected GroovyPageMetaInfo buildPageMetaInfo(InputStream inputStream, Resource // Make a new metaInfo GroovyPageMetaInfo metaInfo = createPageMetaInfo(parser, in); metaInfo.applyLastModifiedFromResource(res); + // Record a checksum here as well as a timestamp, so a page compiled at runtime is checked for staleness + // the same way a precompiled one is. It costs nothing extra -- the bytes are already in hand -- and it + // catches the edits the timestamp comparison misses, which in development is the case that matters: + // two saves inside the grails.gsp.reload.granularity window. + metaInfo.setSourceChecksum(GroovyPageParser.checksumOf(gspBytes)); try { metaInfo.setPageClass(compileGroovyPage(in, name, path, metaInfo)); metaInfo.setHtmlParts(parser.getHtmlPartsArray()); diff --git a/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageCompiler.groovy b/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageCompiler.groovy index 9ebc8e1c144..90955e64929 100644 --- a/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageCompiler.groovy +++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageCompiler.groovy @@ -215,30 +215,44 @@ class GroovyPageCompiler { File gspgroovyfile = new File(new File(generatedGroovyPagesDirectory, packageDir), className + '.groovy') // gspgroovyfile.getParentFile().mkdirs() - gspfile.withInputStream { InputStream gspinput -> - GroovyPageParser gpp = new GroovyPageParser(viewuri - '.gsp', viewuri, gspfile.absolutePath, gspinput, encoding, expressionCodec, configMap) - gpp.packageName = packageName - gpp.className = className - gpp.lastModified = gspfile.lastModified() - StringWriter gsptarget = new StringWriter() - gpp.generateGsp(gsptarget) - gsptarget.flush() - // write static html parts to data file (read from classpath at runtime) - File htmlDataFile = new File(new File(targetDir, packageDir), className + GroovyPageMetaInfo.HTML_DATA_POSTFIX) - htmlDataFile.parentFile.mkdirs() - gpp.writeHtmlParts(htmlDataFile) - // write linenumber mapping info to data file - File lineNumbersDataFile = new File(new File(targetDir, packageDir), className + GroovyPageMetaInfo.LINENUMBERS_DATA_POSTFIX) - gpp.writeLineNumbers(lineNumbersDataFile) + // Read the page once and keep the raw bytes: the checksum has to be taken over the source exactly + // as stored, since the runtime re-reads the resource raw when comparing. + byte[] gspSource = gspfile.bytes - // register viewuri -> classname mapping - compileGSPResults[viewuri] = fullClassName + GroovyPageParser gpp = new GroovyPageParser(viewuri - '.gsp', viewuri, gspfile.absolutePath, + new String(gspSource, encoding ?: GroovyPageParser.DEFAULT_ENCODING), expressionCodec, configMap) + gpp.packageName = packageName + gpp.className = className + // Record what the source *is*, not when it was last touched. LAST_MODIFIED is emitted as a + // `static final long`, so it belongs to the class's ABI and is inlined into callers, which + // even Gradle's COMPILE_CLASSPATH normalization cannot see past. Git stores no modification + // times, so every checkout gave each .gsp a new one and identical sources compiled to + // different bytes, costing every downstream consumer of the jar its build cache. + // + // SOURCE_CHECKSUM answers what the timestamp was only ever a proxy for -- has the source + // changed? -- and answers it identically on every machine. GroovyPageMetaInfo prefers it and + // falls back to LAST_MODIFIED for pages compiled by earlier versions, so the zero here means + // "no timestamp recorded", never "never reload". + gpp.lastModified = 0L + gpp.sourceChecksum = GroovyPageParser.checksumOf(gspSource) + StringWriter gsptarget = new StringWriter() + gpp.generateGsp(gsptarget) + gsptarget.flush() + // write static html parts to data file (read from classpath at runtime) + File htmlDataFile = new File(new File(targetDir, packageDir), className + GroovyPageMetaInfo.HTML_DATA_POSTFIX) + htmlDataFile.parentFile.mkdirs() + gpp.writeHtmlParts(htmlDataFile) + // write linenumber mapping info to data file + File lineNumbersDataFile = new File(new File(targetDir, packageDir), className + GroovyPageMetaInfo.LINENUMBERS_DATA_POSTFIX) + gpp.writeLineNumbers(lineNumbersDataFile) - CompilationUnit unit = new CompilationUnit(compilerConfig, null, classLoader) - unit.addPhaseOperation(operation, Phases.CANONICALIZATION) - unit.addSource(gspgroovyfile.name, gsptarget.toString()) - unit.compile() - } + // register viewuri -> classname mapping + compileGSPResults[viewuri] = fullClassName + + CompilationUnit unit = new CompilationUnit(compilerConfig, null, classLoader) + unit.addPhaseOperation(operation, Phases.CANONICALIZATION) + unit.addSource(gspgroovyfile.name, gsptarget.toString()) + unit.compile() } else { compileGSPResults[viewuri] = fullClassName } diff --git a/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageParser.java b/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageParser.java index 62559b2b8b9..b92666016e2 100644 --- a/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageParser.java +++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageParser.java @@ -27,11 +27,14 @@ import java.io.OutputStreamWriter; import java.io.UnsupportedEncodingException; import java.io.Writer; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.HexFormat; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -99,6 +102,7 @@ public class GroovyPageParser implements Tokens { public static final String CONSTANT_NAME_JSP_TAGS = "JSP_TAGS"; public static final String CONSTANT_NAME_CONTENT_TYPE = "CONTENT_TYPE"; public static final String CONSTANT_NAME_LAST_MODIFIED = "LAST_MODIFIED"; + public static final String CONSTANT_NAME_SOURCE_CHECKSUM = "SOURCE_CHECKSUM"; public static final String CONSTANT_NAME_EXPRESSION_CODEC = "EXPRESSION_CODEC"; public static final String CONSTANT_NAME_STATIC_CODEC = "STATIC_CODEC"; public static final String CONSTANT_NAME_OUT_CODEC = "OUT_CODEC"; @@ -107,6 +111,8 @@ public class GroovyPageParser implements Tokens { public static final String CONSTANT_NAME_MODEL_FIELDS_MODE = "MODEL_FIELDS_MODE"; public static final String DEFAULT_ENCODING = "UTF-8"; + private static final String CHECKSUM_ALGORITHM = "SHA-256"; + private static final String MULTILINE_GROOVY_STRING_DOUBLEQUOTES = "\"\"\""; private static final String MULTILINE_GROOVY_STRING_SINGLEQUOTES = "'''"; public static final String MODEL_DIRECTIVE = "model"; @@ -186,6 +192,7 @@ public class GroovyPageParser implements Tokens { public static final String GROOVY_SOURCE_CHAR_ENCODING = "UTF-8"; private Map jspTags = new HashMap<>(); private long lastModified; + private String sourceChecksum; private boolean precompileMode; private Boolean compileStaticMode; private boolean modelFieldsMode; @@ -908,6 +915,10 @@ private void page() { out.println("public static final long " + CONSTANT_NAME_LAST_MODIFIED + " = " + lastModified + "L"); + out.println("public static final String " + + CONSTANT_NAME_SOURCE_CHECKSUM + " = " + + (this.sourceChecksum == null ? "null" : "'" + escapeGroovy(this.sourceChecksum) + "'")); + out.println("public static final String " + CONSTANT_NAME_EXPRESSION_CODEC + " = '" + escapeGroovy(expressionCodecDirectiveValue) + "'"); out.println("public static final String " + @@ -1383,6 +1394,50 @@ public void setLastModified(long lastModified) { this.lastModified = lastModified; } + /** + * Computes the checksum recorded in the {@code SOURCE_CHECKSUM} constant of a generated page. + *

+ * Both the compiler that writes the constant and the runtime that compares against it use this method, so + * that the two can never disagree on how a GSP source is digested. + *

+ * The argument must be the raw stored bytes of the page, exactly as they sit on disk or in + * the jar entry — not the decoded, re-encoded or otherwise decorated source the runtime parse path works + * with. That is the invariant that lets a checksum taken at compile time be compared against one taken by + * re-reading the resource at reload time. + * + * @param source the raw bytes of the GSP source + * @return the checksum as a lower-case hex string + * @since 8.0.0 + */ + public static String checksumOf(byte[] source) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance(CHECKSUM_ALGORITHM).digest(source)); + } + catch (NoSuchAlgorithmException e) { + // every JVM is required to provide SHA-256 + throw new IllegalStateException("Checksum algorithm " + CHECKSUM_ALGORITHM + " is not available", e); + } + } + + /** + * @return a checksum of the GSP source this page was generated from, or {@code null} if none was recorded + * @since 8.0.0 + */ + public String getSourceChecksum() { + return this.sourceChecksum; + } + + /** + * Records a checksum of the GSP source, emitted as the {@code SOURCE_CHECKSUM} constant so that the + * runtime can detect an edited source without depending on its modification time. + * + * @param sourceChecksum the checksum, or {@code null} to record none + * @since 8.0.0 + */ + public void setSourceChecksum(String sourceChecksum) { + this.sourceChecksum = sourceChecksum; + } + public List getHtmlParts() { return htmlParts; } diff --git a/grails-gsp/core/src/test/groovy/org/grails/gsp/GroovyPageMetaInfoReloadSpec.groovy b/grails-gsp/core/src/test/groovy/org/grails/gsp/GroovyPageMetaInfoReloadSpec.groovy new file mode 100644 index 00000000000..b693539538c --- /dev/null +++ b/grails-gsp/core/src/test/groovy/org/grails/gsp/GroovyPageMetaInfoReloadSpec.groovy @@ -0,0 +1,219 @@ +/* + * 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.gsp + +import java.security.PrivilegedAction + +import spock.lang.Specification +import spock.lang.TempDir + +import org.springframework.core.io.FileSystemResource +import org.springframework.core.io.Resource + +import org.grails.gsp.compiler.GroovyPageParser + +/** + * Reload-staleness behaviour of {@link GroovyPageMetaInfo}. + * + * A page compiled by {@code GroovyPageCompiler} records a checksum of its source rather than the source's + * modification time, which git does not preserve across a checkout. Staleness is therefore decided by + * comparing content, falling back to the timestamp for pages compiled before the checksum existed. + * + * Each feature uses a fresh {@code GroovyPageMetaInfo}, because the result of a check is cached for + * {@code grails.gsp.reload.interval} milliseconds. + */ +class GroovyPageMetaInfoReloadSpec extends Specification { + + private static final String PAGE_CONTENT = 'hi' + + /** Differs from {@link #PAGE_CONTENT} in content but not in length. */ + private static final String EQUAL_LENGTH_EDIT = 'ho' + + @TempDir + File tempDir + + private Resource sourcePage(String content = PAGE_CONTENT) { + File page = new File(this.tempDir, 'index.gsp') + page.text = content + new FileSystemResource(page) + } + + private static String checksumOf(Resource resource) { + GroovyPageParser.checksumOf(resource.contentAsByteArray) + } + + private static PrivilegedAction callableFor(Resource resource) { + { -> resource } as PrivilegedAction + } + + void 'a page whose recorded checksum matches its source is not reported as stale'() { + given: 'a precompiled page recording the checksum of the source on disk' + Resource resource = sourcePage() + GroovyPageMetaInfo metaInfo = new GroovyPageMetaInfo() + metaInfo.sourceChecksum = checksumOf(resource) + + expect: + !metaInfo.shouldReload(callableFor(resource)) + } + + void 'a page whose source no longer matches its recorded checksum is reported as stale'() { + given: 'a precompiled page whose source has since been edited' + Resource resource = sourcePage() + GroovyPageMetaInfo metaInfo = new GroovyPageMetaInfo() + metaInfo.sourceChecksum = checksumOf(resource) + resource.getFile().text = 'edited' + + expect: + metaInfo.shouldReload(callableFor(resource)) + } + + void 'a source that was touched but not edited is not reported as stale'() { + given: 'a page whose source carries a modification time nothing like the one it was compiled at' + Resource resource = sourcePage() + GroovyPageMetaInfo metaInfo = new GroovyPageMetaInfo() + metaInfo.sourceChecksum = checksumOf(resource) + assert resource.getFile().setLastModified(resource.getFile().lastModified() + 86_400_000L) + + expect: 'content decides, so a fresh checkout does not force every page to recompile' + !metaInfo.shouldReload(callableFor(resource)) + } + + void 'a recorded checksum decides staleness even when a timestamp is also recorded'() { + given: 'a page carrying both a matching checksum and a timestamp long predating its source' + Resource resource = sourcePage() + GroovyPageMetaInfo metaInfo = new GroovyPageMetaInfo() + metaInfo.sourceChecksum = checksumOf(resource) + metaInfo.lastModified = resource.getFile().lastModified() - 86_400_000L + + expect: 'the content decides, so the timestamp alone cannot force a reload' + !metaInfo.shouldReload(callableFor(resource)) + } + + void 'a recorded checksum reports an edit even when the timestamp agrees'() { + given: 'a page whose source was edited without its timestamp moving' + Resource resource = sourcePage() + GroovyPageMetaInfo metaInfo = new GroovyPageMetaInfo() + metaInfo.sourceChecksum = checksumOf(resource) + long originalTimestamp = resource.getFile().lastModified() + metaInfo.lastModified = originalTimestamp + resource.getFile().text = 'edited' + assert resource.getFile().setLastModified(originalTimestamp) + + expect: 'the content decides, so an agreeing timestamp cannot mask the edit' + metaInfo.shouldReload(callableFor(resource)) + } + + void 'an edit within the timestamp granularity window is still reported as stale'() { + given: 'an edited source whose modification time is unchanged, as a rapid rewrite can leave it' + Resource resource = sourcePage() + GroovyPageMetaInfo metaInfo = new GroovyPageMetaInfo() + metaInfo.sourceChecksum = checksumOf(resource) + long originalTimestamp = resource.getFile().lastModified() + resource.getFile().text = 'edited' + assert resource.getFile().setLastModified(originalTimestamp) + + expect: 'the timestamp comparison would have missed this; the checksum does not' + metaInfo.shouldReload(callableFor(resource)) + } + + void 'a page recorded with a timestamp older than its source is reported as stale'() { + given: 'a page compiled before SOURCE_CHECKSUM existed, so only a timestamp is recorded' + Resource resource = sourcePage() + GroovyPageMetaInfo metaInfo = new GroovyPageMetaInfo() + metaInfo.lastModified = resource.getFile().lastModified() - 60_000L + + expect: 'the pre-existing staleness detection still applies to it' + metaInfo.shouldReload(callableFor(resource)) + } + + void 'a page recorded with the same timestamp as its source is not reported as stale'() { + given: + Resource resource = sourcePage() + GroovyPageMetaInfo metaInfo = new GroovyPageMetaInfo() + metaInfo.lastModified = resource.getFile().lastModified() + + expect: + !metaInfo.shouldReload(callableFor(resource)) + } + + void 'a page with neither a checksum nor a timestamp is not reported as stale'() { + given: 'nothing was recorded to compare the source against' + Resource resource = sourcePage() + GroovyPageMetaInfo metaInfo = new GroovyPageMetaInfo() + metaInfo.lastModified = 0L + + expect: 'the page is left in place rather than reported as changed on every single check' + !metaInfo.shouldReload(callableFor(resource)) + } + + void 'a later check re-reads the source once its stamp moves, and skips the read while it has not'() { + given: 'a page whose source matches its recorded checksum' + Resource resource = sourcePage() + GroovyPageMetaInfo metaInfo = new GroovyPageMetaInfo() + metaInfo.sourceChecksum = checksumOf(resource) + + and: 'a first check, which hashes and remembers the stamp the source had while matching' + assert !metaInfo.shouldReload(callableFor(resource)) + + when: 'the page is edited in a way that moves neither its length nor its modification time' + long stamp = resource.getFile().lastModified() + resource.getFile().text = EQUAL_LENGTH_EDIT + assert resource.getFile().setLastModified(stamp) + sleep(GroovyPageMetaInfo.LASTMODIFIED_CHECK_INTERVAL + 500L) + + then: 'the stamp pre-check skips the hash, so this one edit goes unseen -- the documented trade-off' + !metaInfo.shouldReload(callableFor(resource)) + + when: 'the length moves, as all but this contrived case would' + resource.getFile().text = 'a longer replacement body' + sleep(GroovyPageMetaInfo.LASTMODIFIED_CHECK_INTERVAL + 500L) + + then: 'the stamp no longer matches, so the source is re-read and the edit is caught' + metaInfo.shouldReload(callableFor(resource)) + } + + void 'a page whose timestamp could not be established reloads once and recovers'() { + given: 'a runtime-compiled page that recorded -1, as establishLastModified yields on an unreadable resource' + Resource resource = sourcePage() + GroovyPageMetaInfo metaInfo = new GroovyPageMetaInfo() + metaInfo.lastModified = -1L + + expect: 'it is reported stale so the first readable check reloads it, rather than stranding it until restart' + metaInfo.shouldReload(callableFor(resource)) + } + + void 'a missing source resource never triggers a reload'() { + given: + Resource resource = new FileSystemResource(new File(this.tempDir, 'absent.gsp')) + GroovyPageMetaInfo metaInfo = new GroovyPageMetaInfo() + metaInfo.sourceChecksum = 'a-checksum-for-a-page-with-no-source' + + expect: + !metaInfo.shouldReload(callableFor(resource)) + } + + void 'a page with no way to resolve its source never triggers a reload'() { + given: 'the locator supplies no callable, as it does for views inside a binary plugin jar' + GroovyPageMetaInfo metaInfo = new GroovyPageMetaInfo() + metaInfo.sourceChecksum = 'a-checksum-for-a-page-shipped-in-a-jar' + + expect: + !metaInfo.shouldReload(null) + } +} diff --git a/grails-gsp/core/src/test/groovy/org/grails/gsp/GroovyPagesTemplateEngineChecksumSpec.groovy b/grails-gsp/core/src/test/groovy/org/grails/gsp/GroovyPagesTemplateEngineChecksumSpec.groovy new file mode 100644 index 00000000000..af9f825b397 --- /dev/null +++ b/grails-gsp/core/src/test/groovy/org/grails/gsp/GroovyPagesTemplateEngineChecksumSpec.groovy @@ -0,0 +1,63 @@ +/* + * 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.gsp + +import spock.lang.Specification + +import org.springframework.core.io.ByteArrayResource + +import org.grails.gsp.compiler.GroovyPageParser + +/** + * A page compiled at runtime records a checksum of its source as well as a modification time, so that it is + * checked for staleness the same way a precompiled page is. + * + * The checksum must be taken over the bytes as stored, not over the decoded or decorated source the parse + * path works with, because the runtime re-reads the resource raw when comparing. + */ +class GroovyPagesTemplateEngineChecksumSpec extends Specification { + + private static final byte[] PAGE_SOURCE = '${greeting}'.bytes + + private GroovyPagesTemplateEngine engineForRuntimeCompilation() { + new GroovyPagesTemplateEngine().tap { afterPropertiesSet() } + } + + void 'a page compiled at runtime records a checksum of its stored bytes'() { + given: + GroovyPagesTemplateEngine engine = engineForRuntimeCompilation() + + when: + GroovyPageTemplate template = engine.createTemplate(new ByteArrayResource(PAGE_SOURCE)) as GroovyPageTemplate + + then: 'the checksum is over the raw bytes, so it matches one taken by re-reading the resource' + template.metaInfo.sourceChecksum == GroovyPageParser.checksumOf(PAGE_SOURCE) + } + + void 'a runtime-compiled page still records its source modification time'() { + given: + GroovyPagesTemplateEngine engine = engineForRuntimeCompilation() + + when: + GroovyPageTemplate template = engine.createTemplate(new ByteArrayResource(PAGE_SOURCE)) as GroovyPageTemplate + + then: 'the timestamp path is unchanged, so pages compiled by earlier versions keep comparing by it' + template.metaInfo.lastModified != 0L + } +} diff --git a/grails-gsp/core/src/test/groovy/org/grails/gsp/compiler/GroovyPageCompilerReproducibilitySpec.groovy b/grails-gsp/core/src/test/groovy/org/grails/gsp/compiler/GroovyPageCompilerReproducibilitySpec.groovy new file mode 100644 index 00000000000..acf540e5274 --- /dev/null +++ b/grails-gsp/core/src/test/groovy/org/grails/gsp/compiler/GroovyPageCompilerReproducibilitySpec.groovy @@ -0,0 +1,110 @@ +/* + * 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.gsp.compiler + +import spock.lang.Specification +import spock.lang.TempDir + +import org.grails.gsp.GroovyPageMetaInfo + +/** + * Precompiled GSPs must not vary with the modification time of their source. + * + * Git records no modification times, so every fresh clone or CI checkout gives each .gsp a new one. Baking + * that into the generated class made otherwise identical jars differ on every checkout, and because + * {@code LAST_MODIFIED} was a compile-time constant the difference survived Gradle's compile-classpath + * normalization, so every downstream task missed the build cache. + */ +class GroovyPageCompilerReproducibilitySpec extends Specification { + + private static final String PAGE_CONTENT = 'Hello' + + @TempDir + File tempDir + + private File viewsDir + private File page + + void setup() { + this.viewsDir = new File(this.tempDir, 'views') + this.page = new File(this.viewsDir, 'index.gsp') + this.page.parentFile.mkdirs() + this.page.text = PAGE_CONTENT + } + + void 'a source compiled at two different modification times produces identical classes'() { + when: 'the same page is compiled twice, as two checkouts of one commit would' + assert this.page.setLastModified(1_000_000_000_000L) + byte[] first = compileToBytes('first') + + and: + assert this.page.setLastModified(1_700_000_000_000L) + byte[] second = compileToBytes('second') + + then: 'the jar built from them is byte-identical, so downstream tasks keep their cache hits' + first == second + } + + void 'a compiled page records a checksum of its source instead of a modification time'() { + when: + GroovyPageMetaInfo metaInfo = compileToMetaInfo('recorded') + + then: 'the checksum identifies the content' + metaInfo.sourceChecksum ==~ /[0-9a-f]{64}/ + + and: 'no modification time is baked in for a fresh checkout to invalidate' + metaInfo.lastModified == 0L + } + + void 'an edited source produces a different checksum'() { + given: + GroovyPageMetaInfo before = compileToMetaInfo('before') + + when: + this.page.text = 'something else entirely' + GroovyPageMetaInfo after = compileToMetaInfo('after') + + then: 'the runtime can still tell that the page changed' + before.sourceChecksum != after.sourceChecksum + } + + private Map compile(File targetDir) { + targetDir.mkdirs() + GroovyPageCompiler compiler = new GroovyPageCompiler() + compiler.viewsDir = this.viewsDir + compiler.srcFiles = [this.page] + compiler.targetDir = targetDir + compiler.generatedGroovyPagesDirectory = new File(this.tempDir, 'generated').tap { mkdirs() } + compiler.compile() + } + + private byte[] compileToBytes(String name) { + File targetDir = new File(this.tempDir, name) + Map results = compile(targetDir) + new File(targetDir, "${results.values().first()}.class").bytes + } + + private GroovyPageMetaInfo compileToMetaInfo(String name) { + File targetDir = new File(this.tempDir, name) + Map results = compile(targetDir) + new URLClassLoader([targetDir.toURI().toURL()] as URL[], getClass().classLoader).withCloseable { URLClassLoader loader -> + new GroovyPageMetaInfo(loader.loadClass(results.values().first() as String)) + } + } +} diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/pages/ParseSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/pages/ParseSpec.groovy index ee6f077d902..55231f9f0b8 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/pages/ParseSpec.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/pages/ParseSpec.groovy @@ -53,6 +53,7 @@ protected void init() { } public static final String CONTENT_TYPE = 'text/html;charset=UTF-8' public static final long LAST_MODIFIED = 0L +public static final String SOURCE_CHECKSUM = null public static final String EXPRESSION_CODEC = 'HTML' public static final String STATIC_CODEC = 'none' public static final String OUT_CODEC = 'none'