September 3, 2024·4 min read·engineeringrubyregexpmemorymaintenance

Ruby 3.3.5 frees regex state before raising timeout errors

Matching could time out correctly while leaking native memory. The maintenance release carries a refactoring that lets callers release their state before Ruby raises the exception.

A regular-expression timeout can stop expensive matching while leaving behind memory allocated for the aborted operation. Repeating the failure then consumes memory even though every individual match is bounded in time. Ruby 3.3.5 includes corrections for this interaction between timeout handling and native cleanup.

The September 3 announcement describes a routine maintenance update. Its detailed release record lists a capture-group leak on timeout and a related leak when String#start_with? uses a regex that times out. These are separate entry paths into matching, with a shared need to preserve cleanup when control leaves the engine.

I find Ruby's concise methods helpful for readable code, but start_with? demonstrates how much implementation a small expression can invoke. With a regex argument, prefix checking participates in regex execution and its failure handling. The method still needs to release the state allocated for that work.

Raising from the engine bypassed caller cleanup

The capture-group correction introduces ONIGERR_TIMEOUT as an engine result. It changes lower-level matching to return that status after releasing its local allocations. The search layer can then distinguish timeout from ordinary mismatch and other errors.

This matters because a Ruby exception transfers control away from the C call path. Code after a call does not necessarily execute when a deeper operation raises. Any native memory whose cleanup sits on that abandoned path can remain allocated.

The first correction adds a timeout branch that frees match arguments and the capture region before invoking the timeout handling macro. This moves cleanup ahead of the exceptional transfer. Its regression test repeatedly executes a capture-heavy pattern with a small timeout and checks resident memory.

The test's purpose is accumulation across failures. One successfully raised Regexp::TimeoutError proves that the time limit can interrupt matching. It does not prove that all temporary allocations made before interruption have been reclaimed. Those are different correctness properties.

Another caller needed the same boundary

The subsequent String#start_with? correction moves the boundary further outward. Its explanation says onig_search should return ONIGERR_TIMEOUT so the caller can free memory and then raise the Ruby timeout error.

The engine's timeout exit now releases match arguments and returns the status. In re.c, the caller frees the region before switching on the result. A timeout becomes Regexp::TimeoutError only after that cleanup has occurred.

This removes the need for one earlier exception-protection wrapper around a particular search path. Keeping the error as an ordinary engine result until the owner can release its state makes the cleanup ordering available to other callers as well. The native interface communicates the failure without prematurely leaving its caller's control flow.

The change does not turn a timeout into a normal false result. Ruby-level callers still receive the exception. The implementation changes where the transition into exception handling happens, preserving the error while allowing native resource release first.

It also keeps ownership local to the layer that allocated or received the state. The lower-level matcher can clean its own work area, while its caller handles the capture region. Returning a distinct status connects those responsibilities without requiring the engine to assume that every caller has identical cleanup needs.

Prefix matching receives its own regression

The second patch adds a test that repeatedly calls str.start_with?(regex) and rescues the failure. It uses the same kind of capture-heavy, deliberately short-timeout setup as the earlier matching test. The distinct public method matters because a fix verified through one entry point can leave another caller's cleanup path uncovered.

Rescuing the exception is part of the reproducer. It allows the process to continue and encounter the timeout again, exposing persistent growth. That is precisely the situation in which a timeout is supposed to let an application reject one operation and keep serving subsequent work.

The commits include before-and-after memory observations for their examples. Those values should not become a promised memory saving for unrelated applications. They support the identified failure and its correction under the reproducer's conditions.

Maintenance has a stated cadence

The release announcement reiterates the plan to publish the latest stable Ruby line every two months after its .1 release, with earlier releases possible for significant changes. That supplies a predictable context for routine fixes without requiring every maintenance update to introduce a new language capability.

Other listed Ruby 3.3.5 changes include weak-map memory safety corrections, keyword-splat behavior and dependency updates. Those entries have their own causes. They should not be attributed to the regex timeout refactoring merely because they share the same release.

My interest in this patch is the clear ownership boundary it establishes. The matcher reports that time expired, the caller releases the state it owns, and Ruby then raises the exception the application expects. That sequence lets repeated rejected operations preserve the process's native-memory cleanup obligations.