March 22, 2017·4 min read·engineeringrubyruntimetesting

Ruby 2.4.1 fixes when unpacked strings become observable

Ruby 2.4.1 repairs an unpacking path that exposed a string before its bytes were filled. Its regression test shows why inspecting a mutable result later can miss an initialization bug.

A callback can observe an object before the native code creating it has finished initialization. Ruby's String#unpack had that problem for bit and hexadecimal formats when yielding a result to a block. The string existed, but its content bytes had not yet been filled. Ruby 2.4.1 moves the point at which that result becomes visible.

The March 22 announcement identifies this as the first patch release of the stable 2.4 series. Its tagged source comparison includes repairs to unpacking and IO lifetime, among other backports. The unpacking repair is especially instructive because its first regression test could miss the defect by observing the object too late.

I find Ruby's blocks an approachable way to express iteration and resource management. That convenience relies on the runtime handing a block a usable value. A maintenance fix at that boundary preserves the ordinary meaning of the Ruby code even though the repair itself is in C.

Allocation is not the end of initialization

The backport for bug 13075 changes unpacking for the b, B, h and H directives. The old implementation allocated a string and passed it through UNPACK_PUSH before filling its contents. In block mode, that operation can yield the value immediately.

The repair keeps the allocation first, fills the buffer in the appropriate loop, and only then passes the string onward. That ordering is the substantive change. Allocating the right number of bytes and eventually writing the right result were insufficient because a callback could run between those operations.

The added regression case uses the bit-string representation of the letter A. Its crucial detail is the duplicate made inside the callback.

observed = []
"A".unpack("B*") { |value| observed << value.dup }
observed == ["01000001"]

Duplicating captures the bytes as the block sees them. Keeping only a reference to the yielded string would let the native function fill that same object's buffer after the block returned. A later comparison could then succeed despite the invalid contents visible during the callback.

This is why the commit includes a correction to the earlier test. A test that checks eventual state does not necessarily check callback-time state. The mutable object connects the two observations, and later initialization can conceal the earlier mistake. The duplicate breaks that connection for the assertion.

A longstanding path can appear in a new maintenance release

The commit traces the unpacking behavior to the introduction of block-based unpacking in 2006. It is therefore inaccurate to describe every fix in 2.4.1 as a regression introduced by Integer unification or another 2.4 feature. The patch release gathers repairs relevant to the maintained branch, including older defects.

The source also adds a corresponding unpack1 assertion for the same bit result. That checks another way callers obtain a single decoded value. The shared implementation needs to produce initialized results regardless of which public entry point requests them.

The repair does not change what B* means. It changes when its completed result is delivered. Code that already expects a bit string should receive the same intended representation, with the additional guarantee that the value is filled before Ruby code can inspect it through the affected path.

IO repairs address another lifetime boundary

The release comparison includes backport 13076 for a race between reading and closing an IO object. Its log describes the case where the IO closes before the reading thread acquires the lock. The repair also coordinates closing with threads still using the descriptor so a buffer in use is not freed prematurely.

A related backport, 13158, describes notifying and closing before waiting for other threads and freeing the IO structure. These are implementation-ordering repairs. Merely having a lock or a close operation is insufficient if cleanup can release state another operation still needs.

This makes the patch release relevant beyond applications adopting new 2.4 methods. A program can use familiar IO and unpacking APIs while depending on their native lifetime rules. Correct Ruby syntax does not protect it from an interpreter exposing an unfinished string or releasing a buffer too early.

I value the concrete regression case because it records the observation the API must support. The important condition is the value visible inside the callback, not the appearance of a completed string after control returns to native code. The IO backports apply the same attention to sequencing where several threads share runtime-managed state.

The release announcement is brief and directs readers to commit history for detail. That history supports a more precise account than a general claim that the first patch fixes the new numeric model. Ruby 2.4.1 includes repairs to object initialization and IO lifetime under existing APIs, with tests and backport records that identify the conditions each change addresses.