//! Synchronous IPC: the microkernel message backbone. An `Endpoint` is a //! rendezvous point; a client `call`s it (send a message, block for a reply) and //! a server `replyWait`s on it (reply to the last client, then block for the next //! request). This is the substrate the user-space VFS server and device drivers //! are reached through — `open`/`read`/`write` become user-space wrappers that //! marshal a request into a `call`. //! //! Design (see docs/syscall.md, the plan): //! - **Copy method, no bounce buffer.** Payloads are copied frame-to-frame //! through the physmap (`copyAcross`), which is mapped in every address space's //! shared kernel half — so the kernel reads/writes either process's user memory //! without a CR3 switch, and an unmapped page fails the copy instead of #PF-ing. //! - **Reply routing on the server.** IPC is synchronous, so a server owes a reply //! to exactly one client at a time; that caller is held in `Task.ipc_client`. //! - **Sender FIFO on the endpoint.** A blocked caller must be *received without //! becoming runnable*, which a WaitQueue can't express, so callers queue on the //! endpoint's own FIFO (threaded through the otherwise-idle `Task.next`); servers //! waiting for work use a normal WaitQueue. //! //! Trust model: every side of a copy that names a *user* address space goes //! through system/kernel/user-memory.zig — user-half bound, page presence, and //! the leaf permissions ring 3 itself would face (U/S to read, U/S + R/W to //! write). A kernel-side buffer is trusted and translated as-is. An unmapped or //! wrongly-permissioned page fails the operation; it never faults ring 0. const std = @import("std"); const boot_handoff = @import("boot-handoff"); const abi = @import("abi"); const scheduler = @import("scheduler.zig"); const sync = @import("sync.zig"); const heap = @import("heap.zig"); const pmm = @import("pmm.zig"); const user_memory = @import("user-memory.zig"); const page_size = abi.page_size; const Task = scheduler.Task; /// Largest message a single call/reply may carry. Bumping it is trivial; kept /// small because the copy runs under the big kernel lock. pub const MESSAGE_MAXIMUM: usize = 256; pub const maximum_handles = scheduler.ipc_maximum_handles; /// Errno-style failures, returned as `-value` in the system_call result register. pub const EBADF: i64 = 1; // bad handle pub const E2BIG: i64 = 2; // message exceeds MESSAGE_MAXIMUM pub const EFAULT: i64 = 3; // buffer unmapped / out of the user half pub const ENOENT: i64 = 4; // no such name pub const ENOSPC: i64 = 5; // handle table full pub const ENOMEM: i64 = 6; // out of memory pub const EPEER: i64 = 7; // peer died before replying (its process exited or was killed) pub const ESRCH: i64 = 8; // no such process (process_kill of an unknown/dead id) pub const EPERM: i64 = 9; // not permitted (process_kill by anyone but the supervisor) /// A badge with this bit set is an asynchronous notification (e.g. an IRQ), not a /// message from a client — there is no reply owed. The low bits carry the source /// (a GSI for IRQs). Posted by `notifyFromIsr`, from the ISR in system/kernel/irq.zig; /// the message path uses a plain task-id badge with this bit clear. Defined in the /// shared kernel↔user ABI (system/abi.zig), because ring 3 has to test the same bit. pub const notify_badge_bit: u64 = abi.notify_badge_bit; /// Set (with `notify_badge_bit`) when a `replyWait` wake carries a buffered payload /// posted by `send` (`ipc_send`), rather than a bare IRQ/exit notification. Shared with /// ring 3 through the ABI so the receiver can tell "a message arrived" from "the hardware /// spoke". pub const notify_message_bit: u64 = abi.notify_message_bit; /// Largest payload a single `send` (`ipc_send`) may post. Kept small — the payload rides /// inline in every `Endpoint`, and the async path is for events (a `KeyEvent` is 16 /// bytes), not bulk transfer, which is what `call` and future shared pages are for. pub const POST_MAXIMUM: usize = 64; /// Depth of an endpoint's async payload ring. Absorbs a burst while a receiver is briefly /// busy; a full ring drops the *oldest* message (see `send`). const post_capacity: usize = 16; /// One buffered message: a length-prefixed payload plus the sender's task id (delivered /// in the low bits of the receiver's badge). const PostSlot = struct { length: u16 = 0, sender_id: u64 = 0, bytes: [POST_MAXIMUM]u8 = undefined, }; /// End of the user (low) canonical half — user buffers must lie below it. One /// definition, in the module that owns the user-memory contract. const user_half_end: u64 = user_memory.user_half_end; /// A rendezvous endpoint. Allocated from the kernel heap; referenced by handle /// (per process) and by whoever a capability was passed to, counted by `refcount`. pub const Endpoint = struct { refcount: u32 = 1, /// Next in the list of every live endpoint. Endpoints are otherwise reachable /// only through the handle tables that name them, and the death path has to /// find a dying task's endpoints without one — see `live_endpoints`. next_live: ?*Endpoint = null, // The task that created it. When that task dies, the endpoint is marked `dead` so a caller // gets -EPEER instead of blocking forever on a service that will never reply again (V6). owner: u32 = 0, // The *process* that created it — `owner`'s leader, snapshotted at creation so the // answer survives the creating thread. `owner` alone cannot answer "is this mine?" // for a threaded service, and the question has to be answerable after that thread is // gone; see `ownedBy`. owner_leader: u32 = 0, dead: bool = false, // Callers blocked in `call`, awaiting receive, in FIFO order (threaded via // Task.next; each such task is .blocked and in no scheduler queue). sender_head: ?*Task = null, sender_tail: ?*Task = null, // Servers blocked in `replyWait` awaiting a request. receive_wait_queue: scheduler.WaitQueue = .{}, // Pending asynchronous notifications (badges), a small coalescing ring. notify_buffer: [8]u64 = undefined, notify_head: u8 = 0, notify_tail: u8 = 0, // Pending buffered messages (payloads posted by `send`), a small FIFO ring. Unlike // notifications — which are a level and coalesce — these are discrete messages, so a // full ring drops the oldest rather than merging. post_buffer: [post_capacity]PostSlot = undefined, post_head: u16 = 0, post_tail: u16 = 0, }; /// Every live endpoint, singly linked through `next_live`. The list exists for /// exactly one purpose: the death path must mark a dying task's endpoints dead, /// and a handle table only answers the other question (which endpoints does this /// task *hold*). Mutated under the big kernel lock, like every other IPC global. var live_endpoints: ?*Endpoint = null; pub fn createIpcEndpoint() ?*Endpoint { const creator = scheduler.current(); const endpoint = heap.allocator().create(Endpoint) catch return null; endpoint.* = .{ .owner = creator.id, .owner_leader = creator.leader, .next_live = live_endpoints }; live_endpoints = endpoint; return endpoint; } /// Whether `t` may have the kernel post **notifications** — signals, timer /// landings, exit notices, interrupts — into `endpoint`: whether the endpoint is /// its process's own. /// /// Holding a *handle* to an endpoint is not ownership of it. `fs_resolve` /// installs a mounted backend's capability in any caller's table /// (`installHandleDeduped`), and any capability may be passed along a call, so a /// sendable handle means only "you may talk to this". A kernel notification is /// different in kind: it makes the kernel speak *into* someone else's mailbox /// with a badge that receiver cannot distinguish from one it asked for — a /// genuine signal badge, a genuine timer landing. That is how a forged /// `terminate` reached PID 1's shutdown path: the attacker aimed **its own** /// signal delivery at init's endpoint with `signal_bind` and then signalled /// itself, and every bit the kernel stamped was authentic. Refusing the *bind* /// is the only place the distinction still exists. /// /// Threads: ownership is the **process's**, not the task's, so any thread may /// bind an endpoint a sibling created — the same normalization `process_signal` /// and `process_kill` perform when they resolve a member to its leader. The /// creating task's own id is honoured too, which is what keeps kernel tasks /// (leader 0) from being treated as one process. pub fn ownedBy(endpoint: *const Endpoint, t: *const Task) bool { if (endpoint.owner == t.id) return true; return t.leader != 0 and endpoint.owner_leader == t.leader; } /// Unlink a freed endpoint from the live list. O(n) in the number of live /// endpoints, which is tens. fn forgetEndpoint(endpoint: *Endpoint) void { var link = &live_endpoints; while (link.*) |current| { if (current == endpoint) { link.* = current.next_live; return; } link = ¤t.next_live; } } /// A task is dying: kill every endpoint it created. Mark each `dead` (so a later /// `call` returns -EPEER rather than blocking on a reply that will never come) and wake /// anyone already parked sending to it with that error. This is what makes a provider's /// death visible to the clients holding its capability — the naming layer's restart /// story (a client re-resolves on -EPEER) rests on it, as does the VFS router's lazy /// unmount of a backend that died. The endpoint object itself lives until the last /// handle naming it drops. The caller holds the big kernel lock (this runs on the death /// path). See docs/display-v2.md (V6). pub fn killOwnedEndpointsLocked(task_id: u32) void { var current = live_endpoints; while (current) |endpoint| { current = endpoint.next_live; if (endpoint.owner != task_id or endpoint.dead) continue; endpoint.dead = true; while (dequeueSender(endpoint)) |sender| { sender.ipc_status = -EPEER; sender.ipc_received_cap = abi.no_cap; scheduler.readyLocked(sender); } } } /// Drop a reference; free the endpoint when the last one goes. (Frames are leaked /// today like other kernel objects — but the refcount bookkeeping lands now.) pub fn dropRef(endpoint: *Endpoint) void { if (endpoint.refcount > 1) { endpoint.refcount -= 1; } else { forgetEndpoint(endpoint); heap.allocator().destroy(endpoint); } } // --- capability objects: what a handle-table entry can name ------------------ /// The `kind` tag on a `scheduler.HandleObject` — which capability object a handle names. /// Defined here (not in scheduler) because the meaning is the IPC/capability layer's. pub const handle_kind_endpoint: u8 = 0; pub const handle_kind_shared_memory: u8 = 1; pub const handle_kind_dma_region: u8 = 2; /// A DMA buffer's delegation token: names the `pages` contiguous frames at `phys` that /// `dma_alloc` handed its `owner`, and can be passed across processes as a capability so /// the driver that owns a device can bind it into that device's IOMMU domain /// (`dma_bind`). Unlike `SharedMemoryObject` this does NOT own the frames — the /// allocating address space still does, and frees them on `dma_free` or teardown — so /// this is a pure token: `dead` is set when the allocator frees the region, after which /// a stale handle can no longer bind it. `refcount` counts the allocator's registry /// entry plus every outstanding handle; the object is freed when the last drops. pub const DmaRegionObject = struct { refcount: u32 = 1, phys: u64, pages: usize, owner: u32, dead: bool = false, }; /// Create a DMA-region token for `pages` frames at `phys` owned by task `owner`. The /// frames are already allocated and mapped by the caller; this only wraps them for /// delegation. null if the heap is out of room. pub fn createDmaRegion(phys: u64, pages: usize, owner: u32) ?*DmaRegionObject { const region = heap.allocator().create(DmaRegionObject) catch return null; region.* = .{ .phys = phys, .pages = pages, .owner = owner }; return region; } /// Drop a DMA-region reference; free the token when the last (registry + handles) goes. /// Never frees frames — the allocator owns those. pub fn dropDmaRegionReference(region: *DmaRegionObject) void { if (region.refcount > 1) { region.refcount -= 1; } else { heap.allocator().destroy(region); } } /// Resolve a handle to its DMA-region token, or null if out of range, unused, or a /// different kind. pub fn resolveDmaRegion(t: *Task, h: u64) ?*DmaRegionObject { if (h >= t.handles.len) return null; const entry = t.handles[@intCast(h)] orelse return null; if (entry.kind != handle_kind_dma_region) return null; return @ptrCast(@alignCast(entry.ptr)); } /// Install a DMA-region handle in task `t`'s table (the slot owns a reference). pub fn installDmaRegionHandle(t: *Task, region: *DmaRegionObject) i64 { return installEntry(t, .{ .kind = handle_kind_dma_region, .ptr = @ptrCast(region) }); } /// Drop the handle at slot `h` of task `t` (handle_close): release its reference and /// free the slot. Returns 0 or -EBADF. pub fn closeHandle(t: *Task, h: u64) i64 { if (h >= t.handles.len) return -EBADF; const entry = t.handles[@intCast(h)] orelse return -EBADF; dropEntry(entry); t.handles[@intCast(h)] = null; return 0; } /// A page-aligned block of **shared cacheable RAM** (docs/display-v2.md), referenced by /// capability handles across processes and freed when the last one drops. `phys` is its /// contiguous physical base, `pages` its length. A sharer's address-space teardown never /// reclaims these frames (the mapping carries `device_grant`); this object owns them. pub const SharedMemoryObject = struct { refcount: u32 = 1, phys: u64, pages: usize, }; /// Wrap `pages` contiguous frames at `phys` (already allocated + zeroed by the caller) in a /// refcounted shared-memory object, or null if the heap is out of room. pub fn createSharedMemory(phys: u64, pages: usize) ?*SharedMemoryObject { const shared_memory = heap.allocator().create(SharedMemoryObject) catch return null; shared_memory.* = .{ .phys = phys, .pages = pages }; return shared_memory; } /// Take a shared-memory reference — a MAPPING's reference (docs/shared-fate-plan.md /// M3): each address space that maps the region holds one, recorded on the space /// and dropped at its destruction. Caller holds the big kernel lock. pub fn retainSharedMemory(shared_memory: *SharedMemoryObject) void { shared_memory.refcount += 1; } /// Drop a shared-memory reference; when the last one goes — no handles AND no /// mappings left — return its frames to the allocator and free the object. /// (The mappings themselves are torn down with each sharer's address space; /// `device_grant` keeps that sweep from freeing the frames, and the space's /// recorded mapping references keep this drop from freeing them early.) pub fn dropSharedMemoryReference(shared_memory: *SharedMemoryObject) void { if (shared_memory.refcount > 1) { shared_memory.refcount -= 1; } else { for (0..shared_memory.pages) |i| pmm.free(shared_memory.phys + i * page_size); heap.allocator().destroy(shared_memory); } } // --- sender FIFO (endpoint-local, via Task.next) ---------------------------- fn enqueueSender(endpoint: *Endpoint, t: *Task) void { t.ipc_wait_endpoint = @ptrCast(endpoint); // so a kill can unlink a parked caller t.next = null; if (endpoint.sender_tail) |tail| tail.next = t else endpoint.sender_head = t; endpoint.sender_tail = t; } fn dequeueSender(endpoint: *Endpoint) ?*Task { const t = endpoint.sender_head orelse return null; endpoint.sender_head = t.next; if (endpoint.sender_head == null) endpoint.sender_tail = null; t.ipc_wait_endpoint = null; t.next = null; return t; } /// Unlink `t` from the sender FIFO it queues in, if any — the kill path for a /// client parked in `call` that no server has received yet. Without this, a dead /// caller would later be dequeued as a dangling pointer. The endpoint is still /// alive here: `t`'s own handle table holds a reference until closeHandles runs /// (which the kill path does *after* this). Precondition: the big kernel lock is /// held. pub fn abandonSenderLocked(t: *Task) void { const endpoint: *Endpoint = @ptrCast(@alignCast(t.ipc_wait_endpoint orelse return)); t.ipc_wait_endpoint = null; var previous: ?*Task = null; var node = endpoint.sender_head; while (node) |n| : ({ previous = n; node = n.next; }) { if (n != t) continue; if (previous) |p| p.next = t.next else endpoint.sender_head = t.next; if (endpoint.sender_tail == t) endpoint.sender_tail = previous; t.next = null; return; } } // --- cross-address-space copy ---------------------------------------------- /// Copy `len` bytes from `source_va` in address space `source_as` to `destination_va` in /// `destination_as`, walking each side's page tables through the physmap (no CR3 switch). /// `*_as == 0` means the kernel address space (for kernel-task endpoints). User /// buffers must lie in the low half and carry the permission ring 3 would need for /// their side of the copy — readable to send from, writable to receive into. /// Returns false — never #PFs — if any page is unmapped, out of range, or /// wrongly permissioned. Handles page-straddling buffers. /// /// This is the process↔process case, which `user-memory` deliberately does not /// cover (it knows one user address space at a time); both sides resolve through /// `user_memory.resolve`, so the permission rules are the same ones. fn copyAcross(source_as: u64, source_va: u64, destination_as: u64, destination_va: u64, len: usize) bool { if (source_as != 0 and !user_memory.userRangeOk(source_va, len)) return false; if (destination_as != 0 and !user_memory.userRangeOk(destination_va, len)) return false; var off: usize = 0; while (off < len) { const s = user_memory.resolve(source_as, source_va + off, false) orelse return false; const d = user_memory.resolve(destination_as, destination_va + off, true) orelse return false; const s_left = page_size - ((source_va + off) & (page_size - 1)); const d_left = page_size - ((destination_va + off) & (page_size - 1)); const n = @min(@min(s_left, d_left), len - off); const source: [*]const u8 = @ptrFromInt(boot_handoff.physicalToVirtual(s)); const destination: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(d)); @memcpy(destination[0..n], source[0..n]); off += n; } return true; } /// The checked copy-in, re-exported from its home in `user-memory` so the many /// `ipc.copyFromUser` call sites keep reading naturally. New code should reach /// for `user-memory` directly — it is where the write direction lives too. pub const copyFromUser = user_memory.copyFromUser; // --- the two IPC operations ------------------------------------------------- /// Share the capability named by handle `cap` in `from`'s table into `to`'s table, /// bumping the endpoint's refcount (the sender keeps its handle — this is a copy, not /// a move). Returns the handle it landed at in `to` (>= 0), or `-EBADF` if `cap` names /// no live handle, or `-ENOSPC` if `to`'s table is full. Callers only invoke this when /// `cap != no_cap`. Used by both IPC directions to carry an endpoint with a message. fn shareCapability(from: *Task, to: *Task, cap: u64) i64 { if (cap >= from.handles.len) return -EBADF; const entry = from.handles[@intCast(cap)] orelse return -EBADF; // Bump the named object's refcount (a copy, not a move — the sender keeps its handle), // dispatching by kind so both endpoints and shared-memory regions can travel with a // message. switch (entry.kind) { handle_kind_endpoint => { const e: *Endpoint = @ptrCast(@alignCast(entry.ptr)); e.refcount += 1; }, handle_kind_shared_memory => { const s: *SharedMemoryObject = @ptrCast(@alignCast(entry.ptr)); s.refcount += 1; }, handle_kind_dma_region => { const r: *DmaRegionObject = @ptrCast(@alignCast(entry.ptr)); r.refcount += 1; }, else => return -EBADF, } const handle = installEntry(to, entry); if (handle < 0) { dropEntry(entry); // undo the bump; the receiver had no room return -ENOSPC; } return handle; } /// Client side of IPC_Call: send `[message_ptr, message_len)` to `endpoint` and block until a /// server replies into `[reply_ptr, reply_cap)`. Returns the reply length, or a /// negative errno. `send_cap` (a handle, or `no_cap`) is an endpoint transferred to the /// server with the request; `out_received_cap` receives the handle of an endpoint the /// server sent back in its reply, or `no_cap`. Runs as the current task. pub fn call(endpoint: *Endpoint, message_ptr: u64, message_len: u64, reply_ptr: u64, reply_cap: u64, send_cap: u64, out_received_cap: *u64) i64 { if (message_len > MESSAGE_MAXIMUM or reply_cap > MESSAGE_MAXIMUM) return -E2BIG; const flags = sync.enter(); defer sync.leave(flags); if (endpoint.dead) return -EPEER; // the service that owned this endpoint is gone — don't block const me = scheduler.current(); me.ipc_send_ptr = message_ptr; me.ipc_send_len = message_len; me.ipc_reply_ptr = reply_ptr; me.ipc_reply_cap = reply_cap; me.ipc_send_cap = send_cap; me.ipc_received_cap = abi.no_cap; me.ipc_status = 0; enqueueSender(endpoint, me); // join the FIFO, then... scheduler.wakeLocked(&endpoint.receive_wait_queue); // ...wake a waiting server (no-op if none) scheduler.blockCurrentLocked(); // block until the reply readies us again out_received_cap.* = me.ipc_received_cap; // a capability the replier sent back, or no_cap return me.ipc_status; // reply length or -errno, written by the replier } /// Server side of IPC_ReplyWait: deliver `[reply_ptr, reply_len)` to the client /// we currently owe (if any), then receive the next request into /// `[receive_ptr, receive_cap)`, blocking until one arrives. Writes the sender's badge /// to `out_badge` and returns the request length, or a negative errno. A pending /// notification is delivered ahead of client requests (length 0, badge with /// `notify_badge_bit` set, no reply owed). pub fn replyWait(endpoint: *Endpoint, reply_ptr: u64, reply_len: u64, receive_ptr: u64, receive_cap: u64, send_cap: u64, out_badge: *u64, out_received_cap: *u64) i64 { if (reply_len > MESSAGE_MAXIMUM or receive_cap > MESSAGE_MAXIMUM) return -E2BIG; const flags = sync.enter(); defer sync.leave(flags); const me = scheduler.current(); out_received_cap.* = abi.no_cap; // no capability received unless a request delivers one // (1) Reply to the client we're still holding, if any — carrying `send_cap` to it. if (me.ipc_client) |client| { me.ipc_client = null; const n = @min(reply_len, client.ipc_reply_cap); client.ipc_received_cap = abi.no_cap; if (!copyAcross(me.address_space, reply_ptr, client.address_space, client.ipc_reply_ptr, n)) { client.ipc_status = -EFAULT; } else if (send_cap != abi.no_cap) { // Transfer the reply's capability into the client. A failure fails the // client's `call` rather than delivering a reply without its promised cap. const shared = shareCapability(me, client, send_cap); if (shared < 0) { client.ipc_status = shared; // -EBADF (bad handle) or -ENOSPC (client table full) } else { client.ipc_received_cap = @intCast(shared); client.ipc_status = @intCast(n); } } else { client.ipc_status = @intCast(n); } scheduler.readyLocked(client); // its `call` now returns } // (2) Receive the next request (or notification / buffered message), blocking until // one is ready. Bare notifications (IRQ/exit) come first — they're latency-sensitive // and carry no payload — then buffered messages, then synchronous client requests. while (true) { if (popNotify(endpoint)) |badge| { out_badge.* = badge | notify_badge_bit; return 0; // notification: no payload, no reply owed, no cap } if (popPost(endpoint)) |slot| { const n = @min(@as(usize, slot.length), receive_cap); // Copy from the kernel-resident ring slot (source address_space 0) into the receiver. if (!copyAcross(0, @intFromPtr(&slot.bytes), me.address_space, receive_ptr, n)) { continue; // bad receive buffer: drop this message, keep serving } out_badge.* = slot.sender_id | notify_badge_bit | notify_message_bit; return @intCast(n); // async message: payload delivered, no reply owed, no cap } if (dequeueSender(endpoint)) |caller| { const n = @min(caller.ipc_send_len, receive_cap); if (!copyAcross(caller.address_space, caller.ipc_send_ptr, me.address_space, receive_ptr, n)) { caller.ipc_status = -EFAULT; // bad sender buffer: fail it, keep serving scheduler.readyLocked(caller); continue; } // Install the capability the caller sent, if any, into my table. A failure // fails the caller's `call` and does not deliver — no half-delivered cap. if (caller.ipc_send_cap != abi.no_cap) { const shared = shareCapability(caller, me, caller.ipc_send_cap); if (shared < 0) { caller.ipc_status = shared; // -EBADF or -ENOSPC scheduler.readyLocked(caller); continue; } out_received_cap.* = @intCast(shared); } me.ipc_client = caller; // remember who to reply to out_badge.* = caller.id; return @intCast(n); } scheduler.waitLocked(&endpoint.receive_wait_queue); // nothing yet — sleep until woken, then retry } } // --- asynchronous notification (for IRQ-as-message, M10) -------------------- fn popNotify(endpoint: *Endpoint) ?u64 { if (endpoint.notify_head == endpoint.notify_tail) return null; const badge = endpoint.notify_buffer[endpoint.notify_head % endpoint.notify_buffer.len]; endpoint.notify_head +%= 1; return badge; } /// Take the oldest buffered message from the post ring, or null if empty. Returns a /// pointer into the endpoint's own storage — valid until the next `send`/`popPost` under /// the same lock region, which is all the copy-out in `replyWait` needs. fn popPost(endpoint: *Endpoint) ?*const PostSlot { if (endpoint.post_head == endpoint.post_tail) return null; const slot = &endpoint.post_buffer[endpoint.post_head % post_capacity]; endpoint.post_head +%= 1; return slot; } /// Client-free side of async IPC (`ipc_send`): copy `[source_va, len)` from address space /// `source_as` into `endpoint`'s post ring and wake a waiting receiver — **without /// blocking the sender** and with no reply owed. `sender_id` rides along, delivered in the /// low bits of the receiver's badge. Returns 0, or a negative errno (`-E2BIG` if the /// payload exceeds `POST_MAXIMUM`, `-EFAULT` if the source buffer is unmapped / out of the /// user half). A full ring drops the *oldest* message (advancing `post_head`), because a /// buffered message is discrete, not a level: keeping the newest keeps input responsive. /// Precondition: the big kernel lock is held. pub fn sendLocked(endpoint: *Endpoint, source_as: u64, source_va: u64, len: u64, sender_id: u64) i64 { if (len > POST_MAXIMUM) return -E2BIG; // Drop the oldest if the ring is full, so this newest message always lands. if (endpoint.post_tail -% endpoint.post_head >= post_capacity) endpoint.post_head +%= 1; const slot = &endpoint.post_buffer[endpoint.post_tail % post_capacity]; if (!copyFromUser(source_as, source_va, slot.bytes[0..@intCast(len)])) return -EFAULT; slot.length = @intCast(len); slot.sender_id = sender_id; endpoint.post_tail +%= 1; scheduler.wakeLocked(&endpoint.receive_wait_queue); return 0; } /// `sendLocked` wrapped in its own critical section, for the `ipc_send` syscall path. pub fn send(endpoint: *Endpoint, source_as: u64, source_va: u64, len: u64, sender_id: u64) i64 { const flags = sync.enter(); defer sync.leave(flags); return sendLocked(endpoint, source_as, source_va, len, sender_id); } /// Post an asynchronous notification carrying `badge` to `endpoint` and wake a waiting /// receiver. Precondition: the big kernel lock is held. /// /// The lock must already cover whatever produced `endpoint` — an ISR that looked the /// endpoint up in a table and *then* took the lock could be racing a process exit /// that unbinds and frees it in between. See irq.dispatch, which holds one lock /// region across the table read and this call. /// /// A full ring drops the notification. That is the correct semantics, not a /// concession: a notification is a *level* ("this device wants attention"), and the /// driver re-reads device state on wake. It is never a count of events. pub fn notifyLocked(endpoint: *Endpoint, badge: u64) void { if (endpoint.notify_tail -% endpoint.notify_head < endpoint.notify_buffer.len) { endpoint.notify_buffer[endpoint.notify_tail % endpoint.notify_buffer.len] = badge; endpoint.notify_tail +%= 1; } scheduler.wakeLocked(&endpoint.receive_wait_queue); } /// `notifyLocked` as a self-contained ISR critical section, for a caller that holds /// `endpoint` by some means other than a table the lock protects. Releases the lock without /// touching the interrupt flag (the ISR's iretq restores it), like the timer tick. pub fn notifyFromIsr(endpoint: *Endpoint, badge: u64) void { _ = sync.enter(); notifyLocked(endpoint, badge); sync.leaveIsr(); } // --- per-process handle table + name registry ------------------------------- /// Install a capability object (kind + pointer) in task `t`'s handle table; returns the /// small-int handle or -ENOSPC. The caller has already taken/holds the reference the slot /// represents. fn installEntry(t: *Task, entry: scheduler.HandleObject) i64 { for (&t.handles, 0..) |*slot, i| { if (slot.* == null) { slot.* = entry; return @intCast(i); } } return -ENOSPC; } /// Install an endpoint handle. The common case; keeps the endpoint callers' signature. pub fn installHandle(t: *Task, endpoint: *Endpoint) i64 { return installEntry(t, .{ .kind = handle_kind_endpoint, .ptr = @ptrCast(endpoint) }); } /// Install a shared-memory handle. pub fn installSharedMemoryHandle(t: *Task, shared_memory: *SharedMemoryObject) i64 { return installEntry(t, .{ .kind = handle_kind_shared_memory, .ptr = @ptrCast(shared_memory) }); } /// Install an endpoint handle, reusing an existing slot that already names this /// endpoint (no new reference taken in that case). For callers that install per /// operation — fs_resolve — so a 16-slot table can't be exhausted by repeats. /// Any subsystem installing handles per-call should come through here. pub fn installHandleDeduped(t: *Task, endpoint: *Endpoint) i64 { for (t.handles, 0..) |slot, i| { const entry = slot orelse continue; if (entry.kind == handle_kind_endpoint and entry.ptr == @as(*anyopaque, @ptrCast(endpoint))) return @intCast(i); } const h = installHandle(t, endpoint); if (h >= 0) endpoint.refcount += 1; // the table entry owns a reference return h; } /// Resolve a handle to its endpoint, or null if out of range, unused, or a different kind /// (e.g. a shared-memory handle used where an endpoint is expected). pub fn resolveHandle(t: *Task, h: u64) ?*Endpoint { if (h >= t.handles.len) return null; const entry = t.handles[@intCast(h)] orelse return null; if (entry.kind != handle_kind_endpoint) return null; return @ptrCast(@alignCast(entry.ptr)); } /// Resolve a handle to its shared-memory object, or null if out of range, unused, or not /// a shared-memory handle. pub fn resolveSharedMemory(t: *Task, h: u64) ?*SharedMemoryObject { if (h >= t.handles.len) return null; const entry = t.handles[@intCast(h)] orelse return null; if (entry.kind != handle_kind_shared_memory) return null; return @ptrCast(@alignCast(entry.ptr)); } /// Drop every capability reference an exiting task holds, dispatching by kind so a dead /// task's endpoints *and* shared-memory regions are released correctly. Called from the /// scheduler exit path. pub fn closeHandles(t: *Task) void { for (&t.handles) |*slot| { if (slot.*) |entry| { dropEntry(entry); slot.* = null; } } } /// Drop the reference a handle-table entry represents, by kind. fn dropEntry(entry: scheduler.HandleObject) void { switch (entry.kind) { handle_kind_endpoint => dropRef(@ptrCast(@alignCast(entry.ptr))), handle_kind_shared_memory => dropSharedMemoryReference(@ptrCast(@alignCast(entry.ptr))), handle_kind_dma_region => dropDmaRegionReference(@ptrCast(@alignCast(entry.ptr))), else => {}, } } // The flat `ServiceId` registry lived here — a 16-slot table any process could // write, indexed by a compile-time enum. Naming is user-space's job now: init // serves `/protocol` and decides who may claim a name // (docs/os-development/protocol-namespace.md). The kernel keeps only what is // genuinely kernel work — moving capabilities and telling clients their provider // died (`killOwnedEndpointsLocked`).