Files
libfreemkv/docs/scsi-macos.md
T
Matthew Jackson 8949b81dfc macos(drive): discover empty-tray drives via IOKit registry-id selectors
Empty-tray optical drives couldn't be opened because the macOS backend
resolved drives by media-child `BSD Name`, which doesn't exist without a
disc loaded. Result: `list_drives()` returned nothing for a drive with no
media, and any attempt to `open("disc://")` on that state failed.

Fix: enumerate `IOBDServices` directly. With media present, the returned
selector is the familiar `/dev/diskN` path (unchanged). With no media,
the selector is an opaque `ioreg:<registry-id>` string that resolves to
the same IOBDServices entry — Disk Arbitration and unmount are skipped
because there's no media node.

- `src/drive/macos.rs`, `src/drive/mod.rs`, `src/scsi/mod.rs`: threads
  the selector through the transport so empty-tray drives are openable
  without punning it as a filesystem path.
- `src/scsi/macos.rs`, `src/scsi/macos_shim.c`: the C shim now accepts
  both `/dev/diskN` and `ioreg:<id>` in `shim_open_exclusive`, and
  `shim_list_drives` reports the correct selector for each drive.
- `tests/macos_drive_tests.rs` (new): covers empty-tray discovery.
- `docs/drive-access.md`, `docs/scsi-macos.md`: document the two-path
  selector model.

[skip-media]
2026-09-25 12:03:02 -07:00

4.0 KiB

macOS SCSI transport (src/scsi/macos.rs)

Open sequence (C shim, macos_shim.c)

MacScsiTransport::open drives all CDBs (INQUIRY, READ, REPORT KEY, etc.) through SCSITaskDeviceInterface::ExecuteTaskSync — 1:1 with the Linux SG_IO backend. Getting there involves the shim doing:

  1. diskutil unmountDisk force on the target device only
  2. Find IOBDServices matching the requested BSD name (walks IOKit registry: IOBDServices -> IOBDBlockStorageDriver -> IOMedia -> BSD Name)
  3. Create MMCDeviceInterface -> SCSITaskDeviceInterface
  4. ObtainExclusiveAccess
  5. Raw CDB dispatch via CreateSCSITask + ExecuteTaskSync

Single-instance ownership (OPEN flag)

The C shim uses one global IOKit handle (g_handle), so only one MacScsiTransport may exist at a time: a second open() would share that handle, and the first drop() would tear it down out from under the other. The OPEN AtomicBool enforces single-instance ownership, independent of the shim's own state.

map_shim_open_error

Maps a shim_open_exclusive failure sentinel (a negative return code, NOT an IOReturn) to its typed Error variant. Pulled out of open as its own callable predicate so the mapping — otherwise reachable only through a real IOKit FFI call — can be pinned by a test: collapsing -4..=-2 or -5 into the DeviceNotFound catch-all would silently turn "another process holds the drive" into "no such drive", or "the IOKit plugin chain failed" into the same.

Drive enumeration and the media-presence probe

list_drives uses the IOKit registry directly via shim_list_drives — no exclusive access, no SCSI commands, no unmounts. It enumerates IOBDServices even when no IOMedia child exists. With media, the returned path remains /dev/diskN; with an empty tray it uses the opaque ioreg:<registry-id> selector so the service can still be opened directly. That selector is an internal list-then-open handle, not a filesystem path.

drive_has_disc is documented (crate::scsi::drive_has_disc) as the cheap, side-effect-free "is there a disc?" question, suitable for a poll-loop tick. The Linux backend honours that: open(O_RDWR|O_NONBLOCK)

  • one TEST UNIT READY, no exclusive access, no unmount. The Windows backend likewise opens a shared handle and issues one TUR.

macOS could not do the same: MacScsiTransport::open is the FULL exclusive-transport path above, whose first act is diskutil unmountDisk force on the target device. So a probe implemented via open would force-unmount the user's disc — and on every poll tick, taking (and dropping) exclusive access each time.

Instead drive_has_disc answers via shim_media_present, the IOKit registry only: steps 1-5 above are the transport open path, and this probe must not run any of them. For a BSD disk path, the registry looks for its matching IOMedia; for an ioreg:<id> selector, it resolves the drive service and checks whether that service currently has media. No SCSI command is issued, which is why no timeout parameter is involved.

Trade-off, stated plainly: this reports what the OS has enumerated, so a disc that is inserted but still spinning up (no IOMedia published yet) reads as absent for the moment the enumeration takes — the same window in which a TUR would answer "not ready" and this function's contract already maps to Ok(false).

Regression test: presence_probe_does_not_open_a_transport

drive_has_disc used to be implemented by constructing a FULL exclusive transport, whose first act is diskutil unmountDisk force on the target device followed by an unconditional usleep(500000) — so the probe force-unmounted the user's disc, on every poll tick.

Two observables separate the registry probe from the transport open, neither of which needs an optical drive to be attached:

  1. It ANSWERS. The transport path returned Err(DeviceNotFound) here; the registry path reports "no media" as Ok(false).
  2. It is FAST. The transport path's usleep(500000) after the spawn is unconditional, so it could not complete inside this budget even when the spawn itself failed.