Skip to content

Memory and Allocators ​

The library never allocates from a global. Every entry point that needs memory takes a std.mem.Allocator, and every entry point that needs I/O takes a std.Io.Reader, a std.Io.Writer or a std.Io interface. Nothing is cached between calls, and nothing reaches for a global file handle.

That makes memory behaviour a property the program chooses rather than one it discovers.

Ownership ​

zig
const compressed = try bzip2.compress(gpa, data, .{});
defer gpa.free(compressed);

const restored = try bzip2.decompress(gpa, compressed, .{});
defer gpa.free(restored);

Both slices are allocated with gpa and freed with gpa. Free them with the same allocator they came from.

Choosing an allocator ​

General purpose ​

zig
const gpa = init.gpa;

Fine for most programs. Each call allocates and frees its own buffers.

Arena, for one-shot work ​

zig
var arena_state = std.heap.ArenaAllocator.init(gpa);
const arena = arena_state.allocator();

const compressed = try bzip2.compress(arena, data);
const restored = try bzip2.decompress(arena, compressed);
// one free at the end releases the compressed buffer, the restored buffer and
// every scratch block along the way
_ = arena_state.deinit();

Convenient when the buffers have similar lifetimes, since individual frees become unnecessary. An arena is a poor fit for a long-running process, because nothing is ever returned until the arena is dropped.

Fixed buffer, for a known budget ​

zig
var storage: [4 << 20]u8 = undefined;
var fixed = std.heap.FixedBufferAllocator.init(&storage);

const compressed = try bzip2.compress(fixed.allocator(), data);

Every allocation the library makes comes out of storage, so the total is bounded and a program with no general purpose heap still works. Running out produces error.OutOfMemory rather than growing without limit.

The buffer has to cover the codec scratch for one block plus the result. The encoder needs the most: level 1 with 4 MiB is comfortable, and higher levels need proportionally more. fixed.end_index reports how much was used.

Caller-owned buffers ​

When a caller already has a destination buffer, Compressor writes into any std.Io.Writer, including one backed by memory the caller owns:

zig
var storage: [8192]u8 = undefined;
var writer = std.Io.Writer.fixed(&storage);
try compressor.write(&writer, data);

fixed fails rather than growing, so check writer.writeAll results for error.WriteFailed when the output might not fit.

What the codec allocates ​

The encoder holds, for one block of block_size bytes:

BufferSize
RLE1 outputblock_size plus a small overrun margin
MTF symbol streamblock_size + 1 entries of two bytes
Last columnblock_size
Rotation indicesblock_size entries of four bytes, plus a second index buffer
Huffman and table working statebounded, independent of block size

The decoder holds the last column, a first-column mapping and the reconstructed block, all sized from the block size the header declares, plus a fixed 64 kB output window.

At level 9 a 900 kB block needs a few megabytes of scratch regardless of how much data passes through. At level 1 it needs proportionally less.

Reuse across calls ​

Scratch on the decode side ​

Decoder allocates its scratch on construction and keeps it. Reusing one decoder across many streams is the whole reason it is a struct rather than a function:

zig
var decoder = try bzip2.Decoder.init(gpa, .{});
defer decoder.deinit();

while (next_archive()) |stream| {
    try decoder.stream(stream.reader, stream.writer);
}

For finer control, use Decompressor with a Scratch you own. The scratch grows to the largest block size it has seen and stays there, so the first stream may allocate and the rest do not:

zig
var scratch = try bzip2.Scratch.init(gpa);
defer scratch.deinit();

var decoder: bzip2.Decompressor = .init(.{});
try decoder.stream(reader, writer, &scratch);

Scratch is safe to share only between decoders used one at a time. It is not synchronised; concurrent decoding needs one scratch per thread.

Reset on the encode side ​

zig
for (inputs) |data| {
    compressor.reset();
    try compressor.write(writer, data);
    try compressor.finish(writer);
}

reset clears the stream state and keeps the buffers.

Memory use is bounded by the block, not the input ​

Nothing in the streaming or whole-file paths scales with the length of the data. A 4 GB file and a 4 kB file cost the same encoder scratch. The large_file example processes 16 MiB and reports its working set.

The one thing that does scale is the result of compress and decompress, because those return the whole thing in one slice. Use the streaming entry points when that matters.

Output budgets ​

Independent of the allocator, max_output_size bounds how much a stream may produce:

zig
const restored = try bzip2.decompress(gpa, untrusted, .{
    .max_output_size = 64 * 1024 * 1024,
});

This is the limit that matters for untrusted input. It caps the result before a single byte is written, so a small stream that claims a large expansion fails with error.OutputTooLarge rather than succeeding and filling memory.

Allocation failures ​

Every allocation path returns error.OutOfMemory. The library holds no state between calls that a failure could leave inconsistent: a Compressor or Decoder whose deinit runs after a failed operation releases everything it acquired, and the objects are not usable afterwards, since the operation that failed leaves them mid-stream.

A Compressor that fails partway through writing a block can be reset and reused. A Decoder that fails mid-stream cannot, because the input position has moved on and the stream is no longer parseable from where it stopped.

Released under the MIT License.