You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
DefaultServlet: cached JarResource serves a closed JarFile → IllegalStateException: zip file closed for large (>512 KB) webjar assets after idle #26078
A WAR with a webjar on its classpath (e.g. org.webjars:swagger-ui) served directly from the JAR (i.e. not unpacked into the
docroot), containing a static asset larger than ~512 KB (swagger-ui-bundle.js ≈ 1.4 MB)
Summary
A static resource served by the DefaultServlet from inside a WEB-INF/lib JAR (META-INF/resources/…) returns HTTP 500 with java.lang.IllegalStateException: zip file closedafter the application has been idle. Only resources above the resource-cache
per-object size limit (~512 KB) are affected; smaller siblings from the same JAR keep working. The root cause is a lifecycle mismatch: the
resource cache pins a JarResource that references a JarFile owned by the WebappClassLoader, the classloader closes that handle after
an idle timeout, and cache revalidation reuses the stale JarResource without reopening.
Observed behavior (smoking gun)
Immediately after deploy: the large asset serves 200 OK.
Next morning, after the app sat idle overnight with no redeployment: the first request to the same asset returns 500 zip file closed. Smaller assets from the same JAR still return 200.
Steps to reproduce
Deploy a WAR that serves a webjar from the JAR with a static file >512 KB (e.g. swagger-ui's swagger-ui-bundle.js).
Request the asset once → 200 (it gets cached as a JarResource while the JAR handle is open).
Leave the app idle for >90 s (no requests that load classes/resources from those JARs; no redeploy).
Request the large asset again → 500 zip file closed. Sub-512 KB assets from the same JAR still serve.
Reproducer: minimal WAR attached (https://github.com/user-attachments/files/28826112/glassfish-webjar-bug-repro.zip) — mvn clean package, deploy webjar-bug.war to GlassFish 8.0.x,
then GET http://localhost:8080/webjar-bug/webjars/swagger-ui/5.31.0/swagger-ui-bundle.js (200), leave the app idle >90 s, GET it again → 500 zip file closed. See
README for the no-wait variant.
Note: verify with curl, not a browser reload — once the asset has loaded 200 the browser serves its cached copy (or gets a 304 Not Modified) and never re-exercises the JAR-stream path. To force a server hit, use curl, DevTools → Network → Disable cache, or append
a cache-buster (…/swagger-ui-bundle.js?cb=1) — the query string is ignored for resource resolution so it still hits the bug.
java.lang.IllegalStateException: zip file closed
at java.base/java.util.zip.ZipFile.ensureOpen(ZipFile.java:846)
at java.base/java.util.zip.ZipFile.getInputStream(ZipFile.java:375)
at java.base/java.util.jar.JarFile.getInputStream(JarFile.java:856)
at org.apache.naming.resources.WebDirContext$JarResource.streamContent(WebDirContext.java:474)
at org.apache.catalina.servlets.DefaultServlet.copy(DefaultServlet.java:1915)
at org.apache.catalina.servlets.DefaultServlet.serveResource(DefaultServlet.java:911)
at org.apache.catalina.servlets.DefaultServlet.doGet(DefaultServlet.java:444)
Root cause (class/method names below are stable across the GlassFish/Catalina lineage)
The JAR handle is owned by WebappClassLoader (org.glassfish.web.loader.WebappClassLoader, which implements JarFileResourcesProvider). It closes its JARs after 90 s of inactivity and self-heals on next access:
// closeJARs(boolean force)if (force || (System.currentTimeMillis() > (lastJarAccessed + 90000))) {
... jarFiles[i].close(); jarFiles[i] = null; ...
}
// openJARs(): reopens only when the handle was nulledif (jarFiles[0] == null) { ... jarFiles[i] = newJarFile(jarRealFiles[i]); ... }
// getJarFiles() calls openJARs() first — so a *fresh* lookup always gets an OPEN handle
The static-serving path captures and reuses a handle without reopening.WebDirContext.lookupFromJars() builds new JarResource(jfEntry.jarFile, jfEntry.jarEntry), and JarResource.streamContent() streams from that captured reference with no liveness
check:
ProxyDirContext caches the JarResource for over-threshold entries and never re-resolves it. A resource’s content is cached only
when contentLength < cacheObjectMaxSize where cacheObjectMaxSize = cacheMaxSize / 20 (default cacheMaxSize 10240 KB ⇒ 512 KB). For
a larger asset, cacheLoad() still stores the CacheEntry with entry.resource = the live JarResource but with content null. On
lookup after cacheTTL (default 5000 ms), revalidate() compares onlylastModified and contentLength:
If unchanged (the JAR on disk hasn’t changed), the same CacheEntry and its stale JarResource are reused — the lookup never goes
back through WebDirContext/getJarFiles()/openJARs(), so the self-healing reopen in (1) is bypassed.
Net: request #1 caches a JarResource while the JAR is open → the 90 s idle reaper closes (and nulls) the classloader’s handle, but the
cached JarResource still references the now-closed JarFile → request #2 revalidates by timestamp only, reuses the stale JarResource,
and streamContent() calls getInputStream() on a closed JarFile → IllegalStateException: zip file closed.
(An additional, faster trigger for the same end state is a JVM-wide JAR-cache close on (re)deploy via JarFileUtils.closeCachedJarFiles() —
see Payara #5001 — but the idle reaper alone reproduces it with no deploy.)
Why only large resources
Sub-512 KB resources are content-cached as a byte[] at first lookup and served from memory — they never call streamContent() or touch
the live JarFile, so a closed handle is harmless. Only resources above cacheMaxSize/20 stream live from the cached JarResource and hit
the closed handle.
Workarounds (confirm the analysis; not fixes)
Raise cacheMaxSize (per-app in glassfish-web.xml, e.g. <property name="cacheMaxSize" value="65536"/> → ~3.2 MB per-file cap) so the
asset is content-cached and never streamed from the JAR. Effective and verified.
Unpack the webjar into the docroot so the asset is served from the filesystem (no JarFile involved).
Suggested fix
Stop serving from a cached Resource that wraps a borrowed, time-limited JarFile:
In ProxyDirContext, for entries whose content is not cached (over cacheObjectMaxSize), do not retain/reuse the Resource across
revalidation — re-resolve via the wrapped DirContext on each access so the lookup goes back through WebDirContext.getJarFiles()/openJARs() and obtains a live handle; and/or
Make WebDirContext$JarResource.streamContent() resilient: if jarFile.getInputStream(jarEntry) fails because the JarFile is closed,
re-fetch a live handle from the provider (getJarFiles() → openJARs()), re-resolve the entry, and retry.
Environment
org.webjars:swagger-ui) served directly from the JAR (i.e. not unpacked into thedocroot), containing a static asset larger than ~512 KB (
swagger-ui-bundle.js≈ 1.4 MB)Summary
A static resource served by the
DefaultServletfrom inside aWEB-INF/libJAR (META-INF/resources/…) returns HTTP 500 withjava.lang.IllegalStateException: zip file closedafter the application has been idle. Only resources above the resource-cacheper-object size limit (~512 KB) are affected; smaller siblings from the same JAR keep working. The root cause is a lifecycle mismatch: the
resource cache pins a
JarResourcethat references aJarFileowned by theWebappClassLoader, the classloader closes that handle afteran idle timeout, and cache revalidation reuses the stale
JarResourcewithout reopening.Observed behavior (smoking gun)
zip file closed. Smaller assets from the same JAR still return 200.Steps to reproduce
swagger-ui-bundle.js).JarResourcewhile the JAR handle is open).zip file closed. Sub-512 KB assets from the same JAR still serve.Reproducer: minimal WAR attached (https://github.com/user-attachments/files/28826112/glassfish-webjar-bug-repro.zip) —
mvn clean package, deploywebjar-bug.warto GlassFish 8.0.x,then GET
http://localhost:8080/webjar-bug/webjars/swagger-ui/5.31.0/swagger-ui-bundle.js(200), leave the app idle >90 s, GET it again → 500zip file closed. SeeREADME for the no-wait variant.
Note: verify with
curl, not a browser reload — once the asset has loaded 200 the browser serves its cached copy (or gets a304 Not Modified) and never re-exercises the JAR-stream path. To force a server hit, usecurl, DevTools → Network → Disable cache, or appenda cache-buster (
…/swagger-ui-bundle.js?cb=1) — the query string is ignored for resource resolution so it still hits the bug.Stack trace (core frames; servlet filter chain elided)
Root cause (class/method names below are stable across the GlassFish/Catalina lineage)
The JAR handle is owned by
WebappClassLoader(org.glassfish.web.loader.WebappClassLoader, which implementsJarFileResourcesProvider). It closes its JARs after 90 s of inactivity and self-heals on next access:The static-serving path captures and reuses a handle without reopening.
WebDirContext.lookupFromJars()buildsnew JarResource(jfEntry.jarFile, jfEntry.jarEntry), andJarResource.streamContent()streams from that captured reference with no livenesscheck:
ProxyDirContextcaches theJarResourcefor over-threshold entries and never re-resolves it. A resource’s content is cached onlywhen
contentLength < cacheObjectMaxSizewherecacheObjectMaxSize = cacheMaxSize / 20(defaultcacheMaxSize10240 KB ⇒ 512 KB). Fora larger asset,
cacheLoad()still stores theCacheEntrywithentry.resource= the liveJarResourcebut with contentnull. Onlookup after
cacheTTL(default 5000 ms),revalidate()compares onlylastModifiedandcontentLength:If unchanged (the JAR on disk hasn’t changed), the same
CacheEntryand its staleJarResourceare reused — the lookup never goesback through
WebDirContext/getJarFiles()/openJARs(), so the self-healing reopen in (1) is bypassed.Net: request #1 caches a
JarResourcewhile the JAR is open → the 90 s idle reaper closes (and nulls) the classloader’s handle, but thecached
JarResourcestill references the now-closedJarFile→ request #2 revalidates by timestamp only, reuses the staleJarResource,and
streamContent()callsgetInputStream()on a closedJarFile→IllegalStateException: zip file closed.(An additional, faster trigger for the same end state is a JVM-wide JAR-cache close on (re)deploy via
JarFileUtils.closeCachedJarFiles()—see Payara #5001 — but the idle reaper alone reproduces it with no deploy.)
Why only large resources
Sub-512 KB resources are content-cached as a
byte[]at first lookup and served from memory — they never callstreamContent()or touchthe live
JarFile, so a closed handle is harmless. Only resources abovecacheMaxSize/20stream live from the cachedJarResourceand hitthe closed handle.
Workarounds (confirm the analysis; not fixes)
cacheMaxSize(per-app inglassfish-web.xml, e.g.<property name="cacheMaxSize" value="65536"/>→ ~3.2 MB per-file cap) so theasset is content-cached and never streamed from the JAR. Effective and verified.
JarFileinvolved).Suggested fix
Stop serving from a cached
Resourcethat wraps a borrowed, time-limitedJarFile:ProxyDirContext, for entries whose content is not cached (overcacheObjectMaxSize), do not retain/reuse theResourceacrossrevalidation — re-resolve via the wrapped
DirContexton each access so the lookup goes back throughWebDirContext.getJarFiles()/openJARs()and obtains a live handle; and/orWebDirContext$JarResource.streamContent()resilient: ifjarFile.getInputStream(jarEntry)fails because theJarFileis closed,re-fetch a live handle from the provider (
getJarFiles()→openJARs()), re-resolve the entry, and retry.References
JarFileUtils.closeCachedJarFiles; closed as abandoned)org.apache.naming.resources.ProxyDirContext(cacheLoad/revalidate,cacheObjectMaxSize = cacheMaxSize/20),org.apache.naming.resources.WebDirContext$JarResource(streamContent),org.glassfish.web.loader.WebappClassLoader(
closeJARs/openJARs, 90 s idle,JarFileResourcesProvider)