block: medium presence — the medium_changed event and usb-storage as publisher (V2b)

The block protocol gains a pushed medium_changed event (present + a monotonic
change counter; presence only, never content). usb-storage becomes a
Subscribers provider and runs a slow TEST UNIT READY poll (1 s): success is
present, failure absent, and a transition bumps the counter and publishes.
This is the second removal trigger — the DEVICE stays while the MEDIUM leaves
(card readers, ATAPI trays) — which channel death cannot see
(storage-architecture.md, two triggers one lifecycle).

The subscriber is the volume manager (V3); until it exists the publish is a
no-op fan-out, so this commit is behaviour-neutral, and its end-to-end test
(eject -> medium_changed -> unmount/remount) lands in V4 with the real
consumer rather than a throwaway subscriber fixture (recorded sequencing).
Sense-key inspection to tell medium-absent from other transport errors is a
noted refinement; a clean eject reads correctly as not-ready.

Neutral: 12/12 across the block-serving surface, restart, confinement,
conformance, and logging.
This commit is contained in:
Daniel Samson
2026-08-09 17:36:57 +01:00
parent 73fbbd3922
commit 7af65697cc
2 changed files with 72 additions and 6 deletions
+53 -6
View File
@@ -25,9 +25,11 @@ const bot = @import("bulk-only-transport.zig");
const envelope = @import("envelope");
const block_protocol = @import("block-protocol");
/// The generated block dispatch. One device per process, so the handler context
/// is empty and the geometry stays in this file's globals.
const Serve = block_protocol.Protocol.Provider(void);
/// The generated block dispatch plus the subscriber machinery the harness owns
/// (subscribe/unsubscribe, the exit sweep, the fan-out) — usb-storage publishes
/// `medium_changed`, so it is a Subscribers provider, not a bare Provider. One
/// device per process, so the handler context is empty.
const Serve = service.Subscribers(block_protocol.Protocol, void);
const Invocation = envelope.Invocation;
const Answer = envelope.Answer;
@@ -47,6 +49,21 @@ var next_tag: u32 = 1;
var block_size: u32 = 512;
var block_count: u64 = 0;
// --- medium presence --------------------------------------------------------
//
// A slow TEST UNIT READY poll tracks whether the medium is present; on a
// transition the driver publishes `medium_changed` to its subscribers (the
// volume manager). This is the second removal trigger — the DEVICE stays while
// the MEDIUM leaves (a card reader, an ATAPI tray) — which channel death cannot
// see (docs/file-system-development/storage-architecture.md). Presence only,
// never content. A device that is genuinely unplugged is reaped by the device
// manager instead; a poll failure just before that death publishes absent
// harmlessly.
var service_endpoint: ipc.Handle = 0;
var medium_present: bool = true; // a successful bring-up means the medium is here
var medium_change_count: u32 = 0;
const presence_poll_ms = 1000;
/// One Bulk-Only-Transport command: send the CBW, run the data stage (to/from
/// `data_physical`), read and validate the CSW. Returns true on a passed status.
fn transact(cdb: []const u8, direction_in: bool, data_physical: u64, data_length: u32) bool {
@@ -82,6 +99,7 @@ fn transact(cdb: []const u8, direction_in: bool, data_physical: u64, data_length
var bring_up_failed = false;
fn initialise(endpoint: ipc.Handle) bool {
service_endpoint = endpoint;
// One hello, both directions: the block-serving endpoint goes UP (the
// manager routes fat's consumer hello here — this driver serves one
// volume, one process per stick, so `block` is never a registry name),
@@ -154,6 +172,8 @@ fn initialise(endpoint: ipc.Handle) bool {
const sector: [*]const u8 = @ptrFromInt(command_data.virtual);
std.log.info("block 0 signature 0x{x:0>2}{x:0>2}", .{ sector[510], sector[511] });
}
// Bring-up succeeded, so the medium is present; start the presence poll.
_ = time.timerOnce(endpoint, presence_poll_ms);
return true;
}
@@ -283,9 +303,34 @@ const handlers = Serve.Handlers{
};
fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize {
// Peeked, never taken: `attach` forwards the capability and the controller's
// binding takes its own reference, so this copy stays the turn's to close.
return Serve.dispatch({}, handlers, message, sender, arrived.peek(), reply);
// The harness peeks the arrival and takes it only if a handler (subscribe)
// claimed it; attach/detach forward the capability without claiming, so the
// turn still closes their copy after the controller took its own reference.
return Serve.dispatch({}, handlers, message, sender, arrived, reply);
}
/// A slow TEST UNIT READY poll: success means the medium is present, failure
/// means it is not. On a transition, bump the counter and publish. (Sense-key
/// inspection to tell "medium absent" from other transport errors is a
/// refinement; a clean eject — what QEMU and a card reader produce — makes
/// TEST UNIT READY report not-ready, which this reads correctly.)
fn pollPresence() void {
const ready = scsi.testUnitReady();
const now = transact(&ready, false, 0, 0);
if (now == medium_present) return;
medium_present = now;
medium_change_count +%= 1;
std.log.info("medium {s}", .{if (now) "present" else "absent"});
Serve.publish(.medium_changed, 0, .{ .present = @intFromBool(now), .change_count = medium_change_count });
}
fn onNotification(badge: u64) void {
const got = ipc.Received{ .len = 0, .badge = badge, .cap = null };
if (got.isTimer()) {
pollPresence();
_ = time.timerOnce(service_endpoint, presence_poll_ms);
}
// Subscriber deaths are swept by the harness (Serve.hooks); nothing else here.
}
pub fn main(init: process.Init) void {
@@ -303,6 +348,8 @@ pub fn main(init: process.Init) void {
service.run(block_protocol.message_maximum, .{
.init = initialise,
.on_message = onMessage,
.on_notification = onNotification,
.subscribers = Serve.hooks,
});
// A failure exit (nonzero -> .aborted) tells the device manager to restart
// us with backoff; a clean return means there was nothing to serve.