February 21, 2020·6 min read·engineeringcmuslabitime

musl widens time_t while preserving its old libc interfaces

musl 1.2.0 gives 32-bit targets a 64-bit time_t. Compatibility depends on which interface crosses the boundary between old and newly compiled code.

Changing the width of a public C type creates a problem that a shared library cannot solve entirely inside its own implementation. Applications can pass that type to other libraries, put it in their own structures, and expose it through callbacks. The important part of musl 1.2.0's release announcement is the distinction between preserving the libc interface for existing programs and making every combination of old and rebuilt libraries compatible. The release achieves the former. Integrators still have work to do on the latter.

The release moves musl's 32-bit architectures to a 64-bit time_t, allowing times beyond January 2038. Its existing 64-bit targets do not change, and it does not impose a newer minimum kernel version. Those facts make the transition narrower than a complete platform replacement, although its consequences can extend well beyond calls made directly into libc.

C's role in interoperation is one of the reasons I still regard it as essential. Calling conventions and public structures connect code written in different languages as well as separately compiled C programs. A change to a shared type makes those connections visible, including ones that an application author may never have intended to maintain as an ABI.

The application can become the compatibility boundary

The time64 notes attached to the announcement explain that old binaries continue to work with the new libc. They also identify a separate mismatch when an application and another library exchange time-related types compiled under different definitions. That distinction determines which components need coordinated rebuilding.

An illustrative library declaration makes the issue concrete. This is an example of an application interface, not an API that musl supplies.

#include <time.h>

struct deadline {
    time_t expires;
};

int queue_schedule(const struct deadline *deadline);

Suppose the library implements queue_schedule using the old definition while its caller builds against the new one. Both source files can contain the same declaration. Both can obtain their respective definitions from legitimate headers. Nevertheless, the two sides disagree about the representation of the object passed through the pointer.

The pointer does not describe the size or meaning of the memory behind it. Successful symbol lookup therefore establishes very little about this agreement. It finds a function with the expected name, but it cannot establish that the caller supplied the object representation the function expects. That is why a successful final link is insufficient evidence of compatibility for a mixed build.

The example also shows why searching only for direct calls to time functions misses part of the problem. The interface contains a libc-defined type without calling a libc function. A wrapper library could carry the mismatch further through another structure or callback. Compatibility follows the representation through those interfaces, including interfaces that appear to belong entirely to the application.

Keeping old callers attached to old definitions

musl uses header declarations to redirect new calls involving affected types to alternative symbols. Legacy entry points remain available through conversion wrappers. The release notes describe the naming scheme and use gmtime_r becoming __gmtime64_r as an example. Newly compiled code can retain its source-level function name while selecting an implementation that agrees with its headers.

This arrangement separates two questions that are easy to conflate. The programmer writes a call through a C declaration. The linker later resolves the symbol associated with that declaration. A header can preserve the source spelling while selecting a different link-time name. The result lets old and new clients reach interfaces with different representations inside the same libc installation.

That mechanism explains why copying a function declaration into application code is especially dangerous during this transition. The declaration in the supported header carries more than a familiar signature. Bypassing it can also bypass the compiler and linker coordination that selects the correct interface.

There is no equivalent automatic fix for the illustrative queue_schedule interface. musl controls its own headers and exports, but it does not control every library's public declarations. For that library, compatibility would require a coordinated rebuild or an explicit library-level compatibility scheme. The libc implementation cannot infer which representation the caller intended from an ordinary pointer argument.

Rebuild decisions follow interfaces

The groundwork for this transition included examining packages whose installed headers refer to time-related types. Arnd Bergmann's July discussion of potentially affected libraries examines dependency relationships and specific uses of those interfaces. The discussion also describes limitations in the initial search. Its results identify places to investigate, rather than proving that every matching package requires the same treatment.

This is a useful way to understand the scale of an ABI change. A type mentioned in a rarely used function can matter less to a particular application than a type embedded in a widely exchanged structure. Package dependency information narrows the search, while actual interface use determines whether representations cross a boundary.

For a distribution, the choice is partly about the guarantees its package system can express. A coordinated update can establish that related components share the new definitions. A more selective update needs enough dependency information to prevent incompatible combinations. Either approach requires a definition of what constitutes a compatible installed set.

A plugin interface needs the same examination. If the host constructs a time-bearing object and passes its address into a loaded plugin, the two builds participate in one representation agreement. Packaging them separately does not make that agreement less necessary. Conversely, a component that exchanges a deliberately specified textual value has a different compatibility question. Its parser and formatter define the exchange, so changing an internal typedef need not change the representation sent across that particular interface.

The same reasoning applies inside a product assembled from several independently built components. A component can compile successfully in isolation and still be unsuitable for the surrounding process. Build provenance matters because the public header text alone may not reveal which definition of an underlying platform type a particular object used.

I prefer describing this transition in terms of those concrete interfaces. Calling it either wholly compatible or wholly incompatible conceals information that determines the upgrade. The release preserves one important boundary, and the components consuming its types need their own compatibility analysis.

A wider application type does not redefine the kernel

A userspace library and the kernel have distinct interfaces. The time64 notes explain how musl selects and translates kernel operations, including fallback on kernels without the newer operations. That makes the absence of a new minimum kernel requirement plausible without implying that every application can disregard its direct kernel dependencies.

An application that bypasses libc takes responsibility for the interface it actually invokes. It cannot assume that a userspace typedef describes every kernel structure with a similar purpose. This matters whenever the application constructs arguments or interprets output itself. Moving the normal library path to a wider type does not automatically revise that code.

The release notes identify syscall filtering as another compatibility consideration. An allowlist describes operations permitted at a boundary. If a library changes which operation it uses, the program's high-level intent can remain identical while its interaction with the filter changes. Compatibility therefore includes the policy around the process, alongside the binary interfaces inside it.

The practical consequence is that a rebuild and a libc upgrade address related but different parts of the system. Rebuilding makes source adopt the new type definitions. It does not by itself establish that a surrounding syscall policy permits the resulting behavior. That conclusion follows from how an allowlist works, regardless of which application supplies it.

Interpreting a successful transition

A useful success criterion includes programs that still use legacy libc entry points and newly compiled programs that use the wider representation. Between those cases sit libraries that exchange time-related values with their callers. Keeping those interfaces consistent is the work that belongs to system integration.

Time also enters persisted formats and application protocols. When an application defines such a format, its representation rules deserve an explicit decision. Changing a compilation environment cannot establish that previously stored data has changed representation. A program that treats its in-memory object layout as a file format has coupled those decisions, whether or not that coupling was intentional.

The widening itself addresses a definite representational limit on the affected targets. Its compatibility design preserves existing libc clients while giving rebuilt code access to the new definition. For a library exposing time-related types, the next substantive decision is how its own callers will select a matching interface. That decision determines whether independently built components can continue to share a process.