bug: a malformed filename throws std::regex_error out of ImageCache, and catching it deadlocks the file cache

Open
#5,660 0 comments 0 reactions 1 assignee View on GitHub

Maintainers usually reply within 2 days

@lgritz is already working on this.

Since Nov 5, 2026.

  • #5665 by @lgritz — open

Assessment

This issue has not been assessed yet.

Description

Describe the bug

ImageCacheFile::udim_setup builds a tile-matching regex from the caller's
filename and compiles it unguarded, at src/libtexture/imagecache.cpp:4179:

    std::regex decoder(pat);

There is no try/catch. There is none anywhere in the file:
grep -c catch src/libtexture/imagecache.cpp returns 0 at main.

pat is built at :4169 by ImageCacheFile::udim_to_wildcard, which calls
Filesystem::filename_to_regex and then substitutes the udim specifiers.
filename_to_regex (src/libutil/filesystem.cpp:1148-1163) replaces . with
\. unconditionally, escapes ( ) [ ] { } when any of them is present, and,
because simple_glob defaults to true (filesystem.h:420), rewrites ? to
.? and * to .*. It does not touch +. So a + with nothing repeatable
before it survives into the pattern and reaches std::regex as a quantifier
with no operand:

"+<UDIM>.exr"  ->  filename_to_regex   ->  "+<UDIM>\.exr"
               ->  <UDIM> substitution ->  "+([0-9]{4})\.exr"
               ->  std::regex          ->  throws std::regex_error

The exception type is std::regex_error on any implementation, but the code
and message are not. On this pattern MSVC 1951 reports error_badrepeat,
"One of *?+{ was not preceded by a valid regular expression", while
libstdc++ 13.1 reports error_paren, "Mismatched '(' and ')' in regular
expression". libstdc++ says the same for a bare +abc, so that is a
difference in how each library describes a leading quantifier rather than
anything about this pattern. The traces below are from the MSVC build.

A leading + is the whole trigger as far as I can find. Sweeping the regex
metacharacters through get_image_info, one fresh name per query:

+<UDIM>.exr        THREW regex_error(error_badrepeat)
++<UDIM>.exr       THREW regex_error(error_badrepeat)
*<UDIM>.exr        returned false, no throw
?<UDIM>.exr        returned false, no throw
|<UDIM>.exr        returned false, no throw
^<UDIM>.exr        returned false, no throw
$<UDIM>.exr        returned false, no throw
(<UDIM>.exr        returned false, no throw
[<UDIM>.exr        returned false, no throw
{<UDIM>.exr        returned false, no throw
tex+<UDIM>.exr     returned false, no throw
tex<UDIM>+.exr     returned false, no throw

( [ { are escaped, * and ? are translated, | ^ $ are already valid in
that position, and a + with a character before it is a well formed
quantifier. Each name is queried once only, because a name that throws leaves
its own cache bin locked and a retry blocks, which is the second half of this
report.

The exception unwinds out of the ImageCacheFile constructor, through
find_file, and out of whichever public API first touched the name. Measured
on ImageCache::get_image_info, whose documented error contract is a false
return with the reason available from geterror():

calling get_image_info on the malformed pattern...
  ESCAPED as std::exception: regex_error(error_badrepeat): One of *?+{ was not
  preceded by a valid regular expression.

oiiotool dies on it

oiiotool --info '+<UDIM>.exr' prints nothing and terminates with
0xC0000409, a fail fast exit rather than a return. Measured on main and on
3.2.0.2dev:

> oiiotool --info "+<UDIM>.exr"
(no output at all)
exit code -1073740791, that is 0xC0000409

For contrast, a file that is merely missing is reported properly and exits 1:

> oiiotool --info nosuchfile.exr
oiiotool ERROR: read : File does not exist: "nosuchfile.exr"
exit code 1

So this is reachable from the command line, as a crash with no diagnostic, and
not only from a library caller.

And catching it is not a recovery

The throwing constructor runs between find_file's manual lock pair,
src/libtexture/imagecache.cpp:1563 and :1574, with the new ImageCacheFile(...) at :1570 between them:

        size_t bin                  = m_files.lock_bin(filename);
        ...
        m_files.unlock_bin(bin);

There is no RAII between them, so the unwinding exception leaves the
file-cache bin locked. Measured, in one process, on the same ImageCache:

1. malformed pattern, catching the throw...
   caught: regex_error(error_badrepeat): One of *?+{ was not preceded by a
   valid regular expression.
2. second query, a normal readable file...
   returned true, wrote 1
3. third query, the SAME malformed name again...
   <hangs forever; killed after 25 seconds>

Step 2 is the useful part of that trace: the one unrelated file I tried still
works, so the damage is one bin rather than the whole map. That is narrower
than it sounds. FILE_CACHE_SHARDS is 64 (imagecache_pvt.h:35), so roughly
one other filename in 64 hashes into the poisoned bin and blocks the same way,
and anything that walks the whole file map blocks on it too. Step 3 is the
unrecoverable part. A caller that catches the exception and retries the same
name, which is the natural thing to do when an error looks transient, blocks
permanently with no diagnostic.

The project already guards the same compilation elsewhere

Filesystem::get_directory_entries compiles the same kind of
caller-derived pattern inside try/catch, filesystem.cpp:279-298:

    std::regex re;
    try {
        re = std::regex(filter_regex);
        ...
    } catch (...) {

udim_setup passes pat to get_directory_entries at :4170 to list
candidate tiles, then hands the same string to a bare std::regex at :4179.
So the identical pattern is compiled once inside a try and once without one,
nine lines apart.

OpenImageIO version and dependencies

Measured on a source build of main at 0558643e7:

OIIO 3.3.0.0dev | Windows/x86_64
    Build compiler: MSVS 1951 | C++17/199711
Dependencies: OpenEXR 3.4.7, Imath 3.2.2, fmt 12.1.0, libdeflate 1.25, ZLIB 1.3.1

and originally found on a local 3.2.2.0 release build:

OIIO 3.2.2.0 | Windows/x86_64
    Build compiler: MSVS 1951 | C++17/199711
    HW features enabled at build: sse2,sse3,ssse3,sse41,sse42
    No CUDA support (disabled / unavailable at build time)
Dependencies: BZip2 1.0.8, DCMTK NONE, FFmpeg NONE, fmt 12.1.0, Freetype 2.13.3, GIF 5.2.2, Imath 3.2.2, JXL NONE, libdeflate 1.25, Libheif NONE, libjpeg-turbo 3.1.3, LibRaw NONE, libuhdr NONE, OpenColorIO 2.5.1, OpenCV NONE, OpenEXR 3.4.7, OpenJPEG 2.5.4, openjph 0.26.3, PNG 1.6.55, Ptex NONE, Ptex NONE, Robinmap 1.4.1, TBB NONE, TIFF 4.7.1, WebP 1.6.0, ZLIB 1.3.1, zstd 1.5.7

Nothing unusual about either environment and no nonstandard build options. The
cited lines are at main. imagecache.cpp is byte identical at main and
v3.2.2.0, so every line number above applies unchanged there. On the 3.1.x
line the same unguarded construction is at :4159 and the find_file lock
pair is at :1542 and :1553, checked at v3.1.9.0, v3.1.12.0 and
v3.1.19.0.

To Reproduce

No image files are needed. The name does not have to exist on disk; the
exception is thrown while building the pattern, before any tile is opened.

#include <OpenImageIO/imagecache.h>
#include <cstdio>
#include <exception>

int main()
{
    auto ic = OIIO::ImageCache::create(false);
    int n   = -1;

    std::printf("1. malformed pattern\n"); std::fflush(stdout);
    try {
        ic->get_image_info(OIIO::ustring("+<UDIM>.exr"), 0, 0,
                           OIIO::ustring("subimages"), OIIO::TypeInt, &n);
        std::printf("   returned without throwing\n");
    } catch (const std::exception& e) {
        std::printf("   threw: %s\n", e.what());
    }
    std::fflush(stdout);

    std::printf("2. same name again, after catching\n"); std::fflush(stdout);
    try {
        ic->get_image_info(OIIO::ustring("+<UDIM>.exr"), 0, 0,
                           OIIO::ustring("subimages"), OIIO::TypeInt, &n);
    } catch (const std::exception& e) {
        std::printf("   threw: %s\n", e.what());
    }
    std::printf("reached the end\n");
    return 0;
}

Expected: both calls return false with the reason in geterror(), and the
program reaches the end.

Observed: step 1 throws std::regex_error, and step 2 never
returns. Run it under a timeout. Linking needs OpenImageIO and
OpenImageIO_Util.

The fix

Two independent halves.

Guard the compilation, so a filename that is not valid regex marks the file
broken with a clear message instead of throwing:

    std::regex decoder;
    try {
        decoder = std::regex(pat);
    } catch (const std::regex_error& e) {
        mark_broken(Strutil::fmt::format(
            "\"{}\" is not a usable UDIM pattern: {}", m_filename, e.what()));
        return;
    }

And make the bin lock in find_file an RAII guard, which closes the deadlock
half for this throw and for any future one on that path. The second change is
worth making on its own merits: there is currently no exception-safe path
between :1563 and :1574.

Test coverage

testsuite/texture-udim covers well-formed patterns only. A regression test
would be a single get_image_info on a name beginning with +, asserting a
false return rather than an exception, with a second call on the same name
asserting it also returns rather than blocking.

Dominant language
C++
Stars
2.4k
Forks
718
Avg merge
2d 18h
Merged PRs (30d)
85

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from AcademySoftwareFoundation/OpenImageIO

All issues in AcademySoftwareFoundation/OpenImageIO

Similar issues

More C++ issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.