codeconsole commented on code in PR #16142:
URL: https://github.com/apache/grails-core/pull/16142#discussion_r3779215841


##########
grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageCompiler.groovy:
##########
@@ -215,11 +215,26 @@ class GroovyPageCompiler {
             File gspgroovyfile = new File(new 
File(generatedGroovyPagesDirectory, packageDir), className + '.groovy')
             // gspgroovyfile.getParentFile().mkdirs()
 
-            gspfile.withInputStream { InputStream gspinput ->
+            byte[] gspSource = gspfile.bytes
+            // closing a ByteArrayInputStream is a no-op, so there is nothing 
to release here
+            String sourceChecksum = GroovyPageParser.checksumOf(new 
ByteArrayInputStream(gspSource))
+
+            new ByteArrayInputStream(gspSource).withStream { 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()
+                // 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

Review Comment:
   You're right, and I've moved it rather than argue the point — this now 
targets `7.1.x`, rebased, with @maczikasz's commit still at the base.
   
   Concretely what changes: `GroovyPageMetaInfo.getLastModified()` returns `0` 
for pages precompiled by this version instead of the source mtime. Nothing in 
this repository reads the getter — the field's only consumer is the staleness 
check right below it, which this PR rewires — but it is public and I can't see 
downstream.
   
   The two halves can't be separated, which is why there's no smaller version 
of this: the reproducibility fix *is* dropping the timestamp from the class, 
and dropping it without recording something in its place silently disables 
reloading for an application's own precompiled pages.
   
   The move paid for itself twice, as it turns out: `7.1.x` has an upgrade 
guide covering 7.0 → 7.1, so the `getLastModified()` change now has somewhere 
to be announced. `7.0.x` had no within-7.0 guide, which is why I'd fallen back 
to "release note" earlier.
   
   Happy to move it back if you and @matrei would rather have it in `7.0.x` — 
the unreproducible jars are shipping from there today, and 
`etc/bin/verify-reproducible.sh` has no GSP exclusion, so release verification 
currently diffs on every artifact carrying precompiled pages. Your call.



##########
grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPageMetaInfo.java:
##########
@@ -474,10 +518,21 @@ public Resource checkIfReloadableResourceHasChanged(final 
PrivilegedAction<Resou
             public Resource call() {
                 Resource resource = resourceCallable.run();
                 if (resource != null && resource.exists()) {
+                    // A page compiled by GroovyPageCompiler records a 
checksum of its source rather than the
+                    // source's modification time, which git does not preserve 
across a checkout. Comparing
+                    // content answers the question directly: a page that was 
merely touched is not stale, and
+                    // an edit is caught however close together the writes 
fall.
+                    if (sourceChecksum != null) {
+                        String currentChecksum = establishChecksum(resource);
+                        return (currentChecksum != null && 
!sourceChecksum.equals(currentChecksum)) ? resource : null;
+                    }
+                    // Pages compiled before SOURCE_CHECKSUM existed, and 
pages compiled at runtime, still carry a
+                    // real timestamp. A lastModified of 0 means none was 
recorded, so there is nothing to compare.
                     long currentLastmodified = establishLastModified(resource);
                     // granularity is required since lastmodified information 
is rounded somewhere in copying & war (zip) file information
                     // usually the lastmodified time is 1000L apart in files 
and in files extracted from the zip (war) file
-                    if (currentLastmodified > 0 && 
Math.abs(currentLastmodified - lastModified) > LASTMODIFIED_CHECK_GRANULARITY) {
+                    if (currentLastmodified > 0 && lastModified > 0 &&

Review Comment:
   Fixed in `45e2452cb9`. Good catch — this was a real regression, not a nit.
   
   Confirmed the path: `establishLastModified` returns `-1` for a null 
resource, `FileNotFoundException` and `IOException`, and 
`applyLastModifiedFromResource` stores it verbatim. Under `lastModified > 0` 
such a page could never reload again, silently, exactly as you describe.
   
   Guard is now `lastModified != 0`, so `0` keeps its "nothing recorded" 
meaning while `-1` keeps its self-healing one. Added `a page whose timestamp 
could not be established reloads once and recovers`, and verified it's a real 
guard rather than decoration: reverting to `> 0` fails that feature alone and 
leaves the others passing.



##########
grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPageMetaInfo.java:
##########
@@ -474,10 +518,21 @@ public Resource checkIfReloadableResourceHasChanged(final 
PrivilegedAction<Resou
             public Resource call() {
                 Resource resource = resourceCallable.run();
                 if (resource != null && resource.exists()) {
+                    // A page compiled by GroovyPageCompiler records a 
checksum of its source rather than the
+                    // source's modification time, which git does not preserve 
across a checkout. Comparing
+                    // content answers the question directly: a page that was 
merely touched is not stale, and
+                    // an edit is caught however close together the writes 
fall.
+                    if (sourceChecksum != null) {
+                        String currentChecksum = establishChecksum(resource);

Review Comment:
   Implemented in `12fed6255c` — you were right that this needed weighing 
before it met an app with hundreds of views.
   
   The stamp the source had when it last matched the checksum is remembered, 
and the read is skipped while neither it nor the length has moved. Steady state 
for a reload-enabled application is back to one stat per page per check 
interval.
   
   Coherence with the `CacheEntry` turned out simpler than I expected: the 
stamp is only consulted inside the callable the cache already gates, so there's 
one decision per interval rather than two layers racing. The comment at the 
call site spells out the role change — the timestamp is a fast path for 
skipping work here, never the thing that decides staleness, so anything that 
moves it without changing the page costs one hash and then correctly reports no 
change.
   
   Covered by `a later check re-reads the source once its stamp moves, and 
skips the read while it has not`. It has to sleep past 
`grails.gsp.reload.interval` to reach a second check, since the skip isn't 
otherwise observable through the public API — flagging that as a test smell you 
may want to weigh in on. It asserts both halves: the equal-length, equal-mtime 
edit goes unseen (the documented trade-off), and a length change is caught. 
Removing the pre-check fails it.



##########
grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageCompiler.groovy:
##########
@@ -215,11 +215,26 @@ class GroovyPageCompiler {
             File gspgroovyfile = new File(new 
File(generatedGroovyPagesDirectory, packageDir), className + '.groovy')
             // gspgroovyfile.getParentFile().mkdirs()
 
-            gspfile.withInputStream { InputStream gspinput ->
+            byte[] gspSource = gspfile.bytes
+            // closing a ByteArrayInputStream is a no-op, so there is nothing 
to release here
+            String sourceChecksum = GroovyPageParser.checksumOf(new 
ByteArrayInputStream(gspSource))
+
+            new ByteArrayInputStream(gspSource).withStream { 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()
+                // 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

Review Comment:
   Both taken.
   
   The cross-version note is now in the 7.0 → 7.1 upgrade guide rather than 
release notes, since retargeting to `7.1.x` gave it a proper home. One 
narrowing from tracing it: for GSPs inside binary plugin jars 
`DefaultGroovyPageLocator.resolveViewInBinaryPlugin` nulls the resource 
callable, so those never reach the comparison at all. The reachable case is the 
one you describe — an app whose own `gsp/views.properties` is absent, so 
`getResource('classpath:gsp/views.properties')` picks up a plugin's and the 
plugin's pages get a live callable. Narrow, self-correcting, reload-only, and 
genuinely surprising when it lands.
   
   On the unguarded read: correct, and there's now a comment at 
`GroovyPageMetaInfo.java:132` recording that `LAST_MODIFIED` is permanent ABI — 
every compiler version has emitted it, so a page class without it can't exist, 
and it must keep being emitted even though the value is always `0`, because 
pages precompiled by earlier versions still carry a real timestamp and are 
still compared by it.



##########
grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageParser.java:
##########
@@ -1380,6 +1391,54 @@ public void setLastModified(long lastModified) {
         this.lastModified = lastModified;
     }
 
+    /**
+     * Computes the checksum recorded in the {@code SOURCE_CHECKSUM} constant 
of a generated page.
+     * <p>
+     * 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 
stream is read to the end but is
+     * not closed; closing it remains the caller's responsibility.
+     *
+     * @param source the raw bytes of the GSP source
+     * @return the checksum as a lower-case hex string
+     * @throws IOException if the source cannot be read
+     * @since 7.0.16
+     */
+    public static String checksumOf(InputStream source) throws IOException {

Review Comment:
   Adopted in `e4055c2cb7`. You're right that the stream signature bought 
nothing — both callers hold the bytes already.
   
   `checksumOf(byte[])` now delegates to `MessageDigest.digest(byte[])`, which 
drops the read loop, the `IOException` from the signature, and the compiler's 
checksum-specific `ByteArrayInputStream` along with its comment. 
`establishChecksum` uses `resource.getContentAsByteArray()` (spring-core 
resolves to 6.2.19 here, well past 6.0.5).
   
   One wrapper does remain, so I don't want to claim otherwise: 
`GroovyPageParser`'s constructor takes an `InputStream`, so the compiler still 
wraps the bytes once to feed the parse. Keeping it avoids reading the file 
twice.
   
   The invariant is now spelled out in the javadoc — raw stored bytes, not the 
decoded or re-encoded source the parse path works with. That turned out to 
matter more than documentation: it's what forced the runtime-compile-path 
checksum in `b6a7c3f79e` to buffer the bytes before decoding rather than hash 
what `buildPageMetaInfo` already had in hand.



##########
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 still compared by modification time, 
and so is any page precompiled by Grails 7.0 or earlier.

Review Comment:
   Applied. You're right that "Grails 7.0 or earlier" reads as including the 
release that introduces the checksum — it's now bounded at 7.1.6, following the 
retarget to `7.1.x`.



##########
grails-gsp/core/src/test/groovy/org/grails/gsp/compiler/GroovyPageCompilerReproducibilitySpec.groovy:
##########
@@ -0,0 +1,109 @@
+/*
+ *  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 = '<html><body><g:if 
test="${flag}">Hello</g:if></body></html>'
+
+    @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'
+        this.page.setLastModified(1_000_000_000_000L)

Review Comment:
   Applied at both call sites.
   
   This one would have been genuinely nasty: a silent `false` makes `first == 
second` pass vacuously, so the spec would have gone green while the exact 
regression it exists to catch shipped.



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