356 lines
18 KiB
Zig
356 lines
18 KiB
Zig
//! The service harness (docs/process-lifecycle.md): one replyWait loop that
|
|
//! folds protocol requests, signals, and subscribed notifications into
|
|
//! callbacks — so the lifecycle contract ("answers ping, exits on terminate")
|
|
//! is satisfied by construction and a service author writes domain logic only.
|
|
//! Nothing is asynchronous inside the process: a callback runs at a point the
|
|
//! loop chose, never on a hijacked stack — the whole reason signals are
|
|
//! messages.
|
|
//!
|
|
//! One rule a service author does have to know, and it is stated on
|
|
//! `Callbacks.on_message`: **a capability that arrives belongs to the turn** —
|
|
//! the loop closes it unless the callback claims it with `take()`. Forgetting is
|
|
//! therefore safe, and keeping is explicit; the opposite arrangement quietly
|
|
//! spends a handle-table slot per request.
|
|
//!
|
|
//! The harness also owns the **subscriber side** of a protocol that declares
|
|
//! `.events` — see `Subscribers`. The table, the reserved subscribe/unsubscribe
|
|
//! verbs, the fan-out, and the dead-subscriber sweep live here rather than in
|
|
//! each provider, so every event stream in the system has identical semantics
|
|
//! (docs/os-development/protocol-namespace.md, "Wiring").
|
|
//!
|
|
//! The liveness probe: a **zero-length request is the universal ping**, answered
|
|
//! with a zero-length reply by the harness itself. No protocol's requests start
|
|
//! at length zero, so the encoding cannot collide, and there is nothing for a
|
|
//! service author to implement — a wedged service simply fails to answer, which
|
|
//! is the diagnosis (see docs/ipc.md).
|
|
|
|
const channel = @import("channel");
|
|
const envelope = @import("envelope");
|
|
const ipc = @import("ipc");
|
|
const process = @import("process");
|
|
|
|
/// The harness's handle on a provider's subscriber table, type-erased because
|
|
/// `run` is not generic over the protocol while `Subscribers` is. A service names
|
|
/// its table once, as `Callbacks.subscribers`, and the loop does the rest: it
|
|
/// subscribes to published process exits at startup and drops a dead task's
|
|
/// subscriptions before the service's own notification callback ever sees the
|
|
/// badge.
|
|
pub const SubscriberHooks = struct {
|
|
/// Ask the kernel for published exit events on this service's endpoint.
|
|
watch: *const fn (endpoint: ipc.Handle) void,
|
|
/// Drop everything task `dead` had subscribed.
|
|
forget: *const fn (dead: u32) void,
|
|
};
|
|
|
|
pub const Callbacks = struct {
|
|
/// Called once with the service's endpoint before the loop starts — the
|
|
/// place to subscribe to exit events, bind IRQs, or announce readiness.
|
|
/// Return false to abort startup (the process exits).
|
|
init: ?*const fn (endpoint: ipc.Handle) bool = null,
|
|
/// One protocol request from `sender` (a task id): write the reply into
|
|
/// `reply`, return its length. The zero-length ping never reaches this.
|
|
///
|
|
/// `arrived` is the capability the request carried (M13 cap passing — how a
|
|
/// subscriber hands over its endpoint), and it comes with **an ownership
|
|
/// rule: the turn owns it, and a handler that wants to keep it must say so
|
|
/// with `take()`.** Whatever is left when this returns, the loop closes.
|
|
/// `peek()` reads it without claiming, which is what a handler that may
|
|
/// still refuse wants — no close of its own on the refusal paths.
|
|
///
|
|
/// The rule is stated here, in the contract, because the alternative has
|
|
/// failed in practice: an implementation that simply ignored a `?ipc.Handle`
|
|
/// argument leaked a handle table slot per request, and every operation
|
|
/// except a subscribe ignores it. Thirty-two such requests — zero-length
|
|
/// pings will do, and they need no authorization — and the service can never
|
|
/// accept another capability for the rest of the boot. See `ipc.Arrival`.
|
|
on_message: *const fn (message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize,
|
|
/// A notification that is not a signal — a subscribed exit event, a bound
|
|
/// IRQ, a timer landing. The raw badge; decode with the ipc helpers.
|
|
on_notification: ?*const fn (badge: u64) void = null,
|
|
/// The reload signal. Default: ignored.
|
|
on_reload: ?*const fn () void = null,
|
|
/// The terminate signal, called before the loop returns. The clean exit is
|
|
/// the return itself — never put *necessary* work here (iron rule 1: a kill
|
|
/// arrives with no warning; this is for graceful extras only).
|
|
on_terminate: ?*const fn () void = null,
|
|
/// The contract this service provides: a name under `/protocol`, mirroring
|
|
/// the `library/protocol/` module that defines the wire format — a program
|
|
/// imports `display-protocol` and the provider binds `"display"`
|
|
/// (docs/os-development/protocol-namespace.md). Bound at startup, before
|
|
/// `init` runs, so the service is reachable the moment it serves. A refusal
|
|
/// (not granted, or a live provider already holds the name) aborts startup.
|
|
service: ?[]const u8 = null,
|
|
/// This provider's subscriber table — `Subscribers(Protocol, Context).hooks`
|
|
/// — for a protocol that declares `.events`. Naming it here is what buys the
|
|
/// exit-notification sweep: the loop subscribes to published deaths at
|
|
/// startup and releases a dead subscriber's slot (and the endpoint capability
|
|
/// in it) when one lands.
|
|
subscribers: ?SubscriberHooks = null,
|
|
};
|
|
|
|
/// How many subscribers one provider fans out to. Bounded like every table in
|
|
/// this system; a subscribe past the end is refused with `-ENOSPC` rather than
|
|
/// silently forgetting an earlier one.
|
|
pub const subscriber_capacity = 8;
|
|
|
|
/// The interest mask that means "every event of this protocol" — what a
|
|
/// subscriber which named no class gets, and what a provider passes when the
|
|
/// event it is publishing belongs to no class.
|
|
pub const every_event: u32 = 0;
|
|
|
|
/// The subscriber side of a protocol, for a provider whose contract declares
|
|
/// `.events` (docs/os-development/protocol-namespace.md: *the harness owns the
|
|
/// machinery — the subscriber table, the dead-subscriber sweep, and the fan-out
|
|
/// loop*). Three services hand-rolled this, with three different ideas of when a
|
|
/// dead subscriber goes away — a poll of the process list on subscribe, a drop on
|
|
/// a failed send, and nothing at all. This is the one idiom.
|
|
///
|
|
/// ```zig
|
|
/// const Subscriptions = service.Subscribers(power_protocol.Protocol, void);
|
|
/// ...
|
|
/// fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize {
|
|
/// return Subscriptions.dispatch({}, handlers, message, sender, arrived, reply);
|
|
/// }
|
|
/// pub fn main() void {
|
|
/// service.run(power_protocol.message_maximum, .{
|
|
/// .service = "power",
|
|
/// .on_message = onMessage,
|
|
/// .subscribers = Subscriptions.hooks,
|
|
/// });
|
|
/// }
|
|
/// ```
|
|
///
|
|
/// What the provider still writes is its own events — `publish(.power_button, 0,
|
|
/// .{})`. Everything else happens here: registering the caller's endpoint on the
|
|
/// reserved `subscribe` verb, taking that capability out of the turn, dropping it
|
|
/// on `unsubscribe` or on the subscriber's death, and framing one packet for the
|
|
/// whole fan-out.
|
|
///
|
|
/// The table is per instantiation (a container-level `var` inside the generic
|
|
/// type), so a process providing two contracts gets two tables and neither can
|
|
/// see the other's subscribers.
|
|
pub fn Subscribers(comptime Protocol: type, comptime Context: type) type {
|
|
return struct {
|
|
/// The generated dispatch this provider answers with.
|
|
pub const Provider = Protocol.Provider(Context);
|
|
pub const Handlers = Provider.Handlers;
|
|
|
|
/// One registered subscriber: the endpoint events are pushed to (the
|
|
/// capability it handed over at subscribe time, which this slot owns),
|
|
/// the task that handed it over — the kernel-stamped badge, the only
|
|
/// source identity there is — and which classes of event it asked for.
|
|
const Slot = struct {
|
|
used: bool = false,
|
|
endpoint: ipc.Handle = 0,
|
|
task: u32 = 0,
|
|
interest: u32 = every_event,
|
|
};
|
|
|
|
var slots: [subscriber_capacity]Slot = .{Slot{}} ** subscriber_capacity;
|
|
|
|
/// Set when a slot has taken the capability the turn carried, and read
|
|
/// back in `dispatch`, which is where the turn's `Arrival` lives. The
|
|
/// generated dispatch hands a handler the raw handle rather than the
|
|
/// `Arrival` — deliberately, since a handler has no business closing the
|
|
/// turn's property — so the *claim* has to travel back out this way. One
|
|
/// turn, one handler, one thread: there is nothing here to race.
|
|
var claimed = false;
|
|
|
|
/// What `Callbacks.subscribers` is given.
|
|
pub const hooks: SubscriberHooks = .{ .watch = watchExits, .forget = forget };
|
|
|
|
fn watchExits(endpoint: ipc.Handle) void {
|
|
// Published exits, not a poll of the process list: a service must
|
|
// never depend on clients cleaning up after themselves, and it must
|
|
// not have to walk the whole table on every subscribe to find out
|
|
// either (docs/process-lifecycle.md, "Who learns of a death").
|
|
_ = process.subscribeExits(endpoint);
|
|
}
|
|
|
|
/// Release everything task `dead` had subscribed. The slot owns the
|
|
/// endpoint capability, so reclaiming the slot closes it — otherwise a
|
|
/// process that subscribes and dies costs a handle-table slot that never
|
|
/// comes back.
|
|
pub fn forget(dead: u32) void {
|
|
for (&slots) |*slot| {
|
|
if (slot.used and slot.task == dead) {
|
|
_ = ipc.close(slot.endpoint);
|
|
slot.* = .{};
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether `task` is a subscriber — the gate for an operation a provider
|
|
/// honours from its subscribers and nobody else. The power service's
|
|
/// shutdown is the one: the badge is kernel-stamped, so nothing in a
|
|
/// packet can claim to be the subscriber that already ran the stop
|
|
/// sequence.
|
|
pub fn has(task: u32) bool {
|
|
for (&slots) |*slot| {
|
|
if (slot.used and slot.task == task) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Answer one received packet, with the reserved `subscribe` and
|
|
/// `unsubscribe` verbs already wired — a provider that leaves those two
|
|
/// handlers null (every provider should) gets the harness's. The turn's
|
|
/// capability is peeked, never taken, unless a slot actually kept it.
|
|
pub fn dispatch(
|
|
context: Context,
|
|
handlers: Handlers,
|
|
packet: []const u8,
|
|
sender: u32,
|
|
arrived: *ipc.Arrival,
|
|
reply: []u8,
|
|
) usize {
|
|
var wired = handlers;
|
|
if (wired.subscribe == null) wired.subscribe = onSubscribe;
|
|
if (wired.unsubscribe == null) wired.unsubscribe = onUnsubscribe;
|
|
claimed = false;
|
|
const written = Provider.dispatch(context, wired, packet, sender, arrived.peek(), reply);
|
|
if (claimed) _ = arrived.take();
|
|
return written;
|
|
}
|
|
|
|
/// Push one event to every subscriber.
|
|
pub fn publish(
|
|
comptime event: Protocol.Event,
|
|
target: u64,
|
|
payload: Protocol.PayloadOf(event),
|
|
) void {
|
|
publishClass(event, target, payload, every_event);
|
|
}
|
|
|
|
/// Push one event to the subscribers whose interest mask includes
|
|
/// `class` (a subscriber that named no class takes everything). The
|
|
/// packet is framed **once**, outside the loop, so every subscriber of a
|
|
/// class receives identical bytes; and delivery is `ipc.send`, which
|
|
/// never blocks, so one slow or dead subscriber can never stall the rest
|
|
/// — the whole reason broadcast is a provider pattern and not a kernel
|
|
/// primitive.
|
|
pub fn publishClass(
|
|
comptime event: Protocol.Event,
|
|
target: u64,
|
|
payload: Protocol.PayloadOf(event),
|
|
class: u32,
|
|
) void {
|
|
var packet: [envelope.post_maximum]u8 = undefined;
|
|
const framed = Protocol.encodeEvent(event, target, payload, &packet) orelse return;
|
|
for (&slots) |*slot| {
|
|
if (!slot.used) continue;
|
|
if (!wants(slot.*, class)) continue;
|
|
// The sweep is what normally reclaims a dead subscriber, promptly
|
|
// and with its capability closed. This is the backstop for a
|
|
// notification that never arrived: an endpoint's notify ring is
|
|
// bounded, so a burst of deaths can drop one, and a send to an
|
|
// endpoint whose owner is gone fails rather than blocking.
|
|
if (!ipc.send(slot.endpoint, framed)) {
|
|
_ = ipc.close(slot.endpoint);
|
|
slot.* = .{};
|
|
}
|
|
}
|
|
}
|
|
|
|
fn wants(slot: Slot, class: u32) bool {
|
|
if (class == every_event) return true; // the event belongs to no class
|
|
if (slot.interest == every_event) return true; // the subscriber named none
|
|
return slot.interest & class != 0;
|
|
}
|
|
|
|
/// The reserved `subscribe` verb: register the caller's endpoint (the
|
|
/// call's capability) for the classes its tail names. A refusal simply
|
|
/// returns and the turn closes what arrived — the harness's ownership
|
|
/// rule (`ipc.Arrival`), which is why a subscribe storm against a full
|
|
/// table cannot spend the handle table.
|
|
fn onSubscribe(_: Context, invocation: envelope.Invocation(void), _: envelope.Answer(void)) isize {
|
|
const endpoint = invocation.capability orelse return -envelope.EPROTO; // no endpoint passed
|
|
const interest = envelope.decodeSubscribe(invocation.tail).interest;
|
|
for (&slots) |*slot| {
|
|
if (slot.used) continue;
|
|
// Appended, not replaced: one task may hold several subscriptions
|
|
// on different endpoints (a client taking keyboard and mouse as
|
|
// two streams), and each is its own conversation.
|
|
slot.* = .{ .used = true, .endpoint = endpoint, .task = invocation.sender, .interest = interest };
|
|
claimed = true; // the table holds it until that task dies
|
|
return 0;
|
|
}
|
|
return -envelope.ENOSPC; // table full
|
|
}
|
|
|
|
/// The reserved `unsubscribe` verb: every subscription the calling task
|
|
/// holds here goes, which is exactly what its death would do. It names no
|
|
/// endpoint because the badge already names the only subscriber a caller
|
|
/// can speak for — its own.
|
|
fn onUnsubscribe(_: Context, invocation: envelope.Invocation(void), _: envelope.Answer(void)) isize {
|
|
if (!has(invocation.sender)) return -envelope.ENOENT;
|
|
forget(invocation.sender);
|
|
return 0;
|
|
}
|
|
};
|
|
}
|
|
|
|
/// Run the service: create the endpoint, bind it under the service's contract
|
|
/// name (if it has one), bind signals to it, call `init`, then serve until
|
|
/// `terminate` arrives — at which point the loop returns and main's return is
|
|
/// the clean exit the supervisor reads as `ExitReason.exited`.
|
|
/// `maximum_message` sizes the receive and reply buffers (a service passes its
|
|
/// protocol's message maximum).
|
|
pub fn run(comptime maximum_message: usize, callbacks: Callbacks) void {
|
|
const endpoint = ipc.createIpcEndpoint() orelse return;
|
|
if (callbacks.service) |name| {
|
|
if (!channel.bindPatiently(name, endpoint)) return;
|
|
}
|
|
_ = process.bindSignals(endpoint);
|
|
// Before `init`, so a subscriber that arrives the instant the name is bound
|
|
// is already covered by the sweep that will release it.
|
|
if (callbacks.subscribers) |subscribers| subscribers.watch(endpoint);
|
|
if (callbacks.init) |initialise| {
|
|
if (!initialise(endpoint)) return;
|
|
}
|
|
|
|
var reply_buffer: [maximum_message]u8 = undefined;
|
|
var reply_len: usize = 0;
|
|
var receive: [maximum_message]u8 = undefined;
|
|
while (true) {
|
|
const got = ipc.replyWait(endpoint, reply_buffer[0..reply_len], &receive, null);
|
|
// Whatever capability came with this turn is the turn's, and the turn
|
|
// closes it unless a callback claims it (`ipc.Arrival`). Structural
|
|
// rather than a close per branch, because the branches are exactly what
|
|
// gets forgotten: the ping's `continue` below, and every `on_message`
|
|
// that has no use for a capability — which is every operation but a
|
|
// subscribe. A `defer` in a loop body runs on `continue` and on the
|
|
// `return` that ends the loop, so this covers all four exits.
|
|
var arrived: ipc.Arrival = .{ .handle = got.cap };
|
|
defer arrived.release();
|
|
|
|
if (got.isNotification()) {
|
|
reply_len = 0; // nothing owed for a notification
|
|
if (process.signalsFrom(got.badge)) |signals| {
|
|
if (signals.has(.reload)) {
|
|
if (callbacks.on_reload) |onReload| onReload();
|
|
}
|
|
if (signals.has(.terminate)) {
|
|
if (callbacks.on_terminate) |onTerminate| onTerminate();
|
|
return; // the loop's return IS the clean exit
|
|
}
|
|
continue;
|
|
}
|
|
// A death sweeps the subscriber table first, then still reaches the
|
|
// service: a provider often has its own per-client state to release
|
|
// (open file handles, device tokens, layers) and the same badge is
|
|
// the notice for both.
|
|
if (got.isChildExit()) {
|
|
if (callbacks.subscribers) |subscribers| subscribers.forget(got.childProcessId());
|
|
}
|
|
if (callbacks.on_notification) |onNotification| onNotification(got.badge);
|
|
continue;
|
|
}
|
|
if (got.len == 0) {
|
|
reply_len = 0; // the universal ping: a zero-length reply, from the harness
|
|
continue; // any capability it carried goes out through the turn's `defer`
|
|
}
|
|
reply_len = callbacks.on_message(receive[0..got.len], &reply_buffer, got.senderTaskId(), &arrived);
|
|
}
|
|
}
|