Skip to content

fix(mac): coalesce unaligned writes/reads to /dev/rdisk* (.img.zst EINVAL)#1621

Merged
tdewey-rpi merged 1 commit into
mainfrom
dev/tdewey/macos-rdisk-alignment
May 12, 2026
Merged

fix(mac): coalesce unaligned writes/reads to /dev/rdisk* (.img.zst EINVAL)#1621
tdewey-rpi merged 1 commit into
mainfrom
dev/tdewey/macos-rdisk-alignment

Conversation

@tdewey-rpi

Copy link
Copy Markdown
Collaborator

macOS raw block devices (/dev/rdisk*) reject pwrite() / pread() with EINVAL when the length is not a multiple of the logical block size. libarchive's zstd decompressor emits chunks of arbitrary length, so writing a .img.zst custom OS fails mid-stream with "Error writing to device". Reproduces around 30% on a typical Raspberry Pi OS image.

The legacy dispatch_io path didn't have this problem because GCD's channel I/O coalesces unaligned writes internally. The current implementation uses direct pwrite() and lost that property.

Fix: per-instance tail buffer + read-modify-write on flush.

  • OpenDevice() caches the logical block size via DKIOCGETBLOCKSIZE (default 512 fallback).
  • New PwriteAligned() prepends any pending tail to the incoming write, pwrites the block-aligned portion at offset, and stashes the residue for the next contiguous write. Returns the caller- visible bytes-committed (== requested size on success) even when a partial block is deferred. tail_mutex_ guards concurrent access from async-write workers.
  • FlushAlignTail() reads the BS-byte block at tail_offset_, splices in the residue, and writes it back. Invoked from Flush() / ForceSync() / Close() so the final partial block lands before the fd is released.

…NVAL)

macOS raw block devices (/dev/rdisk*) reject pwrite() / pread() with
EINVAL when the length is not a multiple of the logical block size.
libarchive's zstd decompressor emits chunks of arbitrary length, so
writing a .img.zst custom OS fails mid-stream with "Error writing to
device". Reproduces around 30% on a typical Raspberry Pi OS image.

The legacy dispatch_io path didn't have this problem because GCD's
channel I/O coalesces unaligned writes internally. The current
implementation uses direct pwrite() and lost that property.

Fix: per-instance tail buffer + read-modify-write on flush.

  - OpenDevice() caches the logical block size via DKIOCGETBLOCKSIZE
    (default 512 fallback).
  - New PwriteAligned() prepends any pending tail to the incoming
    write, pwrites the block-aligned portion at offset, and stashes
    the residue for the next contiguous write. Returns the caller-
    visible bytes-committed (== requested size on success) even when
    a partial block is deferred. tail_mutex_ guards concurrent
    access from async-write workers.
  - FlushAlignTail() reads the BS-byte block at tail_offset_,
    splices in the residue, and writes it back. Invoked from
    Flush() / ForceSync() / Close() so the final partial block
    lands before the fd is released.

  Routed through:
    WriteSequential          - sync write path (was using write(),
                                now uses PwriteAligned at
                                async_write_offset_ which already
                                tracks the logical cursor)
    AsyncWriteSequential     - async write path (was direct pwrite
                                in the dispatched block, now uses
                                PwriteAligned)
    DrainAndSwitchToSync     - sync-fallback replay of pending
                                async writes
    ReadSequential           - rounds the pread length up to a
                                block, returns at most the requested
                                bytes to the caller

Affects only the macOS code path; no protocol/ABI changes. Tested
against the same .img.zst image that reproduced the bug.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG]: zstd compression fails flashing

1 participant