ClassGraph 5.0.0 is coming shortly, and requires JDK 17 or newer. 4.8.195 is a bugfix release on the 4.x maintenance branch, and continues the file-by-file audit that produced 4.8.190 through 4.8.194. As before, most of the bugs listed here were found by Claude through careful code analysis, and were fixed on the v5 branch and backported to v4.
The themes this time are a scan that can never finish, temporary files that were left behind or misnamed, classpath entries that were silently dropped, and a set of correctness fixes in the class graph itself.
New API
ClassGraph#setWorkerTimeout(long, TimeUnit). EveryFuture#get()call in the scanner now has a timeout, so a scan that can never finish reports why instead of hanging. The two known causes are the classloading deadlock of #933, and a worker thread blocking on a filesystem or network read of a classpath element — a stalled HTTP server serving a remote jarfile, for instance. The timeout defaults to one minute, so a scan that previously blocked forever now fails with an exception naming the reason. A timeout of zero or less waits indefinitely, which is the previous behavior.
Bug fixes: scans that never return
-
The call stack and the context classloader were read on a worker thread during
scanAsync(). TheScannerwas constructed inside the task submitted to theExecutorService, so the call stack that ClassGraph reads to find classloaders, and the thread context classloader it uses, were those of a pool thread rather than of the caller. They are now read on the calling thread, before anything is submitted. -
The #933 fallback now works on Java 8, and covers a classloader as well as a static initializer. If the calling thread holds a class loading lock, loading a class on a worker thread deadlocks the scan, so the whole scan is run on the calling thread whatever number of threads was requested, and the frame holding the lock is named in the verbose log. Where
StackWalkeris not available, the classes declaring the stack frames are taken from the call stack that was already read, so a frame can be matched to its class without loading anything — which is what must not be done while a class loading lock is held.
Bug fixes: temporary files
-
A jarfile whose name contains
---was scanned under a truncated name. ClassGraph names the files it extractsClassGraph--<random>---<name>, andleafName()treated---anywhere in a path as that separator. A jarfile genuinely namedx---y.jarwas therefore reported asy.jar, and that truncated name is what accept and reject criteria are matched against. Worse, a---in a path inside a jarfile falls after the end of the leafname, so the leafname came back empty and the jar matched no criterion at all and was silently skipped. The separator is now only honored in a leafname that starts withClassGraph--, and only at the first separator after that prefix. -
A temporary file that was still memory mapped at the end of a scan was left on disk for the life of the JVM. Below JDK 22 a mapping is dropped by freeing its address range, which cannot be done while the caller can still read a buffer of it, so a file held open by such a view stays mapped after the scan closes. Windows refuses to delete a mapped file, and the retry that closing the scan makes after asking for a garbage collection cannot help either, since the buffer the caller holds is strongly reachable. The file is now deleted when the last view of its mapping is released, rather than being left to the
deleteOnExit()hook. -
A slice or an arena that would not close was not reported. An
IOExceptionfrom a slice that would not close was dropped silently, so a file handle or a mapping that outlived a scan left nothing in the log to explain why a jarfile could not afterwards be deleted or overwritten on Windows; an unchecked failure was not caught at all, and abandoned the rest of the teardown — the slices still to be closed, the inflater recycler, and the temporary files. On JDK 22 and later, an arena that fails to close leaves its file mapped, and that too went unrecorded, so no collection was asked for before the temporary files were deleted. -
Scanning an exploded module leaked an open directory for the life of the JVM.
ModuleReader#list()returns a stream that walks the module's directory tree, and closing that stream is what closes the directories the walk opened. It was collected into a list and dropped without being closed.
Bug fixes: classpath elements that were dropped or duplicated
-
A nested classpath element was missed when a sibling sorted between it and its parent.
findNestedClasspathElements()sorts the classpath elements and walks forward from each looking for elements nested within it, stopping at the first element that is not nested. Every character below/sorts before it, so for the base path/a/classesthe sibling/a/classes-extrafalls between it and/a/classes/sub: the walk stopped at the sibling, the outer element no longer masked the nested one, and the nested element's resources were reported twice. -
A package root or nested jarfile that exists only under a version prefix could not be found. An entry stored under
META-INF/versions/N/in a multi-release jarfile is served without that prefix, and the base copy it masks is gone from the entry list, so a lookup by the stored name matched nothing: namingapp.jar!/WEB-INF/classeson the classpath failed with "Path WEB-INF/classes does not exist in jarfile", and the package root of a war or Spring Boot jar whose classes are versioned was dropped. -
Version prefixes were resolved in a jarfile that is not multi-release. A jarfile is only multi-release if its manifest carries the
Multi-Releasekey; in one that does not, the JVM reads an entry underMETA-INF/versions/from the path it is stored under. The prefix was being stripped from every entry as the central directory was read, which is before the manifest has been parsed, so a "versioned" entry masked the base entry of the same path — the scan reported the versioned entry's content under the base entry's path, and did not report the base entry at all. -
A classpath entry whose path a URI cannot hold was dropped entirely. When stripping a package root suffix from a URI or URL entry, the resolved path — which is percent-decoded — was parsed back as a URI, and the entry was discarded when neither the bare path nor the
file:spelling parsed. A path containing a space took that branch, as did any Windows path, since a backslash is illegal in a URI. It now degrades to the path string, as theFilebranch already did. -
A URL that was already percent-encoded was encoded a second time.
normalizeURLPathleft anhttp:,https:orjrt:URL alone and then passed it to the encoder written for decoded file paths, so%20became%2520, the colon before a port number became%3a, and a query string?v=1&t=2became%3fv%3d1%26t%3d2— after whichURI#getHostreturned null andURI#getPortreturned -1, matching nothing that ClassGraph actually fetches. Such a URL is now escaped by a rule that leaves an existing escape alone and encodes only what a URI cannot hold. File paths are unchanged.
Bug fixes: classloader order
-
A classloader appeared in the delegation order once per handler that could handle it. More than one
ClassLoaderHandlercan handle the same classloader — one that a handler recognizes by name may also extendURLClassLoader— and each is asked for the entries it knows how to read, but the classloader itself was added to the order each time. -
Two classloaders that claim to be equal collapsed into one.
findDefaultClassLoaders()collected classloaders in aLinkedHashSet, so the classes of the one that was dropped were never scanned. Separately,ClassLoaderOrderdeduplicated by identity, as it must, but then kept the order in a map keyed by classloader: adding the second of two equal classloaders replaced the handlers of the first rather than appending, so one of the two dropped out of the order and its entries were never found, and the one left behind ran the other one's handlers. This is not hypothetical — TomEE makes an instance ofCxfContainerClassLoaderequal to theTomEEWebappClassLoaderit delegates to (#515).
Bug fixes: zipfile and classfile reading
-
An MS-DOS zip timestamp was read in the default locale's calendar system. The year, month and day of an MS-DOS date are Gregorian, but the conversion built a calendar for the default locale, so under
th-TH-u-ca-buddhistorja-JP-u-ca-japanesean entry timestamped September 2020 was reported as September 1477. Only an entry with no extended timestamp extra field is affected, which is whatZipOutputStreamwrites whenZipEntry#setTime(long)is the only time that was set. -
A read into a
ByteBufferthat carried a limit from a previous read threwIllegalArgumentException. A read leaves the buffer's limit where it stopped, so a later read starting further into the same buffer positioned past that stale limit — not one of the exceptionsInputStream.read(byte[], int, int)is allowed to throw for a valid range. Three of the four readers already opened the limit before positioning;ClassfileReaderdid not, so the same call sequence failed or succeeded depending on where the content was being read from. The file-channel reader reuses oneByteBufferacross array reads, so array reads through it were affected too. -
A stream of unknown length was read into a 64MB buffer. The buffer was allocated at
maxBufferedJarRAMSizeeven for a stream holding a few bytes, and a stream whose length hint understated it was spilled to a temporary file rather than read into a larger buffer. The buffer now starts at the length hint, or 16kB when there is no usable hint, and doubles as it fills. A related fix:InputStream#readis allowed to return zero from a read of a non-empty buffer, and that was being treated as a full buffer, doubling the buffer on every such read until it reached the maximum or spilled to disk. -
A huge inflated nested jarfile probed RAM before spilling to disk. The length passed when inflating a deflated nested jarfile was clamped to what fits in an array and replaced with -1 ("length unknown") for anything longer, which makes the reader fill a RAM buffer and probe for more before giving up — exactly the wrong thing for an entry already known to be too long to buffer.
-
A read past the end of a classfile was reported as "Hit 2GB limit while trying to grow buffer array" whenever the buffer already held the whole classfile, so a corrupt offset in a 500-byte classfile named a 2GB limit that nothing had come close to.
-
A cached last modified time could be read half-written. A non-volatile
longmay be written in two halves, and a zipfile's entries are shared between the threads reading it.
Bug fixes: the class graph
-
A type annotation had no
ScanResult. A type annotation is attached to its type signature by a decorator that runs after the signature has been given itsScanResult, so the annotation was left without one. Nothing threw, because the annotation still knew its own name, but everything needing the annotation class was quietly wrong:getClassInfo()returned null,getDefaultParameterValues()returned the empty list,getParameterValues()omitted every parameter left at its default and did not convert a boxed array to a primitive array, andisInherited()returned false. (A type annotation on a primitive type is still not covered, becauseBaseTypeSignature#setScanResultis deliberately a no-op — #419.) -
A class named by a descriptor and the same class named by its class name got two separate
ClassInfoobjects, becausegetOrCreateClassInfokeyed its map by the string it was passed rather than by the name it derived from it. -
java.lang.Objectappeared to extend itself, andgetSubclasses()listedObjectamong the subclasses ofObject:extendsSuperclass()answered true for any standard class asked aboutObject, includingObjectitself. -
The parameter annotations of a method with no annotations of its own were never followed. Scanning is extended upwards to the classfile of an annotation used by an accepted class, even when the annotation lies outside the accepted packages, so that its meta-annotations are part of the class graph; the loop over a method's parameter annotations was nested inside the check for the method's own annotations.
-
Printing a record scanned without
enableFieldInfo()threw. The record components appended byClassInfo#toString()were read withgetFieldInfo(), and every field was listed, so a static field — which is not a record component — was rendered as one, complete with its constant initializer.toStringWithSimpleNames()also rendered a record's components with fully qualified names, sinceuseSimpleNameswas not threaded through to them. -
ClassInfoList#exclude()could return a class fromdirectOnly()that its own list no longer contained, andAnnotationInfoList#filter()built its result with the no-argument constructor, which records no directly-related annotations, sodirectOnly()on a filtered list returned the meta-annotations too. -
The
.dotfile writer emitted class names as node identifiers without escaping them. The JVM allows any character in a class name except.,;,[and/, so a name from hand-written or obfuscated bytecode could terminate the identifier and make the file unparseable. -
getResourcesWithPathIgnoringAccept()reported rejected resources from a directory classpath element. It documents that it finds a resource "as long as the resource is not rejected", but delegated to a method that does not apply the reject criteria — which a jarfile element happened to satisfy anyway, since a scan never records a rejected resource, while a directory element looks the path up in the filesystem. The reject criteria are now applied in the method itself. -
getClassesWithAllAnnotations(String...)andgetClassesWithAnyAnnotation(String...)skipped their state check when called with no arguments, returning the empty list rather than throwing, even on a closedScanResultor one scanned without annotation info. -
A type annotation targeting the
extendsclause of a class with no superclass in its generic signature threwNullPointerExceptionout of the scan. Such a classfile is malformed, but it should be rejected rather than crashing the scan. -
A resource that was accepted but not scheduled for scanning was missing from the verbose log, while still being listed by the scan.
-
ReflectionUtils#findField()returned a field whosemakeAccessible()had failed, so the caller got a field it could not read and failed later, somewhere that no longer knew why. It now throwsNoSuchFieldException, asfindMethod()already did in the same situation. -
LinkedIdentitySetinheritedremove(),removeAll()andretainAll()fromAbstractSet, which look for the element withequals()— the very comparison the set exists to avoid — soremove(x)returned false for anxthat is in the set, andretainAll()kept elements it had been asked to discard. They now throwUnsupportedOperationException, like the iterator already did. -
FileUtils#readAttributesletUnsupportedOperationExceptionescape fromlastModifiedTime()andsize()for a path on a non-default filesystem, wherePath#toFile()throws, rather than falling back tojava.nio.filelike the rest of the class. -
A stack trace logged on Windows kept a trailing carriage return on every line, because
LogNodesplit on"\n"whilePrintWriter#printlnends each line with the platform line separator.
Diagnostics
-
Eleven places caught an exception and threw a new one without recording the cause, so the frame that actually failed was missing from the stack trace the caller saw: the eight bulk reads in the four readers, the three reflection lookups,
Resource's conversion of ajrt:URI to a URL,ClassInfo's array element type signature, and the two retries inScannerthat reparse a classpath entry after the first parse fails. What is thrown, and the message it carries, are unchanged. Separately, the failure to open a nested jarfile is now reported with the cause unwrapped, named and chained, and four sites reporting a URI failure now say which path failed. -
A zip entry no longer names itself as a
file:URL that it may not be. -
Numerous log messages, error messages and comments that described the code wrongly have been corrected.