From fe0dd6965d604094227b6d0c1924a97b34596ac3 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sat, 4 Jul 2026 22:21:38 -0400 Subject: [PATCH 1/3] Improve hot path virtual-thread and codec performance Use a ReentrantLock inside GrailsHttpSession so wrapper-level session access no longer pins virtual-thread carrier threads on the object monitor. Static-compile the codecs-core extension helpers while preserving existing Groovy extension method descriptors. Assisted-by: Hephaestus:openai/gpt-5.5 codex-review --- .../codecs/Base64CodecExtensionMethods.groovy | 13 +- .../grails/plugins/codecs/DigestUtils.groovy | 63 ++++++-- .../codecs/HexCodecExtensionMethods.groovy | 49 +++--- .../MD5BytesCodecExtensionMethods.groovy | 7 +- .../codecs/MD5CodecExtensionMethods.groovy | 10 +- .../SHA1BytesCodecExtensionMethods.groovy | 7 +- .../codecs/SHA1CodecExtensionMethods.groovy | 9 +- .../SHA256BytesCodecExtensionMethods.groovy | 7 +- .../codecs/SHA256CodecExtensionMethods.groovy | 9 +- .../web/servlet/mvc/GrailsHttpSession.java | 141 ++++++++++++------ .../servlet/mvc/GrailsHttpSessionSpec.groovy | 24 +++ 11 files changed, 233 insertions(+), 106 deletions(-) diff --git a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/Base64CodecExtensionMethods.groovy b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/Base64CodecExtensionMethods.groovy index 91e7b8a1a69..22578ec0002 100644 --- a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/Base64CodecExtensionMethods.groovy +++ b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/Base64CodecExtensionMethods.groovy @@ -24,32 +24,35 @@ import org.codehaus.groovy.runtime.NullObject import org.apache.commons.codec.binary.Base64 +import groovy.transform.CompileStatic + /** * A codec that encodes and decodes Objects using Base64 encoding. * * @author Drew Varner */ +@CompileStatic class Base64CodecExtensionMethods { - static encodeAsBase64(theTarget) { + static Object encodeAsBase64(Object theTarget) { if (theTarget == null || theTarget instanceof NullObject) { return null } if (theTarget instanceof Byte[] || theTarget instanceof byte[]) { - return new String(Base64.encodeBase64(theTarget)) + return new String(Base64.encodeBase64(DigestUtils.toByteArray(theTarget)), StandardCharsets.UTF_8) } - return new String(Base64.encodeBase64(theTarget.toString().getBytes(StandardCharsets.UTF_8))) + return new String(Base64.encodeBase64(theTarget.toString().getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8) } - static decodeBase64(theTarget) { + static Object decodeBase64(Object theTarget) { if (theTarget == null || theTarget instanceof NullObject) { return null } if (theTarget instanceof Byte[] || theTarget instanceof byte[]) { - return Base64.decodeBase64(theTarget) + return Base64.decodeBase64(DigestUtils.toByteArray(theTarget)) } return Base64.decodeBase64(theTarget.toString().getBytes(StandardCharsets.UTF_8)) diff --git a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/DigestUtils.groovy b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/DigestUtils.groovy index fccb6489a10..b4820108ea3 100644 --- a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/DigestUtils.groovy +++ b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/DigestUtils.groovy @@ -18,30 +18,69 @@ */ package org.grails.plugins.codecs +import java.lang.reflect.Array import java.nio.charset.StandardCharsets import java.security.MessageDigest +import groovy.transform.CompileStatic + +@CompileStatic abstract class DigestUtils { // Digest byte[], any list/array or string into a byte[] - static digest(String algorithm, data) { + static Object digest(String algorithm, Object data) { if (data == null) { return null } - def md = MessageDigest.getInstance(algorithm) - def src - if (data instanceof Byte[] || data instanceof byte[]) { - src = data + MessageDigest md = MessageDigest.getInstance(algorithm) + byte[] src = toByteArray(data) + md.update(src) // This probably needs to use the thread's Locale encoding + return md.digest() + } + + protected static byte[] toByteArray(Object data) { + if (data instanceof byte[]) { + return (byte[]) data } - else if (data instanceof List || data.getClass().isArray()) { - src = new byte[data.size()] - data.eachWithIndex { v, i -> src[i] = v } + if (data instanceof Byte[]) { + return toByteArrayFromWrapper((Byte[]) data) } - else { - src = data.toString().getBytes(StandardCharsets.UTF_8) + if (data instanceof List) { + return toByteArrayFromList((List) data) } - md.update(src) // This probably needs to use the thread's Locale encoding - return md.digest() + if (data.getClass().isArray()) { + return toByteArrayFromArray(data, Array.getLength(data)) + } + + return data.toString().getBytes(StandardCharsets.UTF_8) + } + + private static byte[] toByteArrayFromWrapper(Byte[] data) { + byte[] result = new byte[data.length] + for (int i = 0; i < data.length; i++) { + result[i] = data[i].byteValue() + } + return result + } + + private static byte[] toByteArrayFromList(List data) { + byte[] result = new byte[data.size()] + for (int i = 0; i < data.size(); i++) { + result[i] = toByte(data.get(i)) + } + return result + } + + private static byte[] toByteArrayFromArray(Object data, int length) { + byte[] result = new byte[length] + for (int i = 0; i < length; i++) { + result[i] = toByte(Array.get(data, i)) + } + return result + } + + private static byte toByte(Object value) { + return ((Number) value).byteValue() } } diff --git a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/HexCodecExtensionMethods.groovy b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/HexCodecExtensionMethods.groovy index 97af8be4fdc..a8d47473833 100644 --- a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/HexCodecExtensionMethods.groovy +++ b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/HexCodecExtensionMethods.groovy @@ -22,50 +22,45 @@ import java.nio.charset.StandardCharsets import org.codehaus.groovy.runtime.NullObject +import groovy.transform.CompileStatic + +@CompileStatic class HexCodecExtensionMethods { - static HEXDIGITS = '0123456789abcdef' + static Object HEXDIGITS = '0123456789abcdef' // Expects an array/list of numbers - static encodeAsHex(theTarget) { + static Object encodeAsHex(Object theTarget) { if (theTarget == null || theTarget instanceof NullObject) { return null } - def result = new StringBuilder() - if (theTarget instanceof String) { - theTarget = theTarget.getBytes(StandardCharsets.UTF_8) - } - theTarget.each() { - result << HexCodecExtensionMethods.HEXDIGITS[(it & 0xF0) >> 4] - result << HexCodecExtensionMethods.HEXDIGITS[it & 0x0F] + byte[] bytes = theTarget instanceof String ? ((String) theTarget).getBytes(StandardCharsets.UTF_8) : DigestUtils.toByteArray(theTarget) + StringBuilder result = new StringBuilder(bytes.length * 2) + String hexDigits = (String) HEXDIGITS + for (byte value : bytes) { + int unsignedValue = value & 0xFF + result.append(hexDigits.charAt((unsignedValue & 0xF0) >> 4)) + result.append(hexDigits.charAt(unsignedValue & 0x0F)) } return result.toString() } - static decodeHex(theTarget) { - if (!theTarget) return null + static Object decodeHex(Object theTarget) { + if (theTarget == null || theTarget instanceof NullObject || theTarget.toString().length() == 0) return null - def output = [] - - def str = theTarget.toString().toLowerCase() - if (str.size() % 2) { + String str = theTarget.toString().toLowerCase() + if (str.size() % 2 != 0) { throw new UnsupportedOperationException('Decode of hex strings requires strings of even length') } - def currentByte - str.eachWithIndex { val, idx -> - if (!(idx % 2)) { - currentByte = HEXDIGITS.indexOf(val) << 4 - } - else { - output << (currentByte | HEXDIGITS.indexOf(val)) - currentByte = 0 - } + byte[] result = new byte[str.size().intdiv(2)] + String hexDigits = (String) HEXDIGITS + for (int i = 0; i < str.size(); i += 2) { + int high = hexDigits.indexOf((int) str.charAt(i)) + int low = hexDigits.indexOf((int) str.charAt(i + 1)) + result[i.intdiv(2)] = (byte) ((high << 4) | low) } - - def result = new byte[output.size()] - output.eachWithIndex { v, i -> result[i] = v } return result } } diff --git a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/MD5BytesCodecExtensionMethods.groovy b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/MD5BytesCodecExtensionMethods.groovy index f6d601f208d..55f2e605832 100644 --- a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/MD5BytesCodecExtensionMethods.groovy +++ b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/MD5BytesCodecExtensionMethods.groovy @@ -20,18 +20,21 @@ package org.grails.plugins.codecs import org.codehaus.groovy.runtime.NullObject +import groovy.transform.CompileStatic + +@CompileStatic class MD5BytesCodecExtensionMethods { // Returns the byte[] of the digest, taken from UTF-8 of the string representation // or the raw data coerced to bytes - static encodeAsMD5Bytes(theTarget) { + static Object encodeAsMD5Bytes(Object theTarget) { if (theTarget == null || theTarget instanceof NullObject) { return null } DigestUtils.digest('MD5', theTarget) } - static decodeMD5Bytes(theTarget) { + static Object decodeMD5Bytes(Object theTarget) { throw new UnsupportedOperationException('Cannot decode MD5 hashes') } } diff --git a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/MD5CodecExtensionMethods.groovy b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/MD5CodecExtensionMethods.groovy index 06b503bc130..d26cfe5189a 100644 --- a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/MD5CodecExtensionMethods.groovy +++ b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/MD5CodecExtensionMethods.groovy @@ -18,15 +18,19 @@ */ package org.grails.plugins.codecs +import groovy.transform.CompileStatic + +@CompileStatic class MD5CodecExtensionMethods { // Returns the byte[] of the digest, taken from UTF-8 of the string representation // or the raw data coerced to bytes - static encodeAsMD5(theTarget) { - theTarget.encodeAsMD5Bytes()?.encodeAsHex() + static Object encodeAsMD5(Object theTarget) { + byte[] digest = (byte[]) MD5BytesCodecExtensionMethods.encodeAsMD5Bytes(theTarget) + return digest == null ? null : HexCodecExtensionMethods.encodeAsHex(digest) } - static decodeMD5(theTarget) { + static Object decodeMD5(Object theTarget) { throw new UnsupportedOperationException('Cannot decode MD5 hashes') } } diff --git a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/SHA1BytesCodecExtensionMethods.groovy b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/SHA1BytesCodecExtensionMethods.groovy index 3f5e79d8d51..f7feb37354c 100644 --- a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/SHA1BytesCodecExtensionMethods.groovy +++ b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/SHA1BytesCodecExtensionMethods.groovy @@ -20,17 +20,20 @@ package org.grails.plugins.codecs import org.codehaus.groovy.runtime.NullObject +import groovy.transform.CompileStatic + +@CompileStatic class SHA1BytesCodecExtensionMethods { // Returns the byte[] of the digest - static encodeAsSHA1Bytes(theTarget) { + static Object encodeAsSHA1Bytes(Object theTarget) { if (theTarget == null || theTarget instanceof NullObject) { return null } DigestUtils.digest('SHA-1', theTarget) } - static decodeSHA1Bytes(theTarget) { + static Object decodeSHA1Bytes(Object theTarget) { throw new UnsupportedOperationException('Cannot decode SHA-1 hashes') } } diff --git a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/SHA1CodecExtensionMethods.groovy b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/SHA1CodecExtensionMethods.groovy index 177e030256b..9a3d19f356f 100644 --- a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/SHA1CodecExtensionMethods.groovy +++ b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/SHA1CodecExtensionMethods.groovy @@ -20,17 +20,20 @@ package org.grails.plugins.codecs import org.codehaus.groovy.runtime.NullObject +import groovy.transform.CompileStatic + +@CompileStatic class SHA1CodecExtensionMethods { // Returns the byte[] of the digest - static encodeAsSHA1(theTarget) { + static Object encodeAsSHA1(Object theTarget) { if (theTarget == null || theTarget instanceof NullObject) { return null } - theTarget.encodeAsSHA1Bytes().encodeAsHex() + return HexCodecExtensionMethods.encodeAsHex(SHA1BytesCodecExtensionMethods.encodeAsSHA1Bytes(theTarget)) } - static decodeSHA1(theTarget) { + static Object decodeSHA1(Object theTarget) { throw new UnsupportedOperationException('Cannot decode SHA-1 hashes') } } diff --git a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/SHA256BytesCodecExtensionMethods.groovy b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/SHA256BytesCodecExtensionMethods.groovy index 335ca50df8a..a91a27014a0 100644 --- a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/SHA256BytesCodecExtensionMethods.groovy +++ b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/SHA256BytesCodecExtensionMethods.groovy @@ -20,17 +20,20 @@ package org.grails.plugins.codecs import org.codehaus.groovy.runtime.NullObject +import groovy.transform.CompileStatic + +@CompileStatic class SHA256BytesCodecExtensionMethods { // Returns the byte[] of the digest - static encodeAsSHA256Bytes(theTarget) { + static Object encodeAsSHA256Bytes(Object theTarget) { if (theTarget == null || theTarget instanceof NullObject) { return null } DigestUtils.digest('SHA-256', theTarget) } - static decodeSHA256Bytes(theTarget) { + static Object decodeSHA256Bytes(Object theTarget) { throw new UnsupportedOperationException('Cannot decode SHA-256 hashes') } } diff --git a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/SHA256CodecExtensionMethods.groovy b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/SHA256CodecExtensionMethods.groovy index ce231e0ce50..7067a4d4894 100644 --- a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/SHA256CodecExtensionMethods.groovy +++ b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/SHA256CodecExtensionMethods.groovy @@ -20,17 +20,20 @@ package org.grails.plugins.codecs import org.codehaus.groovy.runtime.NullObject +import groovy.transform.CompileStatic + +@CompileStatic class SHA256CodecExtensionMethods extends DigestUtils { // Returns the byte[] of the digest - static encodeAsSHA256(theTarget) { + static Object encodeAsSHA256(Object theTarget) { if (theTarget == null || theTarget instanceof NullObject) { return null } - theTarget.encodeAsSHA256Bytes()?.encodeAsHex() + return HexCodecExtensionMethods.encodeAsHex(SHA256BytesCodecExtensionMethods.encodeAsSHA256Bytes(theTarget)) } - static decodeSHA256(theTarget) { + static Object decodeSHA256(Object theTarget) { throw new UnsupportedOperationException('Cannot decode SHA-256 hashes') } } diff --git a/grails-web-common/src/main/groovy/grails/web/servlet/mvc/GrailsHttpSession.java b/grails-web-common/src/main/groovy/grails/web/servlet/mvc/GrailsHttpSession.java index eefd76ce165..1519251c3dd 100644 --- a/grails-web-common/src/main/groovy/grails/web/servlet/mvc/GrailsHttpSession.java +++ b/grails-web-common/src/main/groovy/grails/web/servlet/mvc/GrailsHttpSession.java @@ -19,6 +19,8 @@ package grails.web.servlet.mvc; import java.util.Enumeration; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; import jakarta.servlet.ServletContext; import jakarta.servlet.http.HttpServletRequest; @@ -32,8 +34,9 @@ */ public class GrailsHttpSession implements HttpSession { + private final Lock sessionLock = new ReentrantLock(); private HttpSession adaptee; - private HttpServletRequest request; + private final HttpServletRequest request; public GrailsHttpSession(HttpServletRequest request) { this.request = request; @@ -43,14 +46,18 @@ public GrailsHttpSession(HttpServletRequest request) { * @see jakarta.servlet.http.HttpSession#getAttribute(java.lang.String) */ public Object getAttribute(String name) { - createSessionIfNecessary(); - synchronized (this) { - return adaptee.getAttribute(name); + sessionLock.lock(); + try { + return createSessionIfNecessary().getAttribute(name); + } + finally { + sessionLock.unlock(); } } - private void createSessionIfNecessary() { + private HttpSession createSessionIfNecessary() { if (adaptee == null) adaptee = request.getSession(true); + return adaptee; } /* (non-Javadoc) @@ -58,9 +65,12 @@ private void createSessionIfNecessary() { */ @SuppressWarnings({ "rawtypes", "unchecked" }) public Enumeration getAttributeNames() { - createSessionIfNecessary(); - synchronized (this) { - return adaptee.getAttributeNames(); + sessionLock.lock(); + try { + return createSessionIfNecessary().getAttributeNames(); + } + finally { + sessionLock.unlock(); } } @@ -68,9 +78,12 @@ public Enumeration getAttributeNames() { * @see jakarta.servlet.http.HttpSession#getCreationTime() */ public long getCreationTime() { - createSessionIfNecessary(); - synchronized (this) { - return adaptee.getCreationTime(); + sessionLock.lock(); + try { + return createSessionIfNecessary().getCreationTime(); + } + finally { + sessionLock.unlock(); } } @@ -78,9 +91,12 @@ public long getCreationTime() { * @see jakarta.servlet.http.HttpSession#getId() */ public String getId() { - createSessionIfNecessary(); - synchronized (this) { - return adaptee.getId(); + sessionLock.lock(); + try { + return createSessionIfNecessary().getId(); + } + finally { + sessionLock.unlock(); } } @@ -88,9 +104,12 @@ public String getId() { * @see jakarta.servlet.http.HttpSession#getLastAccessedTime() */ public long getLastAccessedTime() { - createSessionIfNecessary(); - synchronized (this) { - return adaptee.getLastAccessedTime(); + sessionLock.lock(); + try { + return createSessionIfNecessary().getLastAccessedTime(); + } + finally { + sessionLock.unlock(); } } @@ -98,9 +117,12 @@ public long getLastAccessedTime() { * @see jakarta.servlet.http.HttpSession#getMaxInactiveInterval() */ public int getMaxInactiveInterval() { - createSessionIfNecessary(); - synchronized (this) { - return adaptee.getMaxInactiveInterval(); + sessionLock.lock(); + try { + return createSessionIfNecessary().getMaxInactiveInterval(); + } + finally { + sessionLock.unlock(); } } @@ -108,9 +130,12 @@ public int getMaxInactiveInterval() { * @see jakarta.servlet.http.HttpSession#getServletContext() */ public ServletContext getServletContext() { - createSessionIfNecessary(); - synchronized (this) { - return adaptee.getServletContext(); + sessionLock.lock(); + try { + return createSessionIfNecessary().getServletContext(); + } + finally { + sessionLock.unlock(); } } @@ -189,7 +214,8 @@ public void removeValue(String name) { */ @Deprecated public void invalidate() { - synchronized (this) { + sessionLock.lock(); + try { HttpSession session = adaptee; if (session == null) session = request.getSession(false); if (session != null) { @@ -197,15 +223,21 @@ public void invalidate() { session.invalidate(); } } + finally { + sessionLock.unlock(); + } } /* (non-Javadoc) * @see jakarta.servlet.http.HttpSession#isNew() */ public boolean isNew() { - createSessionIfNecessary(); - synchronized (this) { - return adaptee.isNew(); + sessionLock.lock(); + try { + return createSessionIfNecessary().isNew(); + } + finally { + sessionLock.unlock(); } } @@ -213,9 +245,12 @@ public boolean isNew() { * @see jakarta.servlet.http.HttpSession#removeAttribute(java.lang.String) */ public void removeAttribute(String name) { - createSessionIfNecessary(); - synchronized (this) { - adaptee.removeAttribute(name); + sessionLock.lock(); + try { + createSessionIfNecessary().removeAttribute(name); + } + finally { + sessionLock.unlock(); } } @@ -223,9 +258,12 @@ public void removeAttribute(String name) { * @see jakarta.servlet.http.HttpSession#setAttribute(java.lang.String, java.lang.Object) */ public void setAttribute(String name, Object value) { - createSessionIfNecessary(); - synchronized (this) { - adaptee.setAttribute(name, value); + sessionLock.lock(); + try { + createSessionIfNecessary().setAttribute(name, value); + } + finally { + sessionLock.unlock(); } } @@ -233,26 +271,35 @@ public void setAttribute(String name, Object value) { * @see jakarta.servlet.http.HttpSession#setMaxInactiveInterval(int) */ public void setMaxInactiveInterval(int arg0) { - createSessionIfNecessary(); - synchronized (this) { - adaptee.setMaxInactiveInterval(arg0); + sessionLock.lock(); + try { + createSessionIfNecessary().setMaxInactiveInterval(arg0); + } + finally { + sessionLock.unlock(); } } @SuppressWarnings("rawtypes") @Override public String toString() { - createSessionIfNecessary(); - StringBuilder sb = new StringBuilder("Session Content:\n"); - Enumeration e = adaptee.getAttributeNames(); - while (e.hasMoreElements()) { - String name = (String) e.nextElement(); - sb.append(" "); - sb.append(name); - sb.append(" = "); - sb.append(adaptee.getAttribute(name)); - sb.append('\n'); + sessionLock.lock(); + try { + HttpSession session = createSessionIfNecessary(); + StringBuilder sb = new StringBuilder("Session Content:\n"); + Enumeration e = session.getAttributeNames(); + while (e.hasMoreElements()) { + String name = (String) e.nextElement(); + sb.append(" "); + sb.append(name); + sb.append(" = "); + sb.append(session.getAttribute(name)); + sb.append('\n'); + } + return sb.toString(); + } + finally { + sessionLock.unlock(); } - return sb.toString(); } } diff --git a/grails-web-common/src/test/groovy/grails/web/servlet/mvc/GrailsHttpSessionSpec.groovy b/grails-web-common/src/test/groovy/grails/web/servlet/mvc/GrailsHttpSessionSpec.groovy index e6f4a418daa..eb7aadfaee8 100644 --- a/grails-web-common/src/test/groovy/grails/web/servlet/mvc/GrailsHttpSessionSpec.groovy +++ b/grails-web-common/src/test/groovy/grails/web/servlet/mvc/GrailsHttpSessionSpec.groovy @@ -24,6 +24,30 @@ import spock.lang.Specification class GrailsHttpSessionSpec extends Specification { + void 'attribute access creates servlet session lazily and delegates to the adaptee'() { + given: + def request = new MockHttpServletRequest() + def session = new GrailsHttpSession(request) + + expect: + request.getSession(false) == null + + when: + session.setAttribute('name', 'value') + + then: + request.getSession(false).getAttribute('name') == 'value' + session.getAttribute('name') == 'value' + Collections.list(session.getAttributeNames()) == ['name'] + session.toString().contains('name = value') + + when: + session.removeAttribute('name') + + then: + session.getAttribute('name') == null + } + void 'invalidate does not create a servlet session when none exists'() { given: def request = new MockHttpServletRequest() From 211a1ffae65aaf11b930d2a73a5837daeff36ee1 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sun, 5 Jul 2026 08:57:35 -0400 Subject: [PATCH 2/3] Restore decodeHex Groovy truth compatibility Preserve the pre-existing Groovy truth guard under static compilation by using Groovy's runtime truth conversion helper. Add Hex codec regression coverage for falsy inputs. Assisted-by: Hephaestus:openai/gpt-5.5 codex-review --- .../grails/plugins/codecs/HexCodecExtensionMethods.groovy | 3 ++- .../groovy/org/grails/web/codecs/HexCodecTests.groovy | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/HexCodecExtensionMethods.groovy b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/HexCodecExtensionMethods.groovy index a8d47473833..b42ba61bef8 100644 --- a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/HexCodecExtensionMethods.groovy +++ b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/HexCodecExtensionMethods.groovy @@ -21,6 +21,7 @@ package org.grails.plugins.codecs import java.nio.charset.StandardCharsets import org.codehaus.groovy.runtime.NullObject +import org.codehaus.groovy.runtime.typehandling.DefaultTypeTransformation import groovy.transform.CompileStatic @@ -47,7 +48,7 @@ class HexCodecExtensionMethods { } static Object decodeHex(Object theTarget) { - if (theTarget == null || theTarget instanceof NullObject || theTarget.toString().length() == 0) return null + if (theTarget == null || theTarget instanceof NullObject || !DefaultTypeTransformation.castToBoolean(theTarget)) return null String str = theTarget.toString().toLowerCase() if (str.size() % 2 != 0) { diff --git a/grails-codecs-core/src/test/groovy/org/grails/web/codecs/HexCodecTests.groovy b/grails-codecs-core/src/test/groovy/org/grails/web/codecs/HexCodecTests.groovy index 307383e1697..9ddede8a535 100644 --- a/grails-codecs-core/src/test/groovy/org/grails/web/codecs/HexCodecTests.groovy +++ b/grails-codecs-core/src/test/groovy/org/grails/web/codecs/HexCodecTests.groovy @@ -18,6 +18,7 @@ */ package org.grails.web.codecs +import org.grails.plugins.codecs.HexCodecExtensionMethods import org.junit.jupiter.api.Test import static org.junit.jupiter.api.Assertions.assertEquals @@ -48,6 +49,13 @@ class HexCodecTests { assertIterableEquals(new Byte[] {65, 32, 66, 32, 67, 32, 68, 32, 69}.toList(), result.toList()) //make sure decoding null returns null assertEquals(null.decodeHex(), null) + + //make sure decoding Groovy-falsy values returns null + assertEquals(null, HexCodecExtensionMethods.decodeHex('')) + assertEquals(null, HexCodecExtensionMethods.decodeHex(0)) + assertEquals(null, HexCodecExtensionMethods.decodeHex(false)) + assertEquals(null, HexCodecExtensionMethods.decodeHex([])) + assertEquals(null, HexCodecExtensionMethods.decodeHex(new byte[0])) } void testRoundtrip() { From 25732a678b21ec24f0b6cd9e82f81895ab0d019b Mon Sep 17 00:00:00 2001 From: James Fredley Date: Thu, 9 Jul 2026 19:34:14 -0400 Subject: [PATCH 3/3] Address codec and virtual-thread performance review feedback Fix Base64 charset handling, empty hex decoding, preserve DigestUtils visibility, and document codec notes. Assisted-by: Sisyphus:xai/grok-4.5 [gpt-coding] --- .../codecs/Base64CodecExtensionMethods.groovy | 7 +++-- .../grails/plugins/codecs/DigestUtils.groovy | 30 +++++++++++++++---- .../codecs/HexCodecExtensionMethods.groovy | 11 +++---- .../grails/web/codecs/Base64CodecTests.groovy | 9 ++++++ .../grails/web/codecs/HexCodecTests.groovy | 23 +++++++++----- grails-doc/src/en/guide/security/codecs.adoc | 2 ++ 6 files changed, 61 insertions(+), 21 deletions(-) diff --git a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/Base64CodecExtensionMethods.groovy b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/Base64CodecExtensionMethods.groovy index 22578ec0002..c421af4a888 100644 --- a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/Base64CodecExtensionMethods.groovy +++ b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/Base64CodecExtensionMethods.groovy @@ -18,6 +18,7 @@ */ package org.grails.plugins.codecs +import java.nio.charset.Charset import java.nio.charset.StandardCharsets import org.codehaus.groovy.runtime.NullObject @@ -34,7 +35,7 @@ import groovy.transform.CompileStatic @CompileStatic class Base64CodecExtensionMethods { - static Object encodeAsBase64(Object theTarget) { + static Object encodeAsBase64(Object theTarget, Charset charset = StandardCharsets.UTF_8) { if (theTarget == null || theTarget instanceof NullObject) { return null } @@ -43,10 +44,10 @@ class Base64CodecExtensionMethods { return new String(Base64.encodeBase64(DigestUtils.toByteArray(theTarget)), StandardCharsets.UTF_8) } - return new String(Base64.encodeBase64(theTarget.toString().getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8) + return new String(Base64.encodeBase64(theTarget.toString().getBytes(charset)), StandardCharsets.UTF_8) } - static Object decodeBase64(Object theTarget) { + static Object decodeBase64(Object theTarget, Charset charset = StandardCharsets.UTF_8) { if (theTarget == null || theTarget instanceof NullObject) { return null } diff --git a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/DigestUtils.groovy b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/DigestUtils.groovy index b4820108ea3..7b8222d5c99 100644 --- a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/DigestUtils.groovy +++ b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/DigestUtils.groovy @@ -23,6 +23,7 @@ import java.nio.charset.StandardCharsets import java.security.MessageDigest import groovy.transform.CompileStatic +import org.codehaus.groovy.runtime.typehandling.DefaultTypeTransformation @CompileStatic abstract class DigestUtils { @@ -46,8 +47,11 @@ abstract class DigestUtils { if (data instanceof Byte[]) { return toByteArrayFromWrapper((Byte[]) data) } - if (data instanceof List) { - return toByteArrayFromList((List) data) + if (data instanceof Iterable) { + return toByteArrayFromIterable((Iterable) data) + } + if (data instanceof Iterator) { + return toByteArrayFromIterator((Iterator) data) } if (data.getClass().isArray()) { return toByteArrayFromArray(data, Array.getLength(data)) @@ -64,10 +68,26 @@ abstract class DigestUtils { return result } - private static byte[] toByteArrayFromList(List data) { + private static byte[] toByteArrayFromIterable(Iterable data) { + List bytes = new ArrayList() + for (Object value : data) { + bytes.add(toByte(value)) + } + return toByteArrayFromList(bytes) + } + + private static byte[] toByteArrayFromIterator(Iterator data) { + List bytes = new ArrayList() + while (data.hasNext()) { + bytes.add(toByte(data.next())) + } + return toByteArrayFromList(bytes) + } + + private static byte[] toByteArrayFromList(List data) { byte[] result = new byte[data.size()] for (int i = 0; i < data.size(); i++) { - result[i] = toByte(data.get(i)) + result[i] = data.get(i) } return result } @@ -81,6 +101,6 @@ abstract class DigestUtils { } private static byte toByte(Object value) { - return ((Number) value).byteValue() + DefaultTypeTransformation.castToNumber(value).byteValue() } } diff --git a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/HexCodecExtensionMethods.groovy b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/HexCodecExtensionMethods.groovy index b42ba61bef8..5770527132a 100644 --- a/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/HexCodecExtensionMethods.groovy +++ b/grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/HexCodecExtensionMethods.groovy @@ -48,19 +48,20 @@ class HexCodecExtensionMethods { } static Object decodeHex(Object theTarget) { - if (theTarget == null || theTarget instanceof NullObject || !DefaultTypeTransformation.castToBoolean(theTarget)) return null + if (theTarget instanceof CharSequence && theTarget.length() == 0) return new byte[0] + if (!DefaultTypeTransformation.castToBoolean(theTarget)) return null String str = theTarget.toString().toLowerCase() - if (str.size() % 2 != 0) { + if (str.length() % 2 != 0) { throw new UnsupportedOperationException('Decode of hex strings requires strings of even length') } - byte[] result = new byte[str.size().intdiv(2)] + byte[] result = new byte[str.length() >> 1] String hexDigits = (String) HEXDIGITS - for (int i = 0; i < str.size(); i += 2) { + for (int i = 0; i < str.length(); i += 2) { int high = hexDigits.indexOf((int) str.charAt(i)) int low = hexDigits.indexOf((int) str.charAt(i + 1)) - result[i.intdiv(2)] = (byte) ((high << 4) | low) + result[i >> 1] = (byte) ((high << 4) | low) } return result } diff --git a/grails-codecs-core/src/test/groovy/org/grails/web/codecs/Base64CodecTests.groovy b/grails-codecs-core/src/test/groovy/org/grails/web/codecs/Base64CodecTests.groovy index 120c938a139..f3ec64ef6f3 100644 --- a/grails-codecs-core/src/test/groovy/org/grails/web/codecs/Base64CodecTests.groovy +++ b/grails-codecs-core/src/test/groovy/org/grails/web/codecs/Base64CodecTests.groovy @@ -19,6 +19,8 @@ package org.grails.web.codecs +import java.nio.charset.StandardCharsets + import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -79,4 +81,11 @@ class Base64CodecTests { assertEquals "dGVzdA==", "test".encodeAsBase64() assertEquals "test", new String("dGVzdA==".decodeBase64()) } + + @Test + void testEncodeDecodeAsBase64WithCharset() { + assertEquals '6Q==', 'é'.encodeAsBase64(StandardCharsets.ISO_8859_1) + assertEquals 'é', new String('6Q=='.decodeBase64(StandardCharsets.ISO_8859_1), StandardCharsets.ISO_8859_1) + assertEquals '/v///g==', '￾'.encodeAsBase64(StandardCharsets.UTF_16) + } } diff --git a/grails-codecs-core/src/test/groovy/org/grails/web/codecs/HexCodecTests.groovy b/grails-codecs-core/src/test/groovy/org/grails/web/codecs/HexCodecTests.groovy index 9ddede8a535..6f502d51612 100644 --- a/grails-codecs-core/src/test/groovy/org/grails/web/codecs/HexCodecTests.groovy +++ b/grails-codecs-core/src/test/groovy/org/grails/web/codecs/HexCodecTests.groovy @@ -18,7 +18,6 @@ */ package org.grails.web.codecs -import org.grails.plugins.codecs.HexCodecExtensionMethods import org.junit.jupiter.api.Test import static org.junit.jupiter.api.Assertions.assertEquals @@ -51,15 +50,23 @@ class HexCodecTests { assertEquals(null.decodeHex(), null) //make sure decoding Groovy-falsy values returns null - assertEquals(null, HexCodecExtensionMethods.decodeHex('')) - assertEquals(null, HexCodecExtensionMethods.decodeHex(0)) - assertEquals(null, HexCodecExtensionMethods.decodeHex(false)) - assertEquals(null, HexCodecExtensionMethods.decodeHex([])) - assertEquals(null, HexCodecExtensionMethods.decodeHex(new byte[0])) + assertIterableEquals([], ''.decodeHex().toList()) + assertEquals(null, 0.decodeHex()) + assertEquals(null, false.decodeHex()) + assertEquals(null, [].decodeHex()) + assertEquals(null, new byte[0].decodeHex()) } + @Test void testRoundtrip() { - assertIterableEquals([65, 32, 66, 32, 67, 32, 68, 32, 69], [65, 32, 66, 32, 67, 32, 68, 32, 69].encodeAsHex().decodeHex().toList()) - assertIterableEquals([65, 32, 66, 32, 67, 32, 68, 32, 69], 'A B C D E'.encodeAsHex().decodeHex().toList()) + List expected = [65, 32, 66, 32, 67, 32, 68, 32, 69].collect { ((Number) it).byteValue() } + assertIterableEquals(expected, [65, 32, 66, 32, 67, 32, 68, 32, 69].encodeAsHex().decodeHex().toList()) + assertIterableEquals(expected, 'A B C D E'.encodeAsHex().decodeHex().toList()) + } + + @Test + void testEncodeIterableCoercion() { + assertEquals('4142', ['A' as char, 'B' as char].encodeAsHex()) + assertEquals('4142', new LinkedHashSet([65, 66]).encodeAsHex()) } } diff --git a/grails-doc/src/en/guide/security/codecs.adoc b/grails-doc/src/en/guide/security/codecs.adoc index cdcc4985b94..b3e2aa28347 100644 --- a/grails-doc/src/en/guide/security/codecs.adoc +++ b/grails-doc/src/en/guide/security/codecs.adoc @@ -135,6 +135,8 @@ Performs Base64 encode/decode functions. Example of usage: Your registration code is: ${user.registrationCode.encodeAsBase64()} ---- +String values can be encoded with a specific source character set by passing a `java.nio.charset.Charset` to `encodeAsBase64`. The Base64 text itself is returned as UTF-8/ASCII-compatible text. + *JavaScriptCodec* Escapes Strings so they can be used as valid JavaScript strings. For example: