Skip to content
Haiku OSDeep Dive Published Updated 7 min readViews unavailable

Haiku BBufferIO: Stream Ownership and a Source-Level Flush Audit

Audit Haiku BBufferIO ownership and pinned flush behavior, distinguish buffered acceptance from complete writes, and test partial-transfer failure modes.

BBufferIO wraps a BPositionIO stream with a memory buffer and an ownership option. That sounds like a transparent optimization, but a reliability review must inspect when bytes reach the underlying stream, how errors propagate, and what destruction can report. Buffering changes the timing of failures, and ownership changes which object is deleted at the end of a scope.

This article separates the documented API from findings in Haiku source revision b52e0e4935aa749a857a0f6a649e91134a6538c8. The observations below apply to that inspected implementation. They are not a claim that every Haiku release has identical behavior, nor a substitute for testing the implementation shipped on a target machine. The Haiku Book itself warns against treating this class as an atomic-write mechanism or using it where the original stream must remain consistent during buffered operations.

Establish the stream’s lifetime before buffering

The constructor accepts a stream pointer, a requested buffer size, and an ownership flag. The header defaults ownership to true. If a wrapper owns its stream, its destructor deletes that stream. Passing the address of a stack object with the default ownership policy is therefore an invalid lifetime design.

For a borrowed stack-allocated BFile, explicitly disable ownership and destroy the wrapper before the file. The following read-only arrangement illustrates that order:

#include <BufferIO.h>
#include <File.h>

status_t ReadPrefix(const char* path, void* output, size_t capacity,
    ssize_t* bytesRead)
{
    BFile file(path, B_READ_ONLY);
    status_t status = file.InitCheck();
    if (status != B_OK)
        return status;

    BBufferIO buffered(&file, 65536, false);
    ssize_t result = buffered.ReadAt(0, output, capacity);
    if (result < 0)
        return static_cast<status_t>(result);

    *bytesRead = result;
    return B_OK;
}

This fragment assumes non-null output and result pointers supplied by the caller, and reports the actual count. It does not promise a complete read of capacity bytes. The wrapper leaves scope before the borrowed file and never deletes it.

Do not pass a null stream and expect a later operation to safely return B_NO_INIT. Although several methods contain null checks, the pinned constructor calls stream->Position() immediately. Construction therefore already requires a valid stream pointer. Error checks later in the object do not establish a safe null-construction contract.

The inspected implementation enforces a minimum buffer size of 512 bytes. If allocation fails, some operations use direct stream I/O instead. There is no constructor result that should be confused with confirmation that a requested buffer was successfully allocated. Performance measurements need to distinguish actual buffering from fallback behavior.

Buffered acceptance is not completion

A small WriteAt can copy data into the wrapper’s memory and return the requested count without issuing the corresponding underlying write at that point. This means the caller has achieved buffered acceptance, not necessarily transfer to the target stream.

The distinction matters for a database page, configuration update, or serialized document. A full count returned from the wrapper cannot certify that an underlying device accepted every byte. Nor does a successful transfer into a BFile by itself prove a durability policy was satisfied. Persistent publication requires a separate, appropriate storage synchronization and replacement protocol.

Large writes and allocation-fallback writes can take a direct path in the pinned implementation. A single wrapper can consequently have different error timing for different request sizes. Do not infer that a test using a large payload exercises the same code path as a small production update.

Avoid mixing direct writes through Stream() with cached reads or writes through the wrapper unless a coherence protocol is established. The wrapper’s buffer can retain data that no longer reflects the underlying stream. Exposing the pointer is not a cache invalidation mechanism, and SetSize delegation does not automatically demonstrate coherent cached content after truncation.

What the pinned Flush implementation actually checks

In the inspected source, Flush() returns B_OK immediately when the buffer is not dirty. For a dirty buffer, it calls the underlying stream’s WriteAt once for the cached region. It clears the dirty flag whenever that call returns any positive byte count. It returns an error only when that count is negative; a nonnegative count produces B_OK.

These conditions are weaker than “every cached byte was written.” A positive short write can clear the dirty flag while leaving part of the region unwritten. A zero-byte write can return B_OK while leaving the buffer dirty. The documented purpose of flushing should therefore not be turned into a completeness guarantee for this pinned implementation.

This is a source-level finding, not a reported runtime measurement. A native regression fixture should confirm the behavior against the exact target build. It should also verify whether newer source revisions change the implementation before carrying the finding into a release-specific incident report.

The wrapper’s flush does not call a general underlying synchronization operation. Its code issues WriteAt; it is not an alias for BFile::Sync. Flushing an application buffer and synchronizing a storage object address different layers. A success status at one layer should not be used as evidence for the other.

Test positive partial, zero, and negative results separately

Use a controlled BPositionIO test double whose WriteAt records the offset, requested length, and bytes actually accepted. Install a small pending write, call Flush, and inspect both the wrapper’s return value and the test double’s resulting content.

For an eight-byte pending region, return two from the first underlying write. The inspected code predicts B_OK and a cleared dirty flag. A second flush should not issue another write, exposing the missing six-byte completion path. This is different from merely asserting that the first return status was successful.

In a second fixture, return zero. The inspected code predicts B_OK but retains dirty state, so another flush attempts the operation again. Verify the call count and data, not just the status. In a third fixture, return a negative error such as B_IO_ERROR; verify that the error propagates and that pending state remains available for another attempt.

Keep these tests isolated from destructor behavior. The destructor also attempts to flush dirty data but cannot return its status to the caller. Letting scope exit decide success conceals exactly the failures the fixture is intended to observe. Destroy test wrappers only after collecting explicit flush results and underlying transfer evidence.

Use checked direct writes for critical completion

For a path requiring complete transfer, do not rely on this wrapper’s B_OK alone. A simpler alternative is a direct, checked operation on the underlying BPositionIO. Haiku’s WriteAtExactly implementation loops over positive partial writes, advances the offset and buffer, and returns B_PARTIAL_WRITE when a call makes no progress.

#include <DataIO.h>

status_t WritePayload(BPositionIO& stream, off_t offset,
    const void* payload, size_t length, size_t* completed)
{
    // Caller validates pointers, nonnegative offset, and representable extent.
    return stream.WriteAtExactly(offset, payload, length, completed);
}

Use this helper on the actual stream, not on BBufferIO as a way to certify its internal flush. Calling an exact-write helper on a buffering layer can only verify the counts returned by that layer. It cannot force a later flush implementation to report complete underlying transfer.

A failed exact write may already have modified part of the destination. Preserve the completed count and apply the format’s recovery policy. For an atomic document publication workflow, a temporary destination and validated replacement may be appropriate; for a database, use its transaction protocol. “Retry” is not automatically safe for every partially modified object.

Audit seeking and interleaved access on the target version

The pinned Seek implementation maintains its own cursor. Its SEEK_END branch computes the underlying size minus the supplied position. That merits a dedicated fixture rather than assuming familiar end-relative sign conventions. Test positive and negative offsets and compare observed behavior with the documentation and target version.

Also test small writes near buffer boundaries and transitions between cached and direct operations. Source review reveals enough nontrivial state to make arbitrary random-access writes inappropriate for untested production assumptions. The safe response is to constrain usage or select a simpler checked stream path, not to claim that buffering is universally broken or universally transparent.

The C++ fragments were source-reviewed, not executed on a Haiku host during authoring. A production adoption record should name the deployed build, retain the native fixture results, and distinguish memory ownership, transfer completeness, and persistence. Those three contracts determine whether this wrapper fits a workload much more reliably than a successful flush status by itself.

Related:

Sources:

Comments