From 12f5bdefd87f575a3e24e86edf1bdd97eafb649c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 23:43:39 +0000 Subject: [PATCH 1/5] native: re-export at Kore a4ab7234, where a stoppable wait meets its wake ## Why kore-ffi's cancellable waits slice their parks, because no wake names a thread. Kore `a4ab7234` adds the wait and the wake that remove the slice. They arrive here through the exporter rather than as edits to the snapshot. ## What - `native/` is re-exported byte for byte at `a4ab7234e2fe60b43a1df42f12be8f636255fc7a` with `tools/sync-native.py`, one commit past MicroPerceptron/kore#1494's branch head (`76fddb9f`). Two of the six commits since `0018956a` reach the closure, which keeps its 26 packages and 382 files: - `kore-rt`: `futex::wait_unless`, a wait another thread can end; `futex::wake_thread`, the wake that ends it; and `futex::current_thread`, now public. The thread controller adds `Controller::slice`, `Controller::wakes_threads`, and a public `UNREACHED_SLICE`. - The services' `boot.toml` digests, #1494's reseal over the parking controller. - No exported manifest changed, so `native/Cargo.lock` did not. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ACHC7hXQ3xRtyu6Yt31jXQ --- native/crates/rt/src/futex.rs | 439 +++++++++++++++++++--- native/crates/rt/src/thread_controller.rs | 53 ++- native/provenance.json | 16 +- native/services/block/boot.toml | 4 +- native/services/dagfs/boot.toml | 4 +- native/services/device/boot.toml | 4 +- native/services/mm/boot.toml | 4 +- native/services/swap/boot.toml | 4 +- 8 files changed, 454 insertions(+), 74 deletions(-) diff --git a/native/crates/rt/src/futex.rs b/native/crates/rt/src/futex.rs index bebb188..73d9168 100644 --- a/native/crates/rt/src/futex.rs +++ b/native/crates/rt/src/futex.rs @@ -122,6 +122,30 @@ //! simply that async code uses the async form, and it is a correctness rule //! rather than a stylistic one. //! +//! # Waking a thread out of its wait +//! +//! A futex wakes the threads that wait on a word. Some waits must also end +//! for a reason that is not the word: a C thread's cancellation has to end +//! the `pthread_join` or `kore_futex_wait` it is parked in. [`wait_unless`] +//! is that wait — [`wait`] with a caller condition, `stop`, read *after* the +//! waiter registers, as the word is — and [`wake_thread`] is its wake: a kick +//! into one thread's doorbell rather than to the waiters of one word. +//! +//! The condition's writer stores it (`SeqCst`), then wakes the thread. That +//! is mechanism 2 again, with the condition in the word's place. The writer +//! stores, then reads the waiter's registration — [`wake_thread`] reads it +//! before it kicks or relays, and a hub's sweep reads it again before it +//! kicks — and the waiter registers, then reads the condition, so one of +//! the two sees the other. A writer that misses the registration is one whose +//! store the waiter's read follows: the waiter does not park. A writer that +//! sees it kicks a post that already stands, since the waiter armed before it +//! registered, and that kick resolves the park the waiter is in or about to +//! enter. A wait that can be ended therefore carries no timeout it does not +//! need: nothing re-reads, on a slice, a condition its writer announces. +//! +//! A stoppable wait parks on its doorbell even for a word in a noted window: +//! the kernel's word wait ends only on its word, and a stop arrives as a kick. +//! //! # What a caller must still do //! //! **Re-check in a loop.** A return from [`wait`] is never a promise that the @@ -306,9 +330,9 @@ const NOT_WAITING: usize = 0; /// speed while reporting that it was politely waiting. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum WaitOutcome { - /// The word did not hold `expected` — the caller's condition had already - /// changed, and no park was attempted. The common fast path under - /// contention, and not a failure. + /// The word did not hold `expected`, or a [`wait_unless`] stop held — the + /// caller's condition had already changed, and no park was attempted. + /// The common fast path under contention, and not a failure. Retry, /// The thread parked and was woken. **Says nothing about the word**: it /// may be a matching [`wake`], an unrelated kick on the shared doorbell, @@ -399,7 +423,32 @@ pub(crate) const OWN_KICK: u16 = RELAY + 1; /// `std`'s locks will be built from, the same disposition /// [`block_on`](crate::block::block_on) takes. pub fn wait(word: &AtomicU32, expected: u32, timeout: Option) -> WaitOutcome { - wait_inner(word, expected, timeout, true, LOCK_SPIN) + wait_inner(word, expected, timeout, true, LOCK_SPIN, None) +} + +/// [`wait`] that another thread can also end by publishing `stop` and then +/// waking **this thread** ([`wake_thread`]) — the wait a cancellation point +/// is, whose request is a flag rather than a word. +/// +/// `stop` is read after this waiter registers, as the word is, and the park +/// follows only if the word still holds `expected` and `stop` is still +/// `false`; otherwise this returns [`Retry`](WaitOutcome::Retry), having +/// parked nothing. The writer of the condition stores it with `SeqCst` and +/// then calls [`wake_thread`] with this thread's band, so neither can miss +/// the other (module docs, *Waking a thread out of its wait*): no timeout is +/// needed to notice a stop. +/// +/// The park is always the doorbell's, even for a word in a noted window — +/// the kernel's word wait cannot be ended by a kick. Everything [`wait`] +/// says about spurious wakes holds: re-check the word *and* `stop` after any +/// return. Not from inside a future, for [`wait`]'s reasons. +pub fn wait_unless( + word: &AtomicU32, + expected: u32, + timeout: Option, + stop: impl Fn() -> bool, +) -> WaitOutcome { + wait_inner(word, expected, timeout, true, LOCK_SPIN, Some(&stop)) } /// [`wait`] with the **barrier's** longer pre-park monitored budget. @@ -422,7 +471,7 @@ pub(crate) fn wait_barrier( expected: u32, timeout: Option, ) -> WaitOutcome { - wait_inner(word, expected, timeout, true, BARRIER_SPIN) + wait_inner(word, expected, timeout, true, BARRIER_SPIN, None) } /// [`wait_barrier`] with **no** jitter-window spin: straight to the park. @@ -432,7 +481,7 @@ pub(crate) fn wait_barrier( /// Nothing co-resident is about to store the word, so the spin would only /// occupy the core; counted like the barrier wait it stands in for. pub(crate) fn wait_idle(word: &AtomicU32, expected: u32, timeout: Option) -> WaitOutcome { - wait_inner(word, expected, timeout, true, NO_SPIN) + wait_inner(word, expected, timeout, true, NO_SPIN, None) } /// [`wait`] without the [`stats`] accounting — for the star's hub, @@ -443,7 +492,7 @@ pub(crate) fn wait_quiet( expected: u32, timeout: Option, ) -> WaitOutcome { - wait_inner(word, expected, timeout, false, LOCK_SPIN) + wait_inner(word, expected, timeout, false, LOCK_SPIN, None) } fn wait_inner( @@ -452,6 +501,7 @@ fn wait_inner( timeout: Option, counted: bool, spin: SpinBudget, + stop: Option<&dyn Fn() -> bool>, ) -> WaitOutcome { let Some(me) = current_thread() else { return WaitOutcome::Unsupported; @@ -463,35 +513,19 @@ fn wait_inner( // named to the kernel by that authority and parks on the word itself // (the kernel address-wait); every other word keeps the doorbell park. // Decided once, before the registry entry, because the entry records - // the decision for wakers. - let route = crate::cap::mapped::locate(word as *const AtomicU32 as *const u8); - - // ── 1 · Arm ───────────────────────────────────────────────────────── - // The standing post must exist before this thread is discoverable as a - // waiter, or a notice sent in the window between registering and parking - // would find no post and be dropped (`WouldBlock`, on the floor). Arming - // first makes "too early" indistinguishable from "on time". A word park - // needs no post: the mapping is its wait authority, which is what lets a - // doorbell-less worker wait here at all. - if route.is_none() && !imp::arm() { - return WaitOutcome::Unsupported; - } - - // ── 2 · Register, then check ──────────────────────────────────────── - // This store must be visible before the load below, and `SeqCst` on both - // is what forbids the store-load reordering that would let this thread - // and a waker miss each other (module docs, mechanism 2). - let slot = match route { - Some(_) => claim_wait_on_word(me, addr), - None => claim_wait(me, addr), + // the decision for wakers. A wait with a `stop` keeps the doorbell + // whatever the word: its stop arrives as a kick, which a word park + // never sees (`wait_unless`). + let route = match stop { + Some(_) => None, + None => crate::cap::mapped::locate(word as *const AtomicU32 as *const u8), }; - let Some(slot) = slot else { - return WaitOutcome::Unsupported; + + // ── 1, 2 · Arm, register, then check ──────────────────────────────── + let slot = match stand(me, word, expected, route.is_some(), stop, imp::arm) { + Ok(slot) => slot, + Err(outcome) => return outcome, }; - let _slot = WaitSlot { thread: me, slot }; - if word.load(Ordering::SeqCst) != expected { - return WaitOutcome::Retry; - } // ── 3 · Spin on the line, briefly ─────────────────────────────────── if let Some(end) = spin_briefly(word, expected, spin) { @@ -499,7 +533,7 @@ fn wait_inner( } // ── 4 · Park ──────────────────────────────────────────────────────── - // `_slot` releases on the way out: a waiter that is awake must not stay + // `slot` releases on the way out: a waiter that is awake must not stay // findable, or the next `wake` spends its kick on nobody and reports // having woken someone. if counted { @@ -515,7 +549,7 @@ fn wait_inner( // stored — SeqCst on both sides, one of us sees the other), // then arm the doorbell. Without the demotion a word-mode // waiter parked on its doorbell is unreachable by any waker. - demote_to_bell(me, slot, addr) + demote_to_bell(me, slot.slot, addr) && word.load(Ordering::SeqCst) == expected && imp::arm() }) @@ -532,6 +566,57 @@ fn wait_inner( outcome } +/// Steps 1 and 2 of every wait — **arm, register, then check** — with the +/// arm injected, so the host suite holds the order to account against the +/// registry the target uses. +/// +/// `Ok` is a waiter that must park: registered on `word` (in word mode when +/// `on_word`), its post standing unless it parks on the word, and the word +/// still `expected` and `stop` still `false`, both read after the +/// registration. `Err` is what to return instead, having parked nothing; the +/// registration is released with it. +fn stand( + me: usize, + word: &AtomicU32, + expected: u32, + on_word: bool, + stop: Option<&dyn Fn() -> bool>, + arm: impl FnOnce() -> bool, +) -> Result { + let addr = address_of(word); + + // ── 1 · Arm ───────────────────────────────────────────────────────── + // The standing post must exist before this thread is discoverable as a + // waiter, or a notice sent in the window between registering and parking + // would find no post and be dropped (`WouldBlock`, on the floor). Arming + // first makes "too early" indistinguishable from "on time". A word park + // needs no post: the mapping is its wait authority, which is what lets a + // doorbell-less worker wait here at all. + if !on_word && !arm() { + return Err(WaitOutcome::Unsupported); + } + + // ── 2 · Register, then check ──────────────────────────────────────── + // This store must be visible before the loads below, and `SeqCst` on all + // of them is what forbids the store-load reordering that would let this + // thread and a waker miss each other (module docs, mechanism 2). `stop` + // is the same argument with the caller's condition in the word's place: + // its writer stores, then reads this registration before it kicks. + let slot = if on_word { + claim_wait_on_word(me, addr) + } else { + claim_wait(me, addr) + }; + let Some(slot) = slot else { + return Err(WaitOutcome::Unsupported); + }; + let slot = WaitSlot { thread: me, slot }; + if word.load(Ordering::SeqCst) != expected || stop.is_some_and(|stop| stop()) { + return Err(WaitOutcome::Retry); + } + Ok(slot) +} + /// Cumulative process-wide wait/wake accounting — the terminal's window /// into behavior no serial can show (`res/SPMD.md` §11's doctrine). /// Relaxed counters: telemetry, never synchronization. `rode` is the @@ -952,6 +1037,52 @@ pub fn wake_all(word: &AtomicU32) -> usize { woken } +/// Wake **thread `band`** rather than the waiters of a word: kick its +/// doorbell, so a park it is in or about to enter returns. The wake a +/// [`wait_unless`] waiter's `stop` is announced by. +/// +/// Store the condition first, with `SeqCst`. This reads the target's +/// registration before it kicks, so a target that registers after that read +/// reads the condition after it (module docs, *Waking a thread out of its +/// wait*); a target with no doorbell waiter registered is running, and is not +/// kicked. A thread waking itself kicks nothing either: it is not parked. +/// +/// The kick goes directly when this thread holds a send into the target's +/// doorbell, and otherwise through this thread's relay: the hub it adopted +/// (the [`thread_controller`](crate::thread_controller) of the threads it +/// founds), whose sweep kicks every thread with a doorbell waiter +/// registered, or the star's hub. `true` when a kick or a relay went out. +/// `false` when no kick was needed or this thread has no route to the +/// target, which a caller that must reach it checks before it publishes. +pub fn wake_thread(band: usize) -> bool { + wake_thread_with(current_thread(), band, imp::kick, relay_missed) +} + +/// [`wake_thread`]'s selection, with the kick and the relay injected so the +/// host suite can hold it to account. +fn wake_thread_with( + me: Option, + band: usize, + kick: impl FnOnce(usize) -> bool, + relay: impl FnOnce() -> bool, +) -> bool { + if band >= MAX_THREADS as usize || me == Some(band) || !waits_on_bell(band) { + return false; + } + if kick(band) { + STAT_KICKS.fetch_add(1, Ordering::Relaxed); + return true; + } + // Refused: no send into that doorbell, or its post already spent — the + // relay covers both, as it does for `wake`. + STAT_MISSED.fetch_add(1, Ordering::Relaxed); + if relay() { + STAT_RELAYS.fetch_add(1, Ordering::Relaxed); + return true; + } + false +} + /// A **relay hub that is also a waiter**: the thread that founds a group of /// threads and holds a send into each of their doorbells — the /// [`thread_controller`](crate::thread_controller) — parked on its @@ -1020,6 +1151,13 @@ pub(crate) fn adopt_hub(hub: &'static Hub) -> bool { imp::adopt_hub(hub) } +/// Whether the hub this thread relays through is `hub`: a wake it cannot +/// deliver itself goes there, and from there to every thread `hub`'s thread +/// can kick. +pub(crate) fn relays_through(hub: &Hub) -> bool { + imp::hub().is_some_and(|adopted| core::ptr::eq(adopted, hub)) +} + /// Record that this thread holds `send`, a send into band `peer`'s /// doorbell its founding did not stamp — the channel a controller mints for /// each child it founds, or a starter for the controller it founds. From @@ -1080,7 +1218,6 @@ fn sweep_with(me: usize, mut kick: impl FnMut(usize) -> bool) -> usize { } /// Whether `thread` has any waiter a kick reaches — a doorbell-mode entry. -#[cfg_attr(not(target_os = "none"), allow(dead_code))] fn waits_on_bell(thread: usize) -> bool { WAITING_ON[thread].iter().any(|cell| { let entry = cell.load(Ordering::SeqCst); @@ -1114,16 +1251,10 @@ pub(crate) fn wait_or_end( return WaitOutcome::Unsupported; }; // Arm, register, check — `wait`'s order and for its reasons. - if !imp::arm() { - return WaitOutcome::Unsupported; - } - let Some(slot) = claim_wait(me, address_of(word)) else { - return WaitOutcome::Unsupported; + let _slot = match stand(me, word, expected, false, None, imp::arm) { + Ok(slot) => slot, + Err(outcome) => return outcome, }; - let _slot = WaitSlot { thread: me, slot }; - if word.load(Ordering::SeqCst) != expected { - return WaitOutcome::Retry; - } imp::park_or_end(timeout, task, report) } @@ -1158,7 +1289,8 @@ fn address_of(word: &AtomicU32) -> usize { word as *const AtomicU32 as usize } -/// This thread's band index — its identity in [`WAITING_ON`]. +/// This thread's band index — its identity in the wait registry +/// (`WAITING_ON`). /// /// The allocator's shard index, deliberately: it is already stamped at /// anchor, already proved injective across live threads (the `MAX_THREADS <= @@ -1168,7 +1300,12 @@ fn address_of(word: &AtomicU32) -> usize { /// The bound is re-checked rather than assumed: an out-of-band index would /// index the registry out of range, and a thread that cannot name itself must /// refuse to wait rather than borrow a neighbour's entry. -pub(crate) fn current_thread() -> Option { +/// +/// Also the name [`wake_thread`] reaches this thread by, which a thread +/// records where its wakers can read it. An identity, not an authority: a +/// waker still needs a send into the band's doorbell, or a relay that holds +/// one. `None` off metal, and on a thread outside the band. +pub fn current_thread() -> Option { let index = imp::thread_index()?; (index < MAX_THREADS as usize).then_some(index) } @@ -2014,9 +2151,15 @@ mod tests { claim_wait(9, address_of(&a)).expect("a doorbell waiter"); claim_wait_on_word(10, address_of(&b)).expect("a word waiter"); let mut kicked = std::vec::Vec::new(); + // The sweep scans every row, and tests in parallel register rows of + // their own: only this test's rows are delivered and recorded, and + // another's is a refused kick, which is not counted. let n = sweep_with(8, |thread| { - kicked.push(thread); - true + let ours = (8..=11).contains(&thread); + if ours { + kicked.push(thread); + } + ours }); assert_eq!(kicked, [9], "the sweep kicked a thread no kick can wake"); assert_eq!(n, 1); @@ -2027,6 +2170,202 @@ mod tests { } } + #[test] + fn a_stop_is_read_only_once_the_waiter_is_registered() { + // The order `wait_unless` rests on: arm, register, and only then read + // the stop. Its writer stores the stop and then reads the registry, so + // it misses this waiter only when this waiter's read follows its store. + // Each step asserts that the one before it already happened. + use core::cell::Cell; + const ROW: usize = 12; + reset_thread(ROW); + let word = AtomicU32::new(5); + let addr = address_of(&word); + let armed = Cell::new(false); + let arm = || { + assert!( + !thread_waits_on(ROW, addr), + "registered before the post stood" + ); + armed.set(true); + true + }; + let holds = Cell::new(true); + let read = Cell::new(0); + let stop = || { + assert!( + armed.get() && thread_waits_on(ROW, addr), + "the stop was read before the waiter registered" + ); + read.set(read.get() + 1); + holds.get() + }; + + // A stop that holds: nothing parks, and the registration goes too. + let out = stand(ROW, &word, 5, false, Some(&stop), arm); + assert_eq!(out.err(), Some(WaitOutcome::Retry)); + assert_eq!(read.get(), 1); + assert!(!thread_waits_on(ROW, addr), "a stopped waiter stayed found"); + + // One that does not: the waiter parks registered, and leaves with + // its slot. + holds.set(false); + armed.set(false); + let slot = stand(ROW, &word, 5, false, Some(&stop), arm) + .expect("a waiter whose stop does not hold parks"); + assert_eq!(read.get(), 2); + assert!(thread_waits_on(ROW, addr), "a parking waiter is not found"); + drop(slot); + assert!(!thread_waits_on(ROW, addr)); + + // An arm the kernel refuses registers nothing and reads nothing. + let out = stand(ROW, &word, 5, false, Some(&stop), || false); + assert_eq!(out.err(), Some(WaitOutcome::Unsupported)); + assert_eq!(read.get(), 2); + assert!(!thread_waits_on(ROW, addr)); + reset_thread(ROW); + } + + #[test] + fn a_stop_published_before_its_wake_is_never_missed() { + // Every point at which the stop's writer — store, then + // `wake_thread` — can run against a waiter's arm, registration, stop + // read and park. The waiter either reads the stop and parks nothing, + // or parks on a post the writer's kick consumed, which the kernel + // resolves at once. The simulated post is the kernel's rule: arming + // stands it, a kick consumes a standing one and drops otherwise, and + // a park on a consumed post returns. + use core::sync::atomic::AtomicBool; + const ROW: usize = 13; + #[derive(Clone, Copy, Debug, PartialEq)] + enum At { + BeforeArm, + BeforeRegistering, + BeforeStopRead, + // Just after the waiter read `false`: the window a stop read + // before the registration would leave open. + AfterStopRead, + BeforePark, + } + for at in [ + At::BeforeArm, + At::BeforeRegistering, + At::BeforeStopRead, + At::AfterStopRead, + At::BeforePark, + ] { + reset_thread(ROW); + let word = AtomicU32::new(0); + let stopped = AtomicBool::new(false); + let post = AtomicBool::new(false); + let writer = || { + stopped.store(true, Ordering::SeqCst); + let _ = wake_thread_with( + None, + ROW, + |band| { + assert_eq!(band, ROW); + post.compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + }, + || false, + ); + }; + if at == At::BeforeArm { + writer(); + } + let arm = || { + post.store(true, Ordering::SeqCst); + if at == At::BeforeRegistering { + writer(); + } + true + }; + let stop = || { + if at == At::BeforeStopRead { + writer(); + } + let stop = stopped.load(Ordering::SeqCst); + if at == At::AfterStopRead { + writer(); + } + stop + }; + match stand(ROW, &word, 0, false, Some(&stop), arm) { + Err(WaitOutcome::Retry) => { + assert!( + !matches!(at, At::AfterStopRead | At::BeforePark), + "{at:?}: a stop not yet written was read" + ); + } + Ok(slot) => { + assert!( + matches!(at, At::AfterStopRead | At::BeforePark), + "{at:?}: the stop went unread" + ); + if at == At::BeforePark { + writer(); + } + assert!( + !post.load(Ordering::SeqCst), + "the waiter parks on a post its stop's wake never consumed" + ); + drop(slot); + } + Err(other) => panic!("{at:?}: {other:?}"), + } + } + reset_thread(ROW); + } + + #[test] + fn a_thread_wake_kicks_only_a_registered_waiter_and_relays_a_refusal() { + const ROW: usize = 14; + reset_thread(ROW); + let word = AtomicU32::new(0); + let never = |_: usize| -> bool { panic!("kicked a thread no kick can wake") }; + let no_relay = || -> bool { panic!("relayed a wake that needed none") }; + // A running thread (nothing registered), a band past the registry, + // and the caller itself are not kicked. + let past = MAX_THREADS as usize; + assert!(!wake_thread_with(None, ROW, never, no_relay)); + assert!(!wake_thread_with(None, past, never, no_relay)); + let slot = claim_wait(ROW, address_of(&word)).expect("a free wait slot"); + let waiter = WaitSlot { thread: ROW, slot }; + assert!(!wake_thread_with(Some(ROW), ROW, never, no_relay)); + // A registered doorbell waiter: kicked directly, relayed when the + // kick refuses, and `false` only when neither went out. + assert!(wake_thread_with( + None, + ROW, + |band| { + assert_eq!(band, ROW); + true + }, + no_relay + )); + assert!(wake_thread_with(None, ROW, |_| false, || true)); + assert!(!wake_thread_with(None, ROW, |_| false, || false)); + drop(waiter); + // A word-parked waiter is the kernel's address-wait's to end. + let slot = claim_wait_on_word(ROW, address_of(&word)).expect("a free wait slot"); + let waiter = WaitSlot { thread: ROW, slot }; + assert!(!wake_thread_with(None, ROW, never, no_relay)); + drop(waiter); + reset_thread(ROW); + } + + #[test] + fn off_metal_a_stoppable_wait_refuses_before_reading_its_stop() { + reset_thread(7); + let word = AtomicU32::new(1); + assert_eq!( + wait_unless(&word, 1, None, || panic!("read a stop with no identity")), + WaitOutcome::Unsupported + ); + assert!(!wake_thread(0), "off metal no kick reaches a thread"); + } + #[test] fn the_own_kick_is_named_past_every_peer_and_the_relay() { // No peer arithmetic (`PEER_BASE + band`) and no relay lookup may diff --git a/native/crates/rt/src/thread_controller.rs b/native/crates/rt/src/thread_controller.rs index 55891d7..9c6b7d3 100644 --- a/native/crates/rt/src/thread_controller.rs +++ b/native/crates/rt/src/thread_controller.rs @@ -36,9 +36,12 @@ //! A starter with no channel space founds a controller without a doorbell: //! neither it nor its children can park, and each of their waits takes the //! deadline park every thread can ([`pace::cede`]). A client the controller -//! cannot kick waits in bounded slices ([`UNREACHED_SLICE`]). A thread that +//! cannot kick waits in bounded slices ([`Controller::slice`]). A thread that //! cannot wake a parking controller is refused a create, which would -//! otherwise wait on a request nothing announced. +//! otherwise wait on a request nothing announced; one that cannot relay +//! through it cannot announce a request to its threads either +//! ([`Controller::wakes_threads`]), so a parked child is never left waiting +//! on a request nothing woke it for. //! //! [`thread::exit`]: crate::thread::exit //! [`pace::cede`]: crate::pace::cede @@ -82,7 +85,8 @@ const POLLING: u32 = 3; /// at before the controller parked (`pace::CEDE_NS`): a client the wake /// fabric cannot reach is no worse off than it was. Shorter would wake an /// unreachable joiner more often for nothing; longer would delay it. -const UNREACHED_SLICE: Duration = Duration::from_nanos(crate::pace::CEDE_NS); +/// [`Controller::slice`] says which waits take it. +pub const UNREACHED_SLICE: Duration = Duration::from_nanos(crate::pace::CEDE_NS); /// How long the controller rides its doorbell after a reap the kernel did /// not complete — a refused lifecycle observation or reclamation, which no @@ -330,6 +334,35 @@ impl Controller { .is_some_and(futex::reaches) } + /// Whether a wake this thread sends reaches the threads the controller + /// founded, so a request it announces to them by a wake — a cancellation, + /// stored and then [`futex::wake_thread`]ed — ends any park they are in. + /// + /// True when the controller parks and this thread both holds a send into + /// it and relays through its hub — the starter, or a thread it created — + /// since every thread the controller can kick is then one relay away. True + /// as well when it does not park, since neither do its threads, which need + /// no wake. False otherwise, and before the controller starts: a caller + /// refuses such a request, rather than leave it behind a park nothing ends. + pub fn wakes_threads(&self) -> bool { + match self.mode() { + None => false, + Some(PARKING) => self.wakes_controller() && futex::relays_through(&self.hub), + Some(_) => true, + } + } + + /// How long one wait of the calling thread may park: unbounded (`None`) + /// when the controller can kick it — it holds a send into the thread's + /// doorbell, so every wake it sends, and every wake relayed through it, + /// reaches the thread — and [`UNREACHED_SLICE`] when nothing would end + /// that park early. + pub fn slice(&self) -> Option { + let reached = futex::current_thread() + .is_some_and(|band| self.reach.load(Ordering::SeqCst) & (1 << band) != 0); + (!reached).then_some(UNREACHED_SLICE) + } + /// Park until `record`'s state satisfies `done`, and return that state. fn settle(&self, record: &Record, done: impl Fn(u64) -> bool) -> u64 { loop { @@ -341,9 +374,7 @@ impl Controller { if done(state) { return state; } - let reached = futex::current_thread() - .is_some_and(|band| self.reach.load(Ordering::SeqCst) & (1 << band) != 0); - park(&record.event, seen, (!reached).then_some(UNREACHED_SLICE)); + park(&record.event, seen, self.slice()); } } @@ -1061,6 +1092,16 @@ mod tests { assert_eq!(controller.create(|_| 0, 0), Err(Error::Unavailable)); } #[test] + fn an_unstarted_controller_wakes_no_thread_and_bounds_every_wait() { + // Nothing relays through a controller that was never founded, so no + // request to its threads is announced by a wake; and a thread it + // cannot kick — off metal, every thread — parks one slice at a time. + let controller = Controller::new(); + assert!(!controller.wakes_threads()); + assert_eq!(controller.slice(), Some(UNREACHED_SLICE)); + assert_eq!(controller.started.load(Ordering::Acquire), IDLE); + } + #[test] fn records_own_their_interference_span() { assert_eq!(core::mem::align_of::(), 128); assert_eq!(core::mem::size_of::(), 128); diff --git a/native/provenance.json b/native/provenance.json index b582189..4145765 100644 --- a/native/provenance.json +++ b/native/provenance.json @@ -1,6 +1,6 @@ { "repository": "https://github.com/MicroPerceptron/kore", - "revision": "0018956a3aca19dc6acea8d28fb8ec9fadefcb09", + "revision": "a4ab7234e2fe60b43a1df42f12be8f636255fc7a", "packages": { "crates/abi": "kore-abi", "crates/app": "kore-app", @@ -207,7 +207,7 @@ "crates/rt/src/executor/time.rs": "62f9ca9e5e17b395ec2b19a52fc6ed1bb53fd4acc3554cfca81eaf20e48fc272", "crates/rt/src/exit.rs": "3bf7e8ce6dac63b4ce365bdf2e743a9fd171851f1c5e40f8afbb4548877cef1b", "crates/rt/src/fiber_stack.rs": "48bf4fedba07ea2826615368e22882301ae4e0bbce0d893d0d7754d86bd02ce6", - "crates/rt/src/futex.rs": "df5622a1bd811d7d97ec20dc44a568dba3a2c995254fc30405a60d41371e7390", + "crates/rt/src/futex.rs": "a3947ed8217f8c006ec8ba69d8d13a02dc01fd2afc8d1fe80af07342db246cd0", "crates/rt/src/heap/mod.rs": "a9c39d3de56187a14d4633a229cbbb6e7ddb2c438d6f9f154b3b2551031b9f81", "crates/rt/src/heap/pool.rs": "67d42c6ffc7c7e176d21e97dd3179a3e4ff331f46dd457ed4d29914b1a404de8", "crates/rt/src/heap/shard.rs": "d97f91b3ecc71e3ec56fde0017f9d98cb99c5fbeeffe6ff79ddd153f5c6690fa", @@ -245,7 +245,7 @@ "crates/rt/src/task_watch.rs": "96b646f8785e427640519191cbf1f294c6908e5062a8082db271364048aa5b9a", "crates/rt/src/termination.rs": "920685ebb2071db58ae667e57bd02a6127166e8224ed13ec81216465ca315a55", "crates/rt/src/thread.rs": "3b6b1087a4c20bb592331ae4c48692d8f93056afc98be9708ae32236f494980e", - "crates/rt/src/thread_controller.rs": "5595f7f37bb77edf8a7fa498fcb5c65395f6227e24a651b62bb13db393ebfc30", + "crates/rt/src/thread_controller.rs": "936764b06b0fd09555cd1b574b2bf283262efc95d7aee18b989fc88c6b634649", "crates/rt/src/tls.rs": "85bd95bafb3de73d249d7a2eb8779b5afc929077e27b4baa5f7c7428063063ab", "crates/rt/src/transport/mod.rs": "d5af890c800dd53abdbea8329383068cf43f0bf294ccb6b46dd7d8592ae5f321", "crates/rt/src/upcall.rs": "c0cbd2a59479dfba006850bb1bc5dd4f20f59317d178af776ba678f0e6404f02", @@ -295,13 +295,13 @@ "drivers/nvme/src/regs.rs": "d9410b5c64bb70f99e042f7209119c0f0fdb57f859c6a1c07eba90199f1e98c1", "drivers/nvme/src/swap.rs": "f5e1099b83b2cdeb9d288f4d337f1a234d9dfd16c31e9d9303c10f6f14972141", "services/block/Cargo.toml": "2729b36369486a6f5c1c29200eaa4603b25c26352f06f52301b70d9d57260e43", - "services/block/boot.toml": "b39c8ff266d2110b706d887770421a732b47ab99b1e84c805a840e439c71511e", + "services/block/boot.toml": "dafeae8271530e4e21885595b7697e278143197c10cb5a617e92e805fa4a696b", "services/block/src/client.rs": "6aed79a404620f89ffa13078f615e846fdf6f3db5629c5cee5fbccbeaa7636cf", "services/block/src/lib.rs": "cf45960460cf6c9dba72f7a6727b51497275c686166a90be531d675097c275f1", "services/block/src/service.rs": "191e9d887d3f3267d6e2aa96e81996f01321bb2febb85e68581159f6e098b4b5", "services/block/src/wire.rs": "a9d64aef9ec3d58d37ca55b841933fc51b76ab94e18b245278b4519e96c65939", "services/dagfs/Cargo.toml": "d229a126616b1d7638f0f5a6edb4ac37d9d116f32afaa1897ffe484fbb286e5e", - "services/dagfs/boot.toml": "cfd9a5931d61fd8c4f0240b312d434002d9e33f85da3e9c607dbb26eb7d0c240", + "services/dagfs/boot.toml": "6a5c74554acdeb02588105ba7f56802b7694d6da7d127362970ad183e1e7333c", "services/dagfs/examples/fsdemo.rs": "1666a14ef62fd4fc46f7f9efa2eaa5021ed79823f273a77f53758307f884e7d0", "services/dagfs/examples/fswire.rs": "994b97d699796b317741e2c87cbe812cc1776ddcd01173323e72a5f74c8ac267", "services/dagfs/examples/model_volume.rs": "0e3623e962da02b2126062a19d198b9e0a8f6e91896fd9a6f7d13f6f30ddc5f1", @@ -343,7 +343,7 @@ "services/dagfs/tests/provider.rs": "ba4070af34ae034c109e391e97bcc4755c6bd70b8e2fb229aaf9d747fef71e2d", "services/dagfs/tests/query_experiment.rs": "6fe147810474b6754ef31ad0fb95493e0b3c0886fbbafd78deb3729d410e8155", "services/device/Cargo.toml": "8f35c9ddd701b58a538f151e95e93ce618f0b346960491adad6d843287165d1c", - "services/device/boot.toml": "44fc18ed82e7b3e07bcdc0203953dc6ae9f849b762d27b9735f384ab2bf96319", + "services/device/boot.toml": "510b8448648e4431695de9de4ddb78ac33298a0d078185aa9b0fd1d21e829bb5", "services/device/src/client.rs": "71660deb4a9f51edb7a72fbb3edac94170bf4cccf9ce182cbd478ee3daf406eb", "services/device/src/lib.rs": "215c7c7e81d5d51664ac77b72b2edb0bab739d5a54b30bc9ad08f9dd3a3cfea4", "services/device/src/service/mod.rs": "6038ba0b9375e3c6bbb25c49bf0849a5878a402f4a3a691dc9df1494849d252b", @@ -385,7 +385,7 @@ "services/extract/vendor/jsmn/jsmn.h": "babfcb7413354a43dcd539c600a1e00bd2d1579ad0a6f6c7d8b81dccedb16cd0", "services/extract/vendor/jsmn/kore_jsmn.c": "a666060a7af03c819d1faaccc0d6faa19b76bcfe78e4ef61aeab273ad052c95c", "services/mm/Cargo.toml": "25809b6a03916d96b8de64a5988d005c71c49314fafbe0d920c3ec06f2dc5951", - "services/mm/boot.toml": "461ca1cf03071b333bc2d7a11ba3be273fa4bff923e712b194e263f53c915539", + "services/mm/boot.toml": "afb0cdf8a64635992180edf814518c525651949aa4afc559c89d81090c14ab18", "services/mm/src/client.rs": "c6dcc0dc3ed59e0a4cf7532fd306af4c1dc4d4e5ad1edd1d86887def791c5aa6", "services/mm/src/heap.rs": "e55210f3609f2e3e90f30d5497355ddab5801acc60ccbe3f8acbbffff71dbfde", "services/mm/src/lib.rs": "d3820740666ed993e547ee1b3b579231bf439a813d9766860e3b1b57fe815bd6", @@ -397,7 +397,7 @@ "services/output/src/service/mod.rs": "d80c3beb50979b3e1e500abdfa3c0b9d38e354c24e2e34aea443732abbead6f1", "services/output/src/wire.rs": "3752e994eb18714518facae1b78a9ee2b34f99aad8687653f0b89dc8fdeca621", "services/swap/Cargo.toml": "9c0af01403a6ad5a7f4ff69a02b18c8b1b2624a684814d6dc1ac07968c5bbe53", - "services/swap/boot.toml": "1638a88d04cfec0a5a16c7e61f7a43e2284991e63ca253b84ca38a84dc7d2c23", + "services/swap/boot.toml": "323531c62ec0b063cea376e941218719352c87c1b91e579e560fe55dc1bcbc42", "services/swap/src/client.rs": "04f735d059b491765d5ba63a4f5f640332735bf336e62dcbae86c17647583e2c", "services/swap/src/extent.rs": "d6705f77951df1c42cda51fc4526ba6262cab6af4a0545405ce06d2d2e1db7b4", "services/swap/src/lib.rs": "883684df54a87ae66aa7895ea19a872cabc8ab7fd4aded4ea65b7e3dd51fe0cf", diff --git a/native/services/block/boot.toml b/native/services/block/boot.toml index 1b02999..2c43aaf 100644 --- a/native/services/block/boot.toml +++ b/native/services/block/boot.toml @@ -12,5 +12,5 @@ chain = true endowment = "kore_svc_block::BlockEndowment" protocol = "kore_svc_block::BlockService" endows = ["DMA_POOL@1"] -digest_x86_64 = "blake3:c55dc025414bbcd3a511a3072095c412aedfee76d69f22bedeb51859bc30ec6b" -digest_aarch64 = "blake3:807f59729471b0b98d09a5b8c8e1d1931986475835c116aaee136fbe5843fdc4" +digest_x86_64 = "blake3:a22f3a5b08f35a278ebf663d557e0b1ec490c8e732cdee0fa68151e349ddbc57" +digest_aarch64 = "blake3:2855f4168dcfe5b78dca4c508bc6f3120af84412c74498f2cf6475b01508c8e2" diff --git a/native/services/dagfs/boot.toml b/native/services/dagfs/boot.toml index 2ba36ca..a153d4e 100644 --- a/native/services/dagfs/boot.toml +++ b/native/services/dagfs/boot.toml @@ -47,5 +47,5 @@ exec_client = true # `dagfs_heap_supply_holds_every_session` holds this row to it; the rest is # room for the proofs open handles carry, allocated per open at their depth. heap_supply_bytes = 0x00D0_0000 -digest_x86_64 = "blake3:88925d674abc8a20dd326c89e6ef95e68f0ac98624520a8cc29213d402cee329" -digest_aarch64 = "blake3:6ce4008a3a308dc0fce3bde1d0c4ee18e650b321c6c683854c5bd55e3606e0da" +digest_x86_64 = "blake3:453a43f78c11f4fedf1715a141e4627a4d9858ab975fc8ad03c5d44b8814b7d9" +digest_aarch64 = "blake3:9521f0dae0fede5b0061ca144be8fdb421798faf17fd6cb5376acacb5741777e" diff --git a/native/services/device/boot.toml b/native/services/device/boot.toml index 8e20284..0d1f546 100644 --- a/native/services/device/boot.toml +++ b/native/services/device/boot.toml @@ -47,5 +47,5 @@ protocol = "kore_svc_device::DeviceBroker" # row's own claimant band. instances = 8 endows = ["ROOT_COMPLEX@0", "PLATFORM_BUS@0", "FACE_SUPPLY@0"] -digest_x86_64 = "blake3:7915aee8c49e13907542815f1f4627ab41f656933a8067e5b247f03b55b0bae2" # res/x86_64/; make boot-digests -digest_aarch64 = "blake3:a30ae8ec2936590328807fc1522c906d6ab5afb473437547821899f5df7db4e8" # res/aarch64/; make boot-digests-aarch64 +digest_x86_64 = "blake3:78d5b044c7b3ad3467484859549f8404cffc61ece27dd793cdd21c8c2c0a930a" # res/x86_64/; make boot-digests +digest_aarch64 = "blake3:7006c3d2336f8e984751fce7983384668fb0201e565dea03c8cbfd4c43992b3f" # res/aarch64/; make boot-digests-aarch64 diff --git a/native/services/mm/boot.toml b/native/services/mm/boot.toml index 51a97ec..0071ac5 100644 --- a/native/services/mm/boot.toml +++ b/native/services/mm/boot.toml @@ -54,5 +54,5 @@ supply_quota_bytes = 27917287424 # 26 GiB endows = ["MEM_SUPPLY@0", "MEM_REGION@0", "SYSTEM_OBSERVE@0"] # review mirror; asserted === PROFILE.roles shell_client = true exec_client = true -digest_x86_64 = "blake3:f46d34fd54f1190518157c9b634cf9b5191ec174271dc5f8007bbf8bad824657" # res/x86_64/; make boot-digests -digest_aarch64 = "blake3:b871832aa6a5ca4b4d6fee6beed6e0b1625d96bc019c996da1b02eb28c0bec58" # res/aarch64/; make boot-digests-aarch64 +digest_x86_64 = "blake3:d1ce3128c73f02293e323f87d79850b8558dfd82d882b65169767febb7baf6e4" # res/x86_64/; make boot-digests +digest_aarch64 = "blake3:a03d13d5caf95b15a82902ed087c816af46f74aa305ca47d01681b039e9d55fe" # res/aarch64/; make boot-digests-aarch64 diff --git a/native/services/swap/boot.toml b/native/services/swap/boot.toml index 77f463e..80722a4 100644 --- a/native/services/swap/boot.toml +++ b/native/services/swap/boot.toml @@ -9,5 +9,5 @@ image = "kore-svc-swap" endowment = "kore_svc_swap::ZramEndowment" protocol = "kore_svc_swap::ZramSwap" endows = ["MEM_SUPPLY@0"] -digest_x86_64 = "blake3:9a81892b71b57bc7cea7b2797749293c18ea2fd9cf231bf11d5742d9602c7e7e" # res/x86_64/; make boot-digests -digest_aarch64 = "blake3:7cd848899239fa79c8038af480783b1ccf1772488d2b31d72656c6bd587ef06d" # res/aarch64/; make boot-digests-aarch64 +digest_x86_64 = "blake3:ffa0c5ffe03770b1810bf23a9c546d1e92ce897708af3a5d0fc928c6dace51cd" # res/x86_64/; make boot-digests +digest_aarch64 = "blake3:f71c92926d0ca9faf5a1704659f96931bbf4aed4e1546bb6b1433d7d6d7057e4" # res/aarch64/; make boot-digests-aarch64 From a3f602297ae7946dcef93bd3f5c3136fa77475b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 23:44:22 +0000 Subject: [PATCH 2/5] ffi: a cancellation wakes its target, and no cancellable wait polls A cancellation request was a flag that no wake carried, so a cancellable `kore_thread_join` or managed-thread `kore_futex_wait` parked one millisecond at a time to notice it. An idle cancellable waiter woke a thousand times a second. - A managed thread records its band when it starts. `kore_thread_cancel` stores the request (`SeqCst`, was `Release`) and then wakes that band with `futex::wake_thread`. The wake is relayed through the controller, the one owner of a send into each managed thread's doorbell. - `kore_thread_join` and a managed thread's `kore_futex_wait` wait with `futex::wait_unless`. It reads the request again once the waiter is registered, and the wake reads that registration, so neither misses the other. Neither wait sets a timeout of its own. `CANCEL_SLICE_NS` is gone. - Only a thread the controller cannot wake parks in bounded slices, now the controller's own rule and cadence (`Controller::slice`, `UNREACHED_SLICE`). That also bounds a joiner the controller cannot reach, which before parked with no bound and no wake. - A canceller whose wake cannot reach the controller's threads is refused with EAGAIN and records nothing, as create refuses it: a thread that neither started the controller nor was created through it. A request a parked target never saw would be a silent loss. A host test drives the refusal. - `kore.h` is regenerated with doc changes only, so the ABI is unchanged. `docs/runtime.md` says how a cancellation reaches a parked thread, and drops two statements the parking controller had made stale: a 1 ms controller scan, and controller threads without doorbells. `docs/provenance.md` records the export. The thread guest fixture's comment describes both ways a cancellable worker waits. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ACHC7hXQ3xRtyu6Yt31jXQ --- crates/ffi/src/futex.rs | 52 ++++++++++++++------ crates/ffi/src/thread.rs | 87 +++++++++++++++++++++++++++++---- crates/ffi/src/thread_local.rs | 18 ++++--- docs/provenance.md | 39 +++++++++++++++ docs/runtime.md | 40 ++++++++++----- extern/c/include/kore.h | 10 +++- tests/guest/thread_lifecycle.rs | 10 ++-- 7 files changed, 207 insertions(+), 49 deletions(-) diff --git a/crates/ffi/src/futex.rs b/crates/ffi/src/futex.rs index 6e04454..43910dd 100644 --- a/crates/ffi/src/futex.rs +++ b/crates/ffi/src/futex.rs @@ -61,7 +61,8 @@ pub extern "C" fn kore_futex_available() -> u32 { /// all waits/wakes. All concurrent accesses must be compatible 32-bit atomic /// operations (C atomic builtins or Zig atomics); never mix ordinary accesses /// or differently sized atomics. Managed-thread waits are cancellation points: -/// they may exit without stack unwinding, as specified by kore_thread_testcancel. +/// they may exit without stack unwinding, as specified by kore_thread_testcancel, +/// and a cancellation requested while the thread waits wakes it to act. /// Publish the condition with a sequentially consistent atomic store BEFORE waking. /// No address-space sharing is implied. #[unsafe(no_mangle)] @@ -77,32 +78,51 @@ pub unsafe extern "C" fn kore_futex_wait( if !crate::thread_local::cancellable() { return futex::wait(word, expected, timeout).into(); } - // A bounded native park makes cancellation observable even without a peer - // kick capability. Recompute against one deadline; slices never restart it. unsafe { crate::kore_thread_testcancel() }; - let start = match kore::time::uptime() { - Ok(time) => time.as_nanos(), - Err(_) => return KoreFutexOutcome::Unsupported, + let outcome = match crate::thread::slice() { + // A cancellation's wake reaches this thread (kore_thread_cancel), and + // the wait re-reads the request once it is registered: it parks until + // a wake, the caller's deadline or that request, and never polls. + None => futex::wait_unless(word, expected, timeout, crate::thread_local::cancel_pending), + Some(slice) => unsafe { sliced(word, expected, timeout_ns, slice) }, }; - let deadline = timeout.map(|_| start.saturating_add(timeout_ns)); + unsafe { crate::kore_thread_testcancel() }; + outcome.into() +} + +/// A cancellable wait on a thread the controller cannot wake: no wake would +/// announce a cancellation, so it parks one `slice` at a time and acts on a +/// request between slices, against one deadline that slices never restart. +/// # Safety +/// A managed thread's cancellation point, as kore_futex_wait. +unsafe fn sliced( + word: &AtomicU32, + expected: u32, + timeout_ns: u64, + slice: Duration, +) -> futex::WaitOutcome { + let Ok(start) = kore::time::uptime() else { + return futex::WaitOutcome::Unsupported; + }; + let deadline = + (timeout_ns != KORE_FUTEX_INFINITE).then(|| start.as_nanos().saturating_add(timeout_ns)); + let slice = u64::try_from(slice.as_nanos()).unwrap_or(u64::MAX); loop { unsafe { crate::kore_thread_testcancel() }; - let now = match kore::time::uptime() { - Ok(time) => time.as_nanos(), - Err(_) => return KoreFutexOutcome::Unsupported, + let Ok(now) = kore::time::uptime() else { + return futex::WaitOutcome::Unsupported; }; - let slice = crate::thread_local::CANCEL_SLICE_NS; - let slice = deadline.map_or(slice, |end| end.saturating_sub(now).min(slice)); - let outcome = futex::wait(word, expected, Some(Duration::from_nanos(slice))); + let bound = deadline.map_or(slice, |end| end.saturating_sub(now.as_nanos()).min(slice)); + let outcome = futex::wait(word, expected, Some(Duration::from_nanos(bound))); unsafe { crate::kore_thread_testcancel() }; if outcome != futex::WaitOutcome::TimedOut { - return outcome.into(); + return outcome; } if let Some(end) = deadline { match kore::time::uptime() { Ok(time) if time.as_nanos() < end => {} - Ok(_) => return KoreFutexOutcome::TimedOut, - Err(_) => return KoreFutexOutcome::Unsupported, + Ok(_) => return futex::WaitOutcome::TimedOut, + Err(_) => return futex::WaitOutcome::Unsupported, } } } diff --git a/crates/ffi/src/thread.rs b/crates/ffi/src/thread.rs index 9e05906..5ddf34c 100644 --- a/crates/ffi/src/thread.rs +++ b/crates/ffi/src/thread.rs @@ -45,6 +45,10 @@ pub(crate) struct Control { outcome: AtomicU32, retired: AtomicU32, pub(crate) cancel: AtomicBool, + /// The native band the thread runs on, plus one; zero until it starts + /// (`futex::current_thread`). A canceller reads it after storing + /// `cancel`, to wake the thread out of a wait at a cancellation point. + band: AtomicU32, } impl Control { pub(crate) fn new() -> Self { @@ -53,13 +57,32 @@ impl Control { outcome: AtomicU32::new(0), retired: AtomicU32::new(0), cancel: AtomicBool::new(false), + band: AtomicU32::new(0), } } + /// Record the calling thread's band, before its callback can reach a + /// cancellation point. `SeqCst`, against the canceller's store of `cancel` + /// and then this load: a canceller that reads zero stored `cancel` before + /// the thread's first wait reads it. + fn start(&self) { + let band = kore::futex::current_thread().map_or(0, |band| band as u32 + 1); + self.band.store(band, Ordering::SeqCst); + } + fn band(&self) -> Option { + let band = self.band.load(Ordering::SeqCst); + band.checked_sub(1).map(|band| band as usize) + } pub(crate) fn finish(&self, value: *mut c_void, outcome: u32) { self.result.store(value, Ordering::Relaxed); self.outcome.store(outcome, Ordering::Release); } } +/// How long one wait of the calling thread may park ([`Controller::slice`]): +/// unbounded where the controller can wake it, and therefore where a +/// cancellation's wake reaches it; one bounded slice where nothing could. +pub(crate) fn slice() -> Option { + THREADS.slice() +} fn status(error: Error) -> i32 { match error { Error::Unavailable | Error::Full => KORE_THREAD_EAGAIN, @@ -86,6 +109,7 @@ fn invoke(argument: usize) -> usize { call_context(context) } fn call_context(context: &StartContext) -> usize { + context.control.start(); // SAFETY: forwarded foreign callback and argument lifetime contract. crate::thread_local::enter(&context.control); let value = unsafe { (context.call)(context.argument) }; @@ -208,12 +232,17 @@ pub unsafe extern "C" fn kore_thread_join( if control.retired.load(Ordering::SeqCst) != 0 { break; } - // A cancellation request is a flag, not a wake: a joiner that can be - // cancelled parks one slice at a time, as a cancellable futex wait - // does. Any other joiner parks until the controller retires the target. - let slice = crate::thread_local::cancellable() - .then(|| core::time::Duration::from_nanos(crate::thread_local::CANCEL_SLICE_NS)); - if kore::futex::wait(&control.retired, 0, slice) == kore::futex::WaitOutcome::Unsupported + // Park until the controller wakes `retired`, or this joiner's own + // cancellation is requested: its canceller stores the request, then + // wakes this thread (kore_thread_cancel), and the wait re-reads the + // request once it is registered. Only a joiner the controller cannot + // wake parks in bounded slices; nothing else would end its park. + if kore::futex::wait_unless( + &control.retired, + 0, + slice(), + crate::thread_local::cancel_pending, + ) == kore::futex::WaitOutcome::Unsupported // A native worker with no doorbell takes the deadline park, which // needs no wake capability at all. && !kore::runtime::pace::cede() @@ -270,7 +299,12 @@ pub unsafe extern "C" fn kore_thread_detach(thread: *mut KoreThread) -> i32 { } /// Request deferred cancellation. The target acts at testcancel, join or foreign -/// futex wait. A disabled target retains the request. No asynchronous kill occurs. +/// futex wait; a target waiting at one is woken to act. A disabled target +/// retains the request. No asynchronous kill occurs. EAGAIN when no wake the +/// calling thread sends can reach the target, because it neither started the +/// controller nor was created through it (create refuses it too). Then no +/// request is recorded, since a target parked at a cancellation point would +/// never see it. /// # Safety /// thread is a live handle. Borrowing cancel calls may run concurrently with one /// another, but must finish before a consuming join/detach can free the handle. @@ -279,12 +313,31 @@ pub unsafe extern "C" fn kore_thread_cancel(thread: *mut KoreThread) -> i32 { let Some(ticket) = NonNull::new(thread.cast::()) else { return KORE_THREAD_EINVAL; }; - let control = unsafe { &ticket.as_ref().control }; + // SAFETY: the caller supplies a live handle, borrowed for this call. + request_cancel(unsafe { &ticket.as_ref().control }) +} + +/// kore_thread_cancel's request against the target's shared control. +fn request_cancel(control: &Control) -> i32 { if control.outcome.load(Ordering::Acquire) != 0 || control.retired.load(Ordering::Acquire) != 0 { return KORE_THREAD_ESRCH; } - control.cancel.store(true, Ordering::Release); + // The request is a flag its wake announces: only the controller holds a + // send into a managed thread's doorbell, so the wake is relayed through + // it, and a thread that cannot relay through it is refused here rather + // than leaving a request behind a park nothing ends. + if !THREADS.wakes_threads() { + return KORE_THREAD_EAGAIN; + } + // Store, then wake (`futex::wake_thread`'s order). The wake reads the + // target's registration, and the target's wait reads the request once it + // is registered, so one of the two sees the other. A target that has not + // yet recorded its band reads the request at its first cancellation point. + control.cancel.store(true, Ordering::SeqCst); + if let Some(band) = control.band() { + let _ = kore::futex::wake_thread(band); + } KORE_THREAD_OK } @@ -313,6 +366,22 @@ mod tests { assert_eq!(Arc::strong_count(&control), 1); } #[test] + fn a_cancel_no_wake_can_announce_is_refused_and_never_recorded() { + // Off metal the controller never starts, so no thread's wake reaches + // its threads: the request is refused, and nothing a parked target + // would wait on unwoken is left behind. An ended target is gone first. + let control = Control::new(); + control.start(); + assert_eq!(control.band(), None, "off metal a thread has no band"); + assert_eq!(request_cancel(&control), KORE_THREAD_EAGAIN); + assert!(!control.cancel.load(Ordering::SeqCst)); + control.finish(ptr::null_mut(), 1); + assert_eq!(request_cancel(&control), KORE_THREAD_ESRCH); + assert!(!control.cancel.load(Ordering::SeqCst)); + // Every wait off metal is one the controller cannot end early. + assert!(slice().is_some()); + } + #[test] fn host_refusal_preserves_output_and_never_calls_foreign_code() { unsafe extern "C" fn never(_: *mut c_void) -> *mut c_void { panic!("host ran native callback") diff --git a/crates/ffi/src/thread_local.rs b/crates/ffi/src/thread_local.rs index 64bd577..21484c3 100644 --- a/crates/ffi/src/thread_local.rs +++ b/crates/ffi/src/thread_local.rs @@ -15,12 +15,6 @@ pub const KORE_THREAD_CANCEL_ENABLE: i32 = 0; pub const KORE_THREAD_CANCEL_DISABLE: i32 = 1; pub const KORE_THREAD_CANCEL_DEFERRED: i32 = 0; pub const KORE_THREAD_CANCEL_ASYNCHRONOUS: i32 = 1; -/// How long one park lasts at a cancellation point that waits: a request is -/// a flag no wake carries, so a cancellable wait re-checks it this often. -/// One millisecond is the cadence the native runtime parks an unreachable -/// waiter at (`kore-rt`'s `pace::CEDE_NS`): a cancellation is acted on within -/// one, and an idle cancellable waiter wakes no more often than that. -pub(crate) const CANCEL_SLICE_NS: u64 = 1_000_000; pub type KoreThreadKey = u64; pub type KoreThreadDestructor = Option; @@ -85,6 +79,16 @@ pub(crate) fn leave() { pub(crate) fn cancellable() -> bool { !CURRENT.get().is_null() && !DISABLED.get() && !FINISHING.get() } +/// An enabled cancellation request is pending for this managed thread: what +/// testcancel acts on, and the stop a cancellation point's wait re-reads once +/// it is registered (`futex::wait_unless`). `SeqCst`, as the canceller's store +/// is: the canceller stores, then reads this thread's registration before it +/// wakes it, so one of the two sees the other. +pub(crate) fn cancel_pending() -> bool { + // SAFETY: CURRENT is non-null while cancellable, and points at this + // managed thread's Control until `leave`. + cancellable() && unsafe { &*CURRENT.get() }.cancel.load(Ordering::SeqCst) +} fn active() -> bool { !CURRENT.get().is_null() } @@ -368,7 +372,7 @@ pub unsafe extern "C" fn kore_thread_setcanceltype(kind: i32, old_type: *mut i32 /// never cross live Rust RAII guards or executor callbacks at a cancellation point. #[unsafe(no_mangle)] pub unsafe extern "C" fn kore_thread_testcancel() { - if cancellable() && unsafe { &*CURRENT.get() }.cancel.load(Ordering::Acquire) { + if cancel_pending() { unsafe { kore_thread_exit(kore_thread_canceled()); } diff --git a/docs/provenance.md b/docs/provenance.md index 737162c..f410305 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -351,3 +351,42 @@ C program (`docs/languages.md`). It is the first libkore package a C program on Kore links without a Rust host. Its shared object is a distribution's one-line `cdylib` over it. `tools/check-program.py` links that object for the three Kore ISAs and checks its shape and its exact exported ABI. + +## A cancellation is a wake + +The export records Kore `a4ab7234e2fe60b43a1df42f12be8f636255fc7a`, one commit +past MicroPerceptron/kore#1494's branch head `76fddb9f`. Two of the six +commits since `0018956a` reach the closure, which keeps its 26 packages and +382 files: + +- `kore-rt`'s futex gains a wait another thread can end, and the wake that + ends it (Kore `a4ab7234`). `futex::wait_unless` is `wait` with a caller + condition read once the waiter is registered, as the word is, and always + parks on the doorbell. `futex::wake_thread` kicks one thread's doorbell + rather than a word's waiters, directly or relayed through the caller's hub, + and reads the target's registration before it kicks. A condition stored + with `SeqCst` before that wake is therefore never missed, and no slice is + needed to notice it. Arm, register and check are one function that every + doorbell wait shares, and the host suite drives it with the arm injected to + pin that order. `futex::current_thread` is public: the band a thread + records for its wakers. +- Its thread controller exposes the rule its own joins used: + `Controller::slice` is unbounded where the controller can wake the calling + thread, and `UNREACHED_SLICE` where nothing could. `Controller::wakes_threads` + says whether a wake the calling thread sends reaches the threads it founded. +- The services' `boot.toml` digests are #1494's reseal over the parking + controller (`76fddb9f`). + +No exported manifest changed, so `native/Cargo.lock` did not. + +`kore-ffi`'s cancellation points no longer poll. A managed thread records its +band when it starts; `kore_thread_cancel` stores the request and wakes that +band; `kore_thread_join` and a managed thread's `kore_futex_wait` wait with +`wait_unless`, with no timeout of their own. An idle cancellable waiter now +sleeps until something happens rather than waking every millisecond. Only a +thread the controller cannot wake keeps the bounded slice, now the +controller's `UNREACHED_SLICE`. A canceller whose wake cannot reach the +controller's threads is refused with EAGAIN, as create refuses it, and records +nothing. A host test drives `request_cancel` to that refusal. The metal +evidence is Kore's `keytest-tcc-x86`, whose libkore tour cancels a thread +parked in `pthread_join` and one parked in `kore_futex_wait` with no timeout. diff --git a/docs/runtime.md b/docs/runtime.md index fd1d88b..ab87ad8 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -131,10 +131,13 @@ names on Kore targets; it never interposes host pthreads. The first create starts one process-lifetime controller. It owns all native join capabilities; portable handles contain identifiers, never arena slot authority. -Calls use bounded shared records and paced parks, including a 1 ms controller -scan while idle. They must run on native threads, not executor callbacks that -owe other futures progress. Capacity depends on native bands and the controller's -capability arena; ordinary slot exhaustion is refused before spending a band. +Calls use bounded shared records and futex parks: the controller parks until a +request, a thread's end or a relayed wake moves its word, and a waiting caller +parks until the controller wakes it. Only a thread the controller cannot wake +parks in bounded 1 ms slices. Calls must run on native threads, not executor +callbacks that owe other futures progress. Capacity depends on native bands and +the controller's capability arena; ordinary slot exhaustion is refused before +spending a band. Create uses the installed process allocator for its handle and callback context. On error the output is unchanged and the argument is not retained. On success, @@ -193,8 +196,20 @@ this does not implement dynamic DSO unloading or Rust/C++ stack unwinding. `kore_thread_testcancel`, foreign join and foreign futex waits. Disabled targets retain pending requests. `setcancelstate` enables/disables delivery; `setcanceltype` accepts deferred and refuses asynchronous with ENOTSUP, preserving -output on failure. Cancellation-aware waits use bounded 1 ms native parks and -one monotonic deadline. A canceled joiner leaves its target joinable. +output on failure. A canceled joiner leaves its target joinable. + +A request wakes a target parked at a cancellation point; no cancellation-aware +wait polls for one. The canceller stores the request, then wakes the target's +thread (`futex::wake_thread`), relayed through the controller, the one owner of +a send into each managed thread's doorbell. The target's wait reads the request +again once it is registered (`futex::wait_unless`), and the wake reads that +registration, so one of the two always sees the other. Only a target the +controller cannot wake waits in bounded 1 ms slices against one monotonic +deadline. A thread whose wake cannot reach the controller's threads, because +it neither started the controller nor was created through it, is refused with +EAGAIN and records no request, as create refuses it: Kore's capability model +grants no wake without a send, and a request a parked target never sees would +be a silent loss. On explicit `kore_thread_exit` or cancellation, caller-owned cleanup nodes run LIFO, then compiler TLS destructors, then pthread key destructors. Normal callback @@ -237,11 +252,14 @@ there is no second wait registry. Use `wait_async` in Rust executor tasks. C exposes `kore_futex_available`, `kore_futex_wait`, `kore_futex_wake` and `kore_futex_wake_all` in ``; Zig exposes `kore.futex.*`. The foreign wait blocks a native thread and must not run inside an executor callback. -For managed threads it is also a deferred cancellation point. Independent -controller-created threads are not endowed with doorbells, so their native futex -waits report `Unsupported`; this adapter does not create a peer wake mesh. -Foreign join uses the controller's deadline-park helper and works on these -threads. Cancellation is checked even when a foreign futex wait is unsupported. +For managed threads it is also a deferred cancellation point, which a +cancellation request wakes. Controller-created threads are founded with +doorbells when the thread that started the controller holds a channel-space +grant, and park; a wake one of them cannot deliver directly is relayed through +the controller, which holds a send into each. Without the grant they have none: +their native futex waits report `Unsupported`, and foreign join takes the +deadline park, which needs no wake. Cancellation is checked even when a foreign +futex wait is unsupported. Timeouts are relative nanoseconds; zero is a deadline of zero and `KORE_FUTEX_INFINITE` requests an unbounded wait. Retry, spurious wake, timeout, unsupported context and invalid pointer shape remain distinct outcomes. diff --git a/extern/c/include/kore.h b/extern/c/include/kore.h index a9089a0..ba27a6d 100644 --- a/extern/c/include/kore.h +++ b/extern/c/include/kore.h @@ -690,7 +690,8 @@ uint32_t kore_futex_available(void); * all waits/wakes. All concurrent accesses must be compatible 32-bit atomic * operations (C atomic builtins or Zig atomics); never mix ordinary accesses * or differently sized atomics. Managed-thread waits are cancellation points: - * they may exit without stack unwinding, as specified by kore_thread_testcancel. + * they may exit without stack unwinding, as specified by kore_thread_testcancel, + * and a cancellation requested while the thread waits wakes it to act. * Publish the condition with a sequentially consistent atomic store BEFORE waking. * No address-space sharing is implied. */ @@ -935,7 +936,12 @@ int32_t kore_thread_detach(struct KoreThread *thread); /** * Request deferred cancellation. The target acts at testcancel, join or foreign - * futex wait. A disabled target retains the request. No asynchronous kill occurs. + * futex wait; a target waiting at one is woken to act. A disabled target + * retains the request. No asynchronous kill occurs. EAGAIN when no wake the + * calling thread sends can reach the target, because it neither started the + * controller nor was created through it (create refuses it too). Then no + * request is recorded, since a target parked at a cancellation point would + * never see it. * # Safety * thread is a live handle. Borrowing cancel calls may run concurrently with one * another, but must finish before a consuming join/detach can free the handle. diff --git a/tests/guest/thread_lifecycle.rs b/tests/guest/thread_lifecycle.rs index caad0e8..e447dfd 100644 --- a/tests/guest/thread_lifecycle.rs +++ b/tests/guest/thread_lifecycle.rs @@ -70,10 +70,12 @@ unsafe extern "C" fn worker(mode: *mut c_void) -> *mut c_void { 3 => { READY.store(1, Ordering::Release); loop { - // Every foreign wait entry checks cancellation. Independent - // controller workers have no doorbell: preserve Unsupported - // and park via the native deadline helper between retries. - // This witnesses cancellation delivery, not futex wake routing. + // Nothing wakes WORD: only the cancellation ends this wait. + // A worker with a doorbell parks until the request's wake, + // relayed through the controller, arrives. One founded + // without (a starter with no channel space) is refused the + // park: preserve Unsupported and take the native deadline + // park between retries, whose entry checks cancellation. if kore_futex_wait(WORD.as_ptr(), 0, KORE_FUTEX_INFINITE) == KoreFutexOutcome::Unsupported { From c911ef11475280aca8bd935a3d15e70e37987157 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 02:48:35 +0000 Subject: [PATCH 3/5] native: re-export at Kore 40de2582, where a child is reached from its first wait Kore `40de2582` closes a managed thread's founding window. The controller sets a child's reach bit only after `spawn_with_arg` returns, and the child ran its callback at once. A cancellable `kore_futex_wait` the child entered before that bit took `Controller::slice`'s unreached bound, and `sliced` kept it until the wait's first real wake. `child_entry` now settles on its record until its founding is published, which follows the reach bit. A thread the controller founds with a doorbell is therefore reached from its first wait, and only a thread it cannot wake keeps the slice. The export is byte-exact. Two commits since `a4ab7234` reach the closure, which keeps its 26 packages and 382 files: `40de2582`'s `thread_controller.rs`, and the `services/swap/boot.toml` digest of Kore's reseal over the cancellation wake (`bfecfd17`). No manifest changed, so `native/Cargo.lock` did not. `docs/provenance.md` records the export, and `docs/runtime.md` says why a founded thread never takes the slice. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ACHC7hXQ3xRtyu6Yt31jXQ --- docs/provenance.md | 14 +++++ docs/runtime.md | 4 +- native/crates/rt/src/thread_controller.rs | 62 +++++++++++++++++++++-- native/provenance.json | 6 +-- native/services/swap/boot.toml | 4 +- 5 files changed, 79 insertions(+), 11 deletions(-) diff --git a/docs/provenance.md b/docs/provenance.md index f410305..42098d0 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -390,3 +390,17 @@ controller's threads is refused with EAGAIN, as create refuses it, and records nothing. A host test drives `request_cancel` to that refusal. The metal evidence is Kore's `keytest-tcc-x86`, whose libkore tour cancels a thread parked in `pthread_join` and one parked in `kore_futex_wait` with no timeout. + +The export then moved to Kore `40de25825eb1ea7816f7e7df86e116b3ad20d1fc`, +which closes a child's founding window. The controller sets a child's reach +bit only after `spawn_with_arg` returns, and the child ran its callback at +once. Its first wait could therefore start before the controller had +recorded that it can kick the child. A cancellable `kore_futex_wait` in that +window took the unreached slice and kept it until its first real wake. +`child_entry` now settles on its record until the founding is published, +which follows the reach bit, so a thread the controller founds with a +doorbell is reached from its first wait. A host test holds a founding back +and checks that the callback waits for it. The closure keeps its 26 packages +and 382 files. Besides `thread_controller.rs`, it carries the +`services/swap/boot.toml` digest of Kore's reseal over the cancellation wake +(`bfecfd17`). diff --git a/docs/runtime.md b/docs/runtime.md index ab87ad8..0c80f91 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -205,7 +205,9 @@ a send into each managed thread's doorbell. The target's wait reads the request again once it is registered (`futex::wait_unless`), and the wake reads that registration, so one of the two always sees the other. Only a target the controller cannot wake waits in bounded 1 ms slices against one monotonic -deadline. A thread whose wake cannot reach the controller's threads, because +deadline. A thread the controller founds with a doorbell is never one: it +runs its callback only once its founding is published, after the controller +can kick it. A thread whose wake cannot reach the controller's threads, because it neither started the controller nor was created through it, is refused with EAGAIN and records no request, as create refuses it: Kore's capability model grants no wake without a send, and a request a parked target never sees would diff --git a/native/crates/rt/src/thread_controller.rs b/native/crates/rt/src/thread_controller.rs index 9c6b7d3..d26cc9e 100644 --- a/native/crates/rt/src/thread_controller.rs +++ b/native/crates/rt/src/thread_controller.rs @@ -27,6 +27,8 @@ //! the controller mints a doorbell, keeps the send (an adopted peer, withdrawn //! before the child's band can be reused), and founds the child holding it, a //! send into the controller, the starter's kick, the host's wake and the heap. +//! The child runs its callback once its founding is published, which follows +//! the adopted send, so the controller reaches every wait the callback makes. //! Children and the starter relay through the controller's hub, so a wake //! between two threads neither can kick directly still arrives: the controller //! sweeps ([`futex::sweep`]). @@ -140,7 +142,8 @@ fn empty(_: usize) -> usize { struct Record { state: AtomicU64, /// Bumped by the controller after each transition a client can wait - /// for; clients park on it. An event count: the state is the truth. + /// for; clients park on it, the child too until it is founded. An event + /// count: the state is the truth. event: AtomicU32, /// Set by the child's exit hook: it has stopped running its own code /// and is committing its end. @@ -201,7 +204,8 @@ pub struct Controller { #[cfg_attr(not(target_os = "none"), allow(dead_code))] starter: AtomicU32, /// The bands whose doorbells the controller can kick, a bit each: the - /// clients its wakes reach. A child's bit is set once it is founded and + /// clients its wakes reach. A child's bit is set before its founding is + /// published, which the child waits for before its callback runs, and /// cleared before its band can be reused. reach: AtomicU32, records: [Record; COUNT], @@ -356,7 +360,9 @@ impl Controller { /// when the controller can kick it — it holds a send into the thread's /// doorbell, so every wake it sends, and every wake relayed through it, /// reaches the thread — and [`UNREACHED_SLICE`] when nothing would end - /// that park early. + /// that park early. A thread the controller founds with a doorbell is + /// reached before its callback runs, so every wait of that callback is + /// unbounded from the first. pub fn slice(&self) -> Option { let reached = futex::current_thread() .is_some_and(|band| self.reach.load(Ordering::SeqCst) & (1 << band) != 0); @@ -511,7 +517,7 @@ fn pause() { } /// The managed thread's entry: announce its end however it comes, relay -/// through the controller, run the callback. +/// through the controller, wait to be founded, run the callback. fn child_entry(argument: usize) { // SAFETY: controller storage is static; record cannot be reused until join. let record = unsafe { &*(argument as *const Record) }; @@ -528,7 +534,14 @@ fn child_entry(argument: usize) { let _ = record.state.load(Ordering::Acquire); let start = unsafe { *record.start.get() }; // SAFETY: `home` names the static controller whose record this is. - let _ = futex::adopt_hub(&unsafe { &*(start.home as *const Controller) }.hub); + let home = unsafe { &*(start.home as *const Controller) }; + let _ = futex::adopt_hub(&home.hub); + // The callback runs once the founding is published, as `create` returns + // once it is. The controller publishes it only after it can kick this + // thread (`reach`), so no wait in the callback rides the unreached slice + // for want of a bit the controller had yet to set. A child that outran + // its founding parks here as its creator does, until the announcement. + home.settle(record, |state| state & MASK != REQUEST); record .value .store((start.call)(start.argument), Ordering::Release); @@ -1081,6 +1094,45 @@ mod tests { assert_eq!(record.state.load(Ordering::Acquire), 16); } #[test] + fn a_child_runs_its_callback_only_once_its_founding_is_published() { + // The child's half of its founding, with a host thread for the child + // and this one for the controller. The controller sets the child's + // reach bit before it publishes the founding, so a callback that ran + // first could find its own waits unreached, and bounded. + static SEEN: AtomicU64 = AtomicU64::new(u64::MAX); + fn callback(record: usize) -> usize { + // SAFETY: the record is leaked below, so it outlives this child. + let record = unsafe { &*(record as *const Record) }; + SEEN.store(record.state.load(Ordering::Acquire), Ordering::Release); + 7 + } + let controller: &'static Controller = + std::boxed::Box::leak(std::boxed::Box::new(Controller::new())); + let record = &controller.records[0]; + // SAFETY: no child runs yet; this thread is the BUILD claimant. + unsafe { + *record.start.get() = Start { + call: callback, + argument: record as *const Record as usize, + retire: None, + home: controller as *const Controller as usize, + }; + } + record.state.store(REQUEST, Ordering::Release); + std::thread::scope(|scope| { + let child = scope.spawn(|| child_entry(record as *const Record as usize)); + // Every chance for a child that does not wait to run first. + std::thread::sleep(Duration::from_millis(20)); + assert_eq!(record.returned.load(Ordering::Acquire), 0); + record.state.store(RUNNING, Ordering::Release); + record.announce(); + child.join().unwrap(); + }); + assert_eq!(SEEN.load(Ordering::Acquire), RUNNING); + assert_eq!(record.value.load(Ordering::Acquire), 7); + assert_eq!(record.returned.load(Ordering::Acquire), 1); + } + #[test] fn an_unstarted_controller_refuses_and_never_waits() { let controller: &'static Controller = std::boxed::Box::leak(std::boxed::Box::new(Controller::new())); diff --git a/native/provenance.json b/native/provenance.json index 4145765..fd11e4b 100644 --- a/native/provenance.json +++ b/native/provenance.json @@ -1,6 +1,6 @@ { "repository": "https://github.com/MicroPerceptron/kore", - "revision": "a4ab7234e2fe60b43a1df42f12be8f636255fc7a", + "revision": "40de25825eb1ea7816f7e7df86e116b3ad20d1fc", "packages": { "crates/abi": "kore-abi", "crates/app": "kore-app", @@ -245,7 +245,7 @@ "crates/rt/src/task_watch.rs": "96b646f8785e427640519191cbf1f294c6908e5062a8082db271364048aa5b9a", "crates/rt/src/termination.rs": "920685ebb2071db58ae667e57bd02a6127166e8224ed13ec81216465ca315a55", "crates/rt/src/thread.rs": "3b6b1087a4c20bb592331ae4c48692d8f93056afc98be9708ae32236f494980e", - "crates/rt/src/thread_controller.rs": "936764b06b0fd09555cd1b574b2bf283262efc95d7aee18b989fc88c6b634649", + "crates/rt/src/thread_controller.rs": "7603acf209594d5b02f6e652f76bb1054ecc20a12298ac194dfd01d75af2cc45", "crates/rt/src/tls.rs": "85bd95bafb3de73d249d7a2eb8779b5afc929077e27b4baa5f7c7428063063ab", "crates/rt/src/transport/mod.rs": "d5af890c800dd53abdbea8329383068cf43f0bf294ccb6b46dd7d8592ae5f321", "crates/rt/src/upcall.rs": "c0cbd2a59479dfba006850bb1bc5dd4f20f59317d178af776ba678f0e6404f02", @@ -397,7 +397,7 @@ "services/output/src/service/mod.rs": "d80c3beb50979b3e1e500abdfa3c0b9d38e354c24e2e34aea443732abbead6f1", "services/output/src/wire.rs": "3752e994eb18714518facae1b78a9ee2b34f99aad8687653f0b89dc8fdeca621", "services/swap/Cargo.toml": "9c0af01403a6ad5a7f4ff69a02b18c8b1b2624a684814d6dc1ac07968c5bbe53", - "services/swap/boot.toml": "323531c62ec0b063cea376e941218719352c87c1b91e579e560fe55dc1bcbc42", + "services/swap/boot.toml": "1e2c86025339174d8f108a1a991869081f0bd8e300bee8ff2b4b9b4bfa26edbf", "services/swap/src/client.rs": "04f735d059b491765d5ba63a4f5f640332735bf336e62dcbae86c17647583e2c", "services/swap/src/extent.rs": "d6705f77951df1c42cda51fc4526ba6262cab6af4a0545405ce06d2d2e1db7b4", "services/swap/src/lib.rs": "883684df54a87ae66aa7895ea19a872cabc8ab7fd4aded4ea65b7e3dd51fe0cf", diff --git a/native/services/swap/boot.toml b/native/services/swap/boot.toml index 80722a4..e794857 100644 --- a/native/services/swap/boot.toml +++ b/native/services/swap/boot.toml @@ -9,5 +9,5 @@ image = "kore-svc-swap" endowment = "kore_svc_swap::ZramEndowment" protocol = "kore_svc_swap::ZramSwap" endows = ["MEM_SUPPLY@0"] -digest_x86_64 = "blake3:ffa0c5ffe03770b1810bf23a9c546d1e92ce897708af3a5d0fc928c6dace51cd" # res/x86_64/; make boot-digests -digest_aarch64 = "blake3:f71c92926d0ca9faf5a1704659f96931bbf4aed4e1546bb6b1433d7d6d7057e4" # res/aarch64/; make boot-digests-aarch64 +digest_x86_64 = "blake3:c700a9f761f3801be1cac3461afe89839b4eebaad9ff3361246caacc881784ae" # res/x86_64/; make boot-digests +digest_aarch64 = "blake3:88e967f7a40d5dc52cd13f3c86cc332b3119fcfb78caf5ac41de1ebb0009110b" # res/aarch64/; make boot-digests-aarch64 From 33ba95c7c466bfebfb33e394879d50afeaa01bc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 01:54:36 +0000 Subject: [PATCH 4/5] native: re-export at Kore eb59823a, the merge of #1494's merge of main Kore's #1494 merged main `f2dc8309` and resealed over it (`e6b108d1`). MicroPerceptron/kore#1534 merges that into the cancellation wake at `eb59823a`, and this re-exports the closure there with `tools/sync-native.py`. Main reaches the closure through the seat clipboard and Cmd chords (kore-abi, kore-window), Files' DagFS watch plane and kore-rt's owed service wakes, DagFS's cwd anchor and path folding, and #1494's reseal digests in five services' `boot.toml`. The closure is 27 packages and 406 files. Against Kore `e6b108d1`, only kore-rt's `futex.rs` and `thread_controller.rs` differ. `native/Cargo.lock` is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ACHC7hXQ3xRtyu6Yt31jXQ --- docs/provenance.md | 20 + native/crates/abi/src/svc.rs | 125 +++++- native/crates/dagfs/src/fs.rs | 77 +++- native/crates/dagfs/src/lib.rs | 2 + native/crates/dagfs/src/protocol.rs | 27 ++ native/crates/dagfs/src/text.rs | 36 +- native/crates/dagfs/src/watch.rs | 251 +++++++++++ native/crates/dagfs/src/wire.rs | 107 +++++ native/crates/rt/src/service/mod.rs | 15 + native/crates/rt/src/svc/serve.rs | 144 ++++++ native/crates/window/src/client.rs | 114 +++++ native/crates/window/src/clipboard.rs | 107 +++++ native/crates/window/src/control.rs | 168 ++++++- native/crates/window/src/keysplit.rs | 315 +++++++++++++ native/crates/window/src/lib.rs | 3 + native/crates/window/src/relay.rs | 40 +- native/crates/window/src/shortcut.rs | 283 ++++++++++++ native/crates/window/src/wire.rs | 238 +++++++++- native/provenance.json | 57 +-- native/services/block/boot.toml | 4 +- native/services/dagfs/boot.toml | 4 +- native/services/dagfs/src/durable.rs | 120 ++++- native/services/dagfs/src/engine.rs | 27 ++ native/services/dagfs/src/lib.rs | 4 + native/services/dagfs/src/namespace.rs | 413 +++++++++++++++++- native/services/dagfs/src/router.rs | 17 +- native/services/dagfs/src/service/contract.rs | 37 ++ native/services/dagfs/src/service/mod.rs | 388 +++++++++++++++- native/services/dagfs/src/watches.rs | 310 +++++++++++++ native/services/device/boot.toml | 4 +- native/services/mm/boot.toml | 4 +- native/services/swap/boot.toml | 4 +- 32 files changed, 3401 insertions(+), 64 deletions(-) create mode 100644 native/crates/dagfs/src/watch.rs create mode 100644 native/crates/window/src/clipboard.rs create mode 100644 native/crates/window/src/keysplit.rs create mode 100644 native/crates/window/src/shortcut.rs create mode 100644 native/services/dagfs/src/watches.rs diff --git a/docs/provenance.md b/docs/provenance.md index 041e6a7..71f864e 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -427,3 +427,23 @@ files. Against that branch's export at `10c7b400`, only kore-rt's `futex.rs` and `thread_controller.rs`, the record, and five services' `boot.toml` digests differ. The digests come from #1494's own reseal (`f2644e5b`), which followed its export. + +It then moved to Kore `eb59823a29cc9b0cfe0ea27a386c3fac266534ef`, the merge +of #1494's merge of Kore main `f2dc8309`. Main reaches the closure in four +places: + +- the seat clipboard and Cmd chords (#1469): kore-abi's insert into a held + input line and a window's clipboard chunks, and kore-window's clipboard, + key split and shortcuts; +- Files' live folder and sidebar (#1450, #1456): a DagFS watch plane in the + `dagfs` crate and service, and kore-rt's owed service wakes + (`SystemService::take_owed_wakes`), which the serve host delivers after + each unit of work; +- DagFS's cwd anchor and `MatchView`, `.` and `..` in `resolve`, and a dead + view reaped by asking the kernel; +- five services' `boot.toml` digests, from #1494's reseal over the merge + (`e6b108d1`). + +The closure is now 27 packages and 406 files. Against Kore `e6b108d1`, only +kore-rt's `futex.rs` and `thread_controller.rs` differ. No exported manifest +changed, so `native/Cargo.lock` did not. diff --git a/native/crates/abi/src/svc.rs b/native/crates/abi/src/svc.rs index 437e013..adc7989 100644 --- a/native/crates/abi/src/svc.rs +++ b/native/crates/abi/src/svc.rs @@ -3939,6 +3939,16 @@ impl InReqWire { /// `InputStreamRequest::Reprint` — echo the line being edited, cursor /// in place, then apply every key queued since `Hold`. pub const OP_REPRINT: u8 = 6; + /// `InputStreamRequest::Insert` — type text into the **held** line at + /// its cursor, as if typed, without echo (the `Reprint` shows it): + /// `reserved0` = the byte count (1 to [`INSERT_BYTES`](Self::INSERT_BYTES)), + /// `reserved2..reserved4` = the bytes, little-endian, the words past the + /// count zero. Printable ASCII only: the line takes bytes up to the first + /// it cannot hold — any other byte, or one past a full line — so no + /// insert can ever complete a line. + pub const OP_INSERT: u8 = 7; + /// The most one `Insert` carries: its three data words. + pub const INSERT_BYTES: usize = 24; } /// The stream-input-service reply wire form. The legacy stream counters are @@ -4031,6 +4041,9 @@ impl InRepWire { /// `InputStreamResponse::Reprinted` — the line is echoed again and the /// held keys applied. pub const TAG_REPRINTED: u8 = 7; + /// `InputStreamResponse::Inserted` — `produced` = how many of the + /// insert's bytes the line took (zero to its count). + pub const TAG_INSERTED: u8 = 8; /// `InputStreamRefusal::Busy` — a queue is already attached (the v1 /// single-peer bound, `res/USERLAND.md` §5.9). @@ -4040,6 +4053,9 @@ impl InRepWire { /// `InputStreamRefusal::BadQueue` — the delivered slot was absent or /// not a well-formed queue window. pub const REFUSE_BAD_QUEUE: u8 = 3; + /// `InputStreamRefusal::NotHeld` — an insert into a line that is not + /// held, or not being edited (a completed line still in flight). + pub const REFUSE_NOT_HELD: u8 = 4; } /// One data-plane chunk — the `ring::spsc` record the backend @@ -4476,6 +4492,32 @@ impl SurReqWire { /// compositor snapshots the name and ends the pick. No subject /// ([`SurRepWire::TAG_PICK_ANSWERED`]). pub const OP_PICKED: u8 = 13; + /// `SurfaceRequest::CopyChunk` — one piece of the text this window puts + /// on its seat's clipboard (`res/CLIPBOARD.md`), written at the head of + /// its pair lease's request plane (`kore_window::menu`'s page): `a` = + /// the piece's offset in the text `| len << 32` (1 to the plane's size), + /// `b` = the whole text's byte length (1 to + /// `kore_window::clipboard::CLIPBOARD_BYTES`). The piece at offset zero + /// begins a copy, and only the seat's focused window may begin one, on + /// a gesture of the user's it has not spent; later pieces continue it + /// in order, and the piece that reaches the length ends it. The + /// compositor copies each piece out before replying, so the plane may be + /// reused once answered. No subject ([`SurRepWire::TAG_COPIED`]). + pub const OP_COPY_CHUNK: u8 = 14; + /// `SurfaceRequest::Paste` — read the seat's clipboard into this + /// window's request plane: `a` = the offset to read from. Offset zero + /// begins a read, and only the seat's focused window may begin one, on + /// a gesture of the user's it has not spent; later offsets continue it + /// until it reaches the end or the clipboard changes. No subject + /// ([`SurRepWire::TAG_CLIPBOARD`], [`SurRepWire::TAG_CLIPBOARD_EMPTY`]). + pub const OP_PASTE: u8 = 15; + /// `SurfaceRequest::PasteToShell` — the seat's terminal window asks for + /// the clipboard to be typed into its shell's line: queued as an inert + /// `ShellAction::Paste` for its controller, which reads the text and + /// edits the line itself. Only the seat's focused terminal window may + /// ask, on a gesture it has not spent. No words, no subject + /// ([`SurRepWire::TAG_PASTE_QUEUED`], [`SurRepWire::TAG_CLIPBOARD_EMPTY`]). + pub const OP_PASTE_TO_SHELL: u8 = 16; /// [`OP_SET_CURSOR`](Self::OP_SET_CURSOR) `a`: the standard arrow. pub const CURSOR_DEFAULT: u64 = 0; @@ -4597,6 +4639,20 @@ impl SurRepWire { /// `SurfaceResponse::PickAnswered` — `a` echoes the token answered; the /// pick has ended and the plane may be reused. pub const TAG_PICK_ANSWERED: u8 = 14; + /// `SurfaceResponse::Copied` — `a` = the text copied so far (the + /// piece's offset plus its length, echoed); when it is the whole + /// length, the text is the seat's clipboard. + pub const TAG_COPIED: u8 = 15; + /// `SurfaceResponse::Clipboard` — `a` = the offset read from (echoed) + /// `| len << 32`, the bytes now at the head of the request plane (1 to + /// the plane's size), `b` = the clipboard text's whole byte length. + pub const TAG_CLIPBOARD: u8 = 16; + /// `SurfaceResponse::ClipboardEmpty` — the seat's clipboard holds no + /// text. No words. + pub const TAG_CLIPBOARD_EMPTY: u8 = 17; + /// `SurfaceResponse::PasteQueued` — `a` = the byte length of the text + /// the controller was asked to type. + pub const TAG_PASTE_QUEUED: u8 = 18; /// A v1 vocabulary gate: an unknown op, a non-XRGB8888 format, a /// non-CPU reach, a non-LINEAR modifier, a non-empty flag set. @@ -4613,6 +4669,10 @@ impl SurRepWire { /// A capability transfer or teardown did not settle. A retiring surface /// keeps its backing reserved until revocation succeeds; retry is safe. pub const REFUSE_BUSY: u8 = 6; + /// The user did not ask: a clipboard verb from a window that is not its + /// seat's focused window, or with no unspent gesture of the user's + /// behind it. + pub const REFUSE_DENIED: u8 = 7; } /// One damage rectangle of a [`CommitWire`] record — pixel-addressed, @@ -4956,7 +5016,17 @@ impl FsReqWire { /// route no longer validates. The view must belong to the caller's task. /// `window_offset` is return-post correlation. pub const OP_MATCH_VIEW: u8 = 65; - pub const OP_MAX: u8 = Self::OP_MATCH_VIEW; + /// Arm watch slot `kind` on the directory `handle`, tagged `length` + /// (non-zero, the client's own name for this arming): from now on the + /// pair's watch plane — the event channel after the rings in this pair's + /// own window — counts every change to what a listing of that directory + /// shows. Re-arming a slot replaces its watch. Observation, not + /// authority: the handle's route is revalidated before every count, and + /// a route that stops proving ends the watch. Answered `Done`. + pub const OP_WATCH: u8 = 66; + /// Disarm watch slot `kind`. Answered `Done`, armed or not. + pub const OP_UNWATCH: u8 = 67; + pub const OP_MAX: u8 = Self::OP_UNWATCH; /// [`OP_STOR_MAINTAIN`](Self::OP_STOR_MAINTAIN): report only. pub const MAINTAIN_STATUS: u8 = 0; @@ -5119,6 +5189,59 @@ impl FsDirEntryWire { pub const BYTES: usize = 48; } +/// Watch slots one filesystem pair may arm ([`FsReqWire::OP_WATCH`]). +/// +/// Sized by the client that follows the most folders at once: the Files +/// explorer watches the folder it shows and the sidebar's live folders (its +/// root and the expanded folders whose lines are on screen), fifteen of them +/// at most. Every armed watch holds a handle, so this stays under half the +/// service's per-session handle bound (32, `kore_dagfs::bounds`) — a client +/// watching every slot keeps the handles a listing needs. The plane's +/// `ended` word is one bit per slot, so 32 is the layout's own ceiling. +pub const FS_WATCH_SLOTS: usize = 16; +const _: () = assert!(FS_WATCH_SLOTS <= u32::BITS as usize); + +/// The level state of a filesystem pair's **watch plane**: the event channel +/// (`res/EVENT.md`) the service formats after the request/reply rings in the +/// pair's own window and republishes whole whenever a watched directory +/// changes. Slot `k` belongs to the watch [`FsReqWire::OP_WATCH`] armed with +/// slot `k`. 136 bytes. +/// +/// Counts, not contents: a change is "what a listing of this directory, or +/// of one of its child folders, shows may differ" — an entry made, removed +/// or renamed in it, a child's length, revision or folder count moved, or an +/// entry made or removed in a child folder (its line's count and whether it +/// holds folders) — and the client lists again through its own handle, whose +/// route the service revalidates. Nothing here names an entry, so the plane +/// carries no observation the handle could not make itself. +#[repr(C)] +#[derive( + Clone, Copy, Debug, Default, PartialEq, Eq, FromBytes, IntoBytes, KnownLayout, Immutable, +)] +pub struct FsWatchState { + /// Per slot: the tag the slot was armed with; zero while disarmed. A + /// client reads a slot's counts only under the tag it armed, so a count + /// left by the slot's previous watch is never mistaken for its own. + pub tag: [u32; FS_WATCH_SLOTS], + /// Per slot: changes seen since the slot was armed, wrapping. Coalesced: + /// several changes between two service passes advance it once. + pub changes: [u32; FS_WATCH_SLOTS], + /// Bit `k`: slot `k`'s watch ended — its handle closed, its route stopped + /// proving, or its directory was reclaimed. Cleared by re-arming. + pub ended: u32, + /// Reserved; zero. + pub reserved: u32, +} + +/// The watch plane's record ring: one slot of a reserved edge kind, never +/// pushed. A later per-entry change record lands in it without moving the +/// plane's state or its offset. +pub type FsWatchRecord = u64; +/// Records the watch plane's ring holds. +pub const FS_WATCH_RECORDS: u32 = 1; + +const _: () = assert!(core::mem::size_of::() == 136); + /// Maximum bytes in one view-relative search path. /// /// The filesystem admits thirty-two 32-byte components diff --git a/native/crates/dagfs/src/fs.rs b/native/crates/dagfs/src/fs.rs index 55b4b8d..3dd38e4 100644 --- a/native/crates/dagfs/src/fs.rs +++ b/native/crates/dagfs/src/fs.rs @@ -106,6 +106,10 @@ pub struct Fs<'w, 't> { pub(crate) ring_files: u64, /// Why the last whole-file read did or did not take that ring. pub(crate) ring_note: &'static str, + /// This pair's watch plane — the event channel the service formats + /// after the rings in this pair's own window (`crate::watch`) — or + /// `None` on a pair formatted without one. + pub(crate) watch: Option>, } impl<'w, 't> Fs<'w, 't> { @@ -128,11 +132,13 @@ impl<'w, 't> Fs<'w, 't> { // window only through the SPSC protocol. let window: &'static mut [u8] = unsafe { core::slice::from_raw_parts_mut(window.cast::().as_ptr(), window.len()) }; - // An *attach*, not an init: the filesystem row's own `init_window` - // formatted this ring at founding, and `client_rings` fail-closes - // on an unformatted window rather than reading a zeroed page as an - // empty ring. - let rings = client_rings::(window, REQ_CAP, kore_rt::svc::REP_CAP).ok()?; + // An *attach*, not an init: the filesystem row's own formatter + // laid out this window at founding — the rings, then the watch + // plane — and both attaches fail closed on an unformatted window + // rather than reading a zeroed page as an empty ring. + let (pair, tail) = window.split_at_mut(crate::watch::PLANE_AT.min(window.len())); + let rings = client_rings::(pair, REQ_CAP, kore_rt::svc::REP_CAP).ok()?; + let watch = crate::watch::sink(tail).ok(); let kick = rt .bootstrap() .handle(role::SVC_SEND, process_face::REQUEST_KICK)?; @@ -223,9 +229,70 @@ impl<'w, 't> Fs<'w, 't> { ring, ring_files: 0, ring_note: "unused", + watch, }) } + /// This pair's watch plane, if the pair carries one. + pub fn watch_plane(&self) -> Option<&crate::watch::WatchSink<'static>> { + self.watch.as_ref() + } + + /// Arm watch `slot` on the directory `dir`, tagged `tag` (a non-zero + /// name of the client's own for this arming): the plane counts every + /// change to what listing `dir` shows from now on. The view stays the + /// caller's — closing it ends the watch. `Unsupported` on a pair without + /// a plane. + pub async fn watch(&mut self, dir: &crate::View, slot: u8, tag: u32) -> Result<(), Refusal> { + if self.watch.is_none() { + return Err(Refusal::Fs(crate::FsError::Unsupported)); + } + self.checked(&FsRequest::Watch { + handle: dir.handle(), + slot, + tag, + }) + .await + .map(|_| ()) + } + + /// Disarm watch `slot`. + pub async fn unwatch(&mut self, slot: u8) -> Result<(), Refusal> { + self.checked(&FsRequest::Unwatch { slot }).await.map(|_| ()) + } + + /// The plane's wake word, read before a caller drains its sources: a + /// park published against it ([`Self::arm_watch_park`]) is lossless. + /// Zero, and never moving, on a pair without a plane. + pub fn watch_seq(&self) -> u32 { + self.watch.as_ref().map_or(0, crate::watch::WatchSink::seq) + } + + /// The plane's state, `None` on a pair without one (or mid-publish, + /// which the caller treats as nothing new and reads again next pass — + /// the publish ends by moving the wake word). + pub fn watch_state(&self) -> Option { + self.watch.as_ref().and_then(crate::watch::WatchSink::state) + } + + /// Publish this task's intent to wait on the plane, for a caller about to + /// enter one executor wait that every source shares (the multiplexed + /// park, `res/FILES_DESKTOP.md`): the service's next batch then owes + /// this task a notice on its doorbell. `false` when the plane already + /// moved past `seen` — drain again instead of waiting. Always `true` on + /// a pair without a plane, which has nothing to wait for. + pub fn arm_watch_park(&self, seen: u32) -> bool { + self.watch.as_ref().is_none_or(|sink| sink.park(seen)) + } + + /// Withdraw the intent [`Self::arm_watch_park`] published, however the + /// wait ended, so a departed waiter is never charged a notice. + pub fn withdraw_watch_park(&self) { + if let Some(sink) = self.watch.as_ref() { + sink.unpark(); + } + } + /// The bulk data window, read side. Bytes a verb landed here are valid /// until the next request that uses the window. pub fn window(&self) -> &[u8] { diff --git a/native/crates/dagfs/src/lib.rs b/native/crates/dagfs/src/lib.rs index 4800948..0597035 100644 --- a/native/crates/dagfs/src/lib.rs +++ b/native/crates/dagfs/src/lib.rs @@ -67,6 +67,8 @@ mod view; #[cfg(feature = "runtime")] mod walk; #[cfg(feature = "runtime")] +pub mod watch; +#[cfg(feature = "runtime")] pub mod wire; #[cfg(feature = "runtime")] diff --git a/native/crates/dagfs/src/protocol.rs b/native/crates/dagfs/src/protocol.rs index abfbe60..3125092 100644 --- a/native/crates/dagfs/src/protocol.rs +++ b/native/crates/dagfs/src/protocol.rs @@ -193,6 +193,22 @@ pub enum FsRequest { Sync { handle: Handle, }, + /// Arm watch `slot` (below [`FS_WATCH_SLOTS`](kore_abi::svc::FS_WATCH_SLOTS)) + /// on the directory `handle` names, tagged `tag` (non-zero): the pair's + /// watch plane ([`crate::watch`]) counts every change to what a listing + /// of that directory shows, under `tag`, until the slot is re-armed or + /// disarmed or the watch ends. The handle stays the client's; its route + /// is revalidated before every count, and one that stops proving ends the + /// watch rather than reporting through a route the client lost. + Watch { + handle: Handle, + slot: u8, + tag: u32, + }, + /// Disarm watch `slot`, armed or not. + Unwatch { + slot: u8, + }, /// Hold this session's whole-tree commits until the window closes. /// /// The durable contract serializes session zero's entire tree after every @@ -631,6 +647,17 @@ impl SvcProtocol for DagfsProtocol { /// execution pipeline and made boot topology scale with the command set. const SERVE_PAIRS: usize = 2; + /// The rings, then the watch plane ([`crate::watch`]) in the same window: + /// every filesystem pair carries its change channel, so a client never + /// needs a capability beyond the pair to learn that a directory changed. + fn init_pair_window( + window: &mut [u8], + req_cap: u32, + rep_cap: u32, + ) -> Result<(), kore_ring::spsc::RingError> { + crate::watch::init_pair_window(window, req_cap, rep_cap) + } + fn reply_matches(request: &FsReqWire, response: &FsResponse, grants: u8) -> bool { if grants != 0 { return false; diff --git a/native/crates/dagfs/src/text.rs b/native/crates/dagfs/src/text.rs index 49ac01e..3af8d2f 100644 --- a/native/crates/dagfs/src/text.rs +++ b/native/crates/dagfs/src/text.rs @@ -1,6 +1,7 @@ //! The two text helpers every command needs and none should re-spell: a -//! bounded whitespace splitter over one command line, and a bounded line -//! buffer for composing an answer out of pieces. +//! bounded whitespace splitter over one command line (with `"quoted"` words, +//! so a name with spaces can be named), and a bounded line buffer for +//! composing an answer out of pieces. /// A whitespace splitter over one completed command line. /// @@ -18,13 +19,27 @@ impl<'a> Args<'a> { Self { rest: line } } - /// The next space-separated word, or `None` at the end of the line. + /// The next word, or `None` at the end of the line. A word is a run of + /// non-spaces, or — opened by `"` — everything up to the next `"`, + /// spaces included and quotes excluded, so `inspect "Untitled Folder"` + /// names the folder Files makes. A quote left open runs to the end of + /// the line. Borrowed either way: a quoted word is a span of the line. pub fn next(&mut self) -> Option<&'a [u8]> { let start = self.skip_spaces(); if start >= self.rest.len() { self.rest = &self.rest[self.rest.len()..]; return None; } + if self.rest[start] == b'"' { + let open = start + 1; + let close = self.rest[open..] + .iter() + .position(|&byte| byte == b'"') + .map_or(self.rest.len(), |at| open + at); + let word = &self.rest[open..close]; + self.rest = &self.rest[(close + 1).min(self.rest.len())..]; + return Some(word); + } let mut end = start; while end < self.rest.len() && self.rest[end] != b' ' { end += 1; @@ -128,6 +143,21 @@ mod tests { assert_eq!(args.rest(), b""); } + #[test] + fn a_quoted_word_keeps_its_spaces_and_drops_its_quotes() { + let mut args = Args::new(b"remove \"Untitled Folder\" next \"\" \"open to the end"); + assert_eq!(args.next(), Some(&b"remove"[..])); + assert_eq!(args.next(), Some(&b"Untitled Folder"[..])); + assert_eq!(args.next(), Some(&b"next"[..])); + assert_eq!(args.next(), Some(&b""[..]), "an empty quote is an empty word"); + assert_eq!(args.next(), Some(&b"open to the end"[..])); + assert_eq!(args.next(), None); + // A quote inside a word is part of it: only a word's first byte opens. + let mut args = Args::new(b"a\"b c"); + assert_eq!(args.next(), Some(&b"a\"b"[..])); + assert_eq!(args.next(), Some(&b"c"[..])); + } + #[test] fn a_line_buffer_truncates_rather_than_grows() { let mut line = LineBuf::<8>::new(); diff --git a/native/crates/dagfs/src/watch.rs b/native/crates/dagfs/src/watch.rs new file mode 100644 index 0000000..1f9b68a --- /dev/null +++ b/native/crates/dagfs/src/watch.rs @@ -0,0 +1,251 @@ +//! The **watch plane**: a filesystem pair's change channel (`res/EVENT.md`). +//! +//! A client that shows a directory — the Files explorer — needs to learn when +//! what it shows went stale without asking. The plane is the +//! service→client direction of one pair, as the window pair's event channel is +//! the compositor→client one: an event channel formatted after the +//! request/reply rings in the pair's **own** window, so it rides the lease +//! both sides already hold and needs no capability of its own. +//! +//! - **State is level.** [`FsWatchState`] holds, per slot, the tag the slot +//! was armed with and a wrapping count of changes since; the service +//! republishes it whole. Several changes between two service passes advance +//! a count once — a client lists again, it does not replay. +//! - **The wake is conditional.** The service commits after the request (or +//! teardown) that changed a watched directory and owes the client a notice +//! only if it had parked on the plane; a client that is running pays the +//! service nothing. +//! - **No authority, no names.** A watch is armed on a directory handle the +//! client holds; the service revalidates that handle's route before every +//! count and ends the watch the moment it stops proving. The plane says +//! *that* a listing may differ, never *what* it holds — the client lists +//! through its own handle. +//! +//! The record ring is reserved ([`FS_WATCH_RECORDS`], never pushed) so a later +//! per-entry change record lands without moving the state or the offset. + +use kore_abi::svc::{FS_WATCH_RECORDS, FS_WATCH_SLOTS, FsWatchRecord, FsWatchState}; +use kore_ring::event::{EventSink, EventSource}; +use kore_ring::spsc::RingError; +use kore_rt::svc::{REP_CAP, REQ_CAP, SvcProtocol}; + +use crate::DagfsProtocol; + +/// The producer half: the service's. +pub type WatchSource<'a> = EventSource<'a, FsWatchState, FsWatchRecord>; +/// The consumer half: the client's. +pub type WatchSink<'a> = EventSink<'a, FsWatchState, FsWatchRecord>; + +/// Where the plane begins in a pair window: right after the canonical +/// request/reply rings (both 64-byte rounded, so the plane is line-aligned). +pub const PLANE_AT: usize = kore_rt::svc::window_bytes::( + ::REQUEST_CAPACITY, + ::RESPONSE_CAPACITY, +); + +/// Bytes the plane occupies. +pub const PLANE_BYTES: usize = + kore_ring::event::region_bytes::(FS_WATCH_RECORDS); + +/// The smallest pair window that carries the plane. +pub const WINDOW_BYTES: usize = PLANE_AT + PLANE_BYTES; + +// Whoever sizes a filesystem pair window asserts it holds `WINDOW_BYTES` +// against its own constant (exec's `FS_CONTROL_BYTES`); the formatter refuses +// a smaller window at founding. Line alignment is the layout's own invariant. +const _: () = assert!(PLANE_AT % 64 == 0); +const _: () = assert!( + ::REQUEST_CAPACITY == REQ_CAP + && ::RESPONSE_CAPACITY == REP_CAP +); + +/// Format a whole filesystem pair window — the rings, then the plane — before +/// either side maps it. The protocol's [`SvcProtocol::init_pair_window`]. +pub fn init_pair_window(window: &mut [u8], req_cap: u32, rep_cap: u32) -> Result<(), RingError> { + kore_rt::svc::init_window::(window, req_cap, rep_cap)?; + let at = kore_rt::svc::window_bytes::(req_cap, rep_cap); + let plane = window.get_mut(at..).ok_or(RingError::TooSmall)?; + kore_ring::event::init::(plane, FS_WATCH_RECORDS) +} + +/// The service's half over its mapping of the pair window's tail (`aux` is +/// what follows the rings, as `attach_peer_aux` hands it over). +pub fn source(aux: &mut [u8]) -> Result, RingError> { + kore_ring::event::source(aux.get_mut(..PLANE_BYTES).ok_or(RingError::TooSmall)?) +} + +/// The client's half over its mapping of the same tail. +pub fn sink(aux: &mut [u8]) -> Result, RingError> { + kore_ring::event::sink(aux.get_mut(..PLANE_BYTES).ok_or(RingError::TooSmall)?) +} + +/// What one slot's watch did since a [`Cursor`] last looked. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Seen { + /// Nothing the cursor has not seen. + Same, + /// The directory changed: list it again. + Changed, + /// The watch ended (its route stopped proving, or the directory went): + /// what was shown cannot be refreshed through this watch. + Ended, +} + +/// A client's view of one slot: the tag it armed and the last count it acted +/// on. A slot is read only under the cursor's own tag, so the counts the +/// slot carried for an earlier arming — or for nobody — never read as news. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Cursor { + slot: u8, + tag: u32, + seen: u32, + ended: bool, +} + +impl Cursor { + /// A cursor for `slot` armed with `tag`; `None` for a slot past the plane + /// or the zero tag (the plane's "disarmed"). + pub const fn new(slot: u8, tag: u32) -> Option { + if slot as usize >= FS_WATCH_SLOTS || tag == 0 { + return None; + } + Some(Self { + slot, + tag, + seen: 0, + ended: false, + }) + } + + pub const fn slot(&self) -> u8 { + self.slot + } + + pub const fn tag(&self) -> u32 { + self.tag + } + + /// Compare `state` with what this cursor last acted on, and take it as + /// seen. A state still showing another tag in the slot (the arming has + /// not landed, or the slot was re-armed by someone else's request on + /// this pair) is [`Seen::Same`]: nothing of this watch is in it yet. + pub fn observe(&mut self, state: &FsWatchState) -> Seen { + let slot = usize::from(self.slot); + if state.tag[slot] != self.tag { + return Seen::Same; + } + if state.ended & (1 << slot) != 0 { + if self.ended { + return Seen::Same; + } + self.ended = true; + return Seen::Ended; + } + let changes = state.changes[slot]; + if changes == self.seen { + return Seen::Same; + } + self.seen = changes; + Seen::Changed + } +} + +/// The plane's state as a producer edits it — the service's one batch verb +/// over a slot. Kept beside the layout so both halves read one definition. +pub mod state { + use super::{FS_WATCH_SLOTS, FsWatchState}; + + /// Arm `slot` under `tag`: no changes yet, not ended. + pub fn arm(state: &mut FsWatchState, slot: usize, tag: u32) { + if slot < FS_WATCH_SLOTS { + state.tag[slot] = tag; + state.changes[slot] = 0; + state.ended &= !(1 << slot); + } + } + + /// Disarm `slot`. + pub fn disarm(state: &mut FsWatchState, slot: usize) { + if slot < FS_WATCH_SLOTS { + state.tag[slot] = 0; + state.changes[slot] = 0; + state.ended &= !(1 << slot); + } + } + + /// One more change under `slot`'s arming. + pub fn changed(state: &mut FsWatchState, slot: usize) { + if slot < FS_WATCH_SLOTS { + state.changes[slot] = state.changes[slot].wrapping_add(1); + } + } + + /// `slot`'s watch ended; its tag stays, so the client that armed it + /// learns that it did. + pub fn end(state: &mut FsWatchState, slot: usize) { + if slot < FS_WATCH_SLOTS { + state.ended |= 1 << slot; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::vec; + + #[repr(align(64))] + struct Window([u8; WINDOW_BYTES]); + + #[test] + fn the_plane_follows_the_rings_and_both_halves_attach() { + let mut window = Window([0; WINDOW_BYTES]); + init_pair_window(&mut window.0, REQ_CAP, REP_CAP).unwrap(); + // The rings are exactly where every existing client expects them. + let (pair, aux) = window.0.split_at_mut(PLANE_AT); + assert!(kore_rt::svc::client_rings::(pair, REQ_CAP, REP_CAP).is_ok()); + let base = aux.as_mut_ptr(); + // SAFETY: the two halves alias the plane only through the channel + // protocol, as the cross-space form does over two mappings. + let alias = unsafe { core::slice::from_raw_parts_mut(base, PLANE_BYTES) }; + let mut source = source(aux).unwrap(); + let sink = sink(alias).unwrap(); + assert_eq!(sink.state(), Some(FsWatchState::default())); + source.state(|s| state::arm(s, 1, 7)); + assert!(!source.commit(), "nobody parked: no notice owed"); + let seen = sink.seq(); + assert!(sink.park(seen)); + source.state(|s| state::changed(s, 1)); + assert!(source.commit(), "a parked client is owed its notice"); + assert_eq!(sink.state().unwrap().changes[1], 1); + } + + #[test] + fn a_window_too_small_for_the_plane_is_refused() { + let mut short = vec![0u8; PLANE_AT + 64 + 64]; + let at = short.as_ptr().align_offset(64); + let window = &mut short[at..at + PLANE_AT]; + assert_eq!(init_pair_window(window, REQ_CAP, REP_CAP), Err(RingError::TooSmall)); + } + + #[test] + fn a_cursor_reads_only_its_own_arming() { + let mut cursor = Cursor::new(2, 9).unwrap(); + let mut s = FsWatchState::default(); + // The slot's previous watch left counts: not this cursor's news. + state::arm(&mut s, 2, 8); + state::changed(&mut s, 2); + assert_eq!(cursor.observe(&s), Seen::Same); + state::arm(&mut s, 2, 9); + assert_eq!(cursor.observe(&s), Seen::Same); + state::changed(&mut s, 2); + state::changed(&mut s, 2); + assert_eq!(cursor.observe(&s), Seen::Changed, "two changes coalesce"); + assert_eq!(cursor.observe(&s), Seen::Same); + state::end(&mut s, 2); + assert_eq!(cursor.observe(&s), Seen::Ended); + assert_eq!(cursor.observe(&s), Seen::Same, "an ending is reported once"); + assert!(Cursor::new(FS_WATCH_SLOTS as u8, 1).is_none()); + assert!(Cursor::new(0, 0).is_none()); + } +} diff --git a/native/crates/dagfs/src/wire.rs b/native/crates/dagfs/src/wire.rs index 638461e..4cdae6f 100644 --- a/native/crates/dagfs/src/wire.rs +++ b/native/crates/dagfs/src/wire.rs @@ -23,6 +23,8 @@ //! | `OpenAt` | parent | — | — | — | `name` | //! | `OpenGrant` | — | grant id | — | — | — | //! | `Close`/`Stat`/`Sync` | object | — | — | — | — | +//! | `Watch` | directory | — | — | tag (slot in `kind`) | — | +//! | `Unwatch` | — | — | — | — (slot in `kind`) | — | //! | `List` | directory | — | — | cursor | — | //! | `Parents` | object | — | — | cursor | — | //! | `Locate` | anchor | stable identity | — | — | — | @@ -448,6 +450,16 @@ pub fn encode_req(req: &FsRequest) -> FsReqWire { w.op = FsReqWire::OP_SYNC; w.handle = U64::new(handle.to_bits()); } + FsRequest::Watch { handle, slot, tag } => { + w.op = FsReqWire::OP_WATCH; + w.handle = U64::new(handle.to_bits()); + w.kind = slot; + w.length = U32::new(tag); + } + FsRequest::Unwatch { slot } => { + w.op = FsReqWire::OP_UNWATCH; + w.kind = slot; + } FsRequest::DeferCommits { hold } => { w.op = FsReqWire::OP_DEFER_COMMITS; w.kind = u8::from(hold); @@ -713,6 +725,8 @@ pub fn decode_req(w: &FsReqWire) -> Option { | FsReqWire::OP_DEFER_COMMITS | FsReqWire::OP_SET_SYSTEM_SEED | FsReqWire::OP_STOR_MAINTAIN + | FsReqWire::OP_WATCH + | FsReqWire::OP_UNWATCH ) && w.kind != 0 { return None; @@ -914,6 +928,35 @@ pub fn decode_req(w: &FsReqWire) -> Option { } FsReqWire::OP_REVOKE => FsRequest::Revoke { grant }, FsReqWire::OP_SYNC => FsRequest::Sync { handle }, + FsReqWire::OP_WATCH => { + // `kind` is the slot and `length` the tag; nothing else rides. A + // slot past the plane or a zero tag (the plane's "disarmed") is a + // framing mismatch, not a request to guess at. + if usize::from(w.kind) >= kore_abi::svc::FS_WATCH_SLOTS + || w.length.get() == 0 + || w.window_offset.get() != 0 + || w.handle2.get() != 0 + || w.offset.get() != 0 + || w.expected_revision.get() != 0 + || !names_are_empty(w) + { + return None; + } + FsRequest::Watch { + handle, + slot: w.kind, + tag: w.length.get(), + } + } + FsReqWire::OP_UNWATCH => { + if usize::from(w.kind) >= kore_abi::svc::FS_WATCH_SLOTS + || !control_payload_is_empty(w) + || w.window_offset.get() != 0 + { + return None; + } + FsRequest::Unwatch { slot: w.kind } + } FsReqWire::OP_DEFER_COMMITS => { // `kind` is the whole payload: a deferral names no object, and a // value outside the two-state vocabulary is a framing mismatch. @@ -1737,6 +1780,8 @@ pub fn decode_rep_for(request: &FsRequest, w: &FsRepWire) -> Option<(FsResponse, | FsRequest::DetachEdge { .. } | FsRequest::Revoke { .. } | FsRequest::Sync { .. } + | FsRequest::Watch { .. } + | FsRequest::Unwatch { .. } | FsRequest::BeginReplace { .. } | FsRequest::WriteReplacement { .. } | FsRequest::AbortReplace { .. } @@ -1876,6 +1921,8 @@ pub fn reply_matches(request: &FsRequest, response: &FsResponse) -> bool { | FsRequest::DetachEdge { .. } | FsRequest::Revoke { .. } | FsRequest::Sync { .. } + | FsRequest::Watch { .. } + | FsRequest::Unwatch { .. } | FsRequest::BeginReplace { .. } | FsRequest::WriteReplacement { .. } | FsRequest::AbortReplace { .. } @@ -2020,6 +2067,66 @@ mod tests { assert_eq!(decode_req(&stray), None); } + /// A watch carries its directory, its slot and its tag and nothing else; + /// an unwatch carries its slot alone. Both answer `Done`, and a slot past + /// the plane, the zero tag, or any stray operand is refused. + #[test] + fn watch_verbs_round_trip_and_refuse_stray_operands() { + let slots = kore_abi::svc::FS_WATCH_SLOTS as u8; + for (slot, tag) in [(0, 1), (slots - 1, u32::MAX)] { + let watch = FsRequest::Watch { + handle: Handle::from_bits(0x1_0000_0003), + slot, + tag, + }; + let unwatch = FsRequest::Unwatch { slot }; + for request in [watch, unwatch] { + assert!(!request.needs_window()); + assert_eq!(decode_req(&encode_req(&request)), Some(request)); + assert!(reply_matches(&request, &FsResponse::Done)); + assert_eq!( + decode_rep_for(&request, &encode_rep(&FsResponse::Done, 0)), + Some((FsResponse::Done, 0)) + ); + } + } + let wire = encode_req(&FsRequest::Watch { + handle: Handle::from_bits(9), + slot: 1, + tag: 5, + }); + let strays: [fn(&mut FsReqWire); 8] = [ + |w| w.kind = kore_abi::svc::FS_WATCH_SLOTS as u8, + |w| w.length = U32::new(0), + |w| w.window_offset = U32::new(4), + |w| w.handle2 = U64::new(1), + |w| w.offset = U64::new(1), + |w| w.expected_revision = U64::new(1), + |w| w.rights = 1, + |w| { + w.name[0] = b'x'; + w.name_len = 1; + }, + ]; + for stray in strays { + let mut bad = wire; + stray(&mut bad); + assert_eq!(decode_req(&bad), None); + } + let wire = encode_req(&FsRequest::Unwatch { slot: 2 }); + let strays: [fn(&mut FsReqWire); 4] = [ + |w| w.kind = kore_abi::svc::FS_WATCH_SLOTS as u8, + |w| w.handle = U64::new(1), + |w| w.length = U32::new(1), + |w| w.window_offset = U32::new(1), + ]; + for stray in strays { + let mut bad = wire; + stray(&mut bad); + assert_eq!(decode_req(&bad), None); + } + } + /// Every maintenance action survives the wire; only a forced refresh /// carries operands, and an operand on any other action — or a range /// past `u32` — is refused. diff --git a/native/crates/rt/src/service/mod.rs b/native/crates/rt/src/service/mod.rs index bf11006..a4cc04a 100644 --- a/native/crates/rt/src/service/mod.rs +++ b/native/crates/rt/src/service/mod.rs @@ -413,6 +413,21 @@ pub trait SystemService: crate::svc::SvcProtocol + Sized { /// retryable domain custody still closes in `peer_closed` afterward. fn peer_detaching(&mut self, _peer: crate::svc::PeerId) {} + /// Recyclable peers this service owes one wake: it published a batch into + /// an event channel it keeps in their pair's tail (`attach_peer_aux`, + /// `res/EVENT.md`) and the batch's commit found the consumer parked. + /// + /// [`ServeHost`](crate::svc::ServeHost) asks after every unit of work — + /// a request, a teardown, an idle recheck — and sends each peer named in + /// `owed[..n]` one notice through the endpoint it owns, so a service never + /// copies an affine endpoint out of its peer bundle to wake a client. Name + /// each peer once per owed wake and clear it as it is handed over; a full + /// batch is asked again. The default owes nothing: the request/reply shape, + /// whose only wake is the reply's. + fn take_owed_wakes(&mut self, _owed: &mut [Option]) -> usize { + 0 + } + /// Observe completion of an admission requested through /// [`Serve::admit_peer`]. The host, not the wire request, chooses the /// generational identity and reports only the authenticated result. diff --git a/native/crates/rt/src/svc/serve.rs b/native/crates/rt/src/svc/serve.rs index 1b79832..985f785 100644 --- a/native/crates/rt/src/svc/serve.rs +++ b/native/crates/rt/src/svc/serve.rs @@ -2358,6 +2358,39 @@ impl<'w, 't, T: Transport, S: SystemService> ServeHost<'w, 't, T, S> { // work: a pair a serve step just minted is attached before // the next wake, and a retired one stops now. self.settle_minted(); + self.deliver_owed_wakes().await; + } + } + + /// Send each peer the service owes a wake its one notice + /// ([`SystemService::take_owed_wakes`]), through the endpoint this host + /// owns. A service names a peer once per owed wake, so the batches end + /// within the peer table; the bound only keeps a service that kept + /// answering full batches from holding the loop. + async fn deliver_owed_wakes(&mut self) { + // Peers asked for per call: a stack array, so the ask allocates + // nothing; its size only trades calls for stack, and the founder + // roster's whole recyclable band fits one batch. + const BATCH: usize = 8; + for _ in 0..=self.pairs.len().div_ceil(BATCH) { + let mut owed = [None; BATCH]; + let count = self.svc.take_owed_wakes(&mut owed).min(BATCH); + for peer in owed[..count].iter().flatten() { + // A peer retired since its batch was published has no one + // left to wake; `notify_peer` refuses it by generation. + let _ = self + .notify_peer( + *peer, + Notice { + cookie: crate::event::COOKIE_EVENT, + payload: 0, + }, + ) + .await; + } + if count < BATCH { + break; + } } } @@ -2406,6 +2439,7 @@ impl<'w, 't, T: Transport, S: SystemService> ServeHost<'w, 't, T, S> { // The handshake drains even on an idle pass, so a host test can // attach a freshly minted pair without a request in flight. self.settle_minted(); + self.deliver_owed_wakes().await; served } } @@ -2820,6 +2854,116 @@ mod tests { ); } + /// A service that publishes into its peers' auxiliary planes and owes + /// some of them a wake (`SystemService::take_owed_wakes`). + struct Owing { + /// Peers still owed, or `None` for a service that answers full + /// batches forever. + owed: Option, + asks: u32, + } + + impl SystemService for Owing { + const NAME: &'static str = "owing-test"; + type Endowment = NoCaps; + + fn bind(_: NoCaps) -> Self { + Self { owed: Some(0), asks: 0 } + } + + fn serve(&mut self, _: u64, sv: &mut Serve<'_, Reply>) { + sv.reply(Reply::Granted); + } + + fn take_owed_wakes(&mut self, owed: &mut [Option]) -> usize { + self.asks += 1; + let count = match self.owed.as_mut() { + Some(left) => { + let count = (*left).min(owed.len()); + *left -= count; + count + } + None => owed.len(), + }; + for peer in &mut owed[..count] { + *peer = PeerId::from_arrival(0x102); + } + count + } + } + + impl SvcProtocol for Owing { + type Request = u64; + type Response = Reply; + type ReqWire = u64; + type RepWire = u64; + + const REPLY_GRANTS: usize = 0; + + fn encode_req(req: &u64) -> u64 { + *req + } + + fn decode_req(wire: &u64) -> Option { + Some(*wire) + } + + fn encode_rep(rep: &Reply, grants: u8) -> u64 { + DropAware::encode_rep(rep, grants) + } + + fn decode_rep(wire: &u64) -> Option<(Reply, u8)> { + DropAware::decode_rep(wire) + } + } + + /// The host asks for owed wakes after every pass — a served request and + /// an idle one alike — asks again while batches come back full, and stops + /// within the peer table even for a service that never stops owing. (A + /// notice to a peer that is not a live recyclable generation is refused + /// by generation, which is what a peer retired since its batch gets.) + #[test] + fn the_host_drains_owed_wakes_after_every_pass_and_bounds_the_batches() { + let window = { + let bytes = window_bytes::(REQ_CAP, REP_CAP); + let layout = std::alloc::Layout::from_size_align(bytes, 64).expect("window layout"); + // SAFETY: as `window()` above — one leaked aligned allocation. + unsafe { + let ptr = std::alloc::alloc_zeroed(layout); + assert!(!ptr.is_null()); + core::slice::from_raw_parts_mut(ptr, bytes) + } + }; + let (mut client, rings) = split_window::(window, REQ_CAP, REP_CAP).expect("rings"); + let endpoints = ServeEndpoints { + kick: SlotRef::from_wire(60), + grant_tx: [None; MAX_GRANTS], + scratch: [None; MAX_GRANTS], + return_post: None, + }; + let transport = OkTransport::new(); + let mut host = ServeHost::new(Owing::bind(NoCaps), &transport, rings, endpoints); + + client.req.push(1).expect("request fits"); + assert!(drive(host.serve_next())); + assert_eq!(host.service().asks, 1, "a pass that owes nothing asks once"); + + // Every peer the table holds owed at once: full batches, then the + // short ask that ends them. + let peers = host.pairs.len(); + host.service_mut().owed = Some(peers); + assert!(!drive(host.serve_next())); + assert_eq!(host.service().asks, 1 + peers as u32 / 8 + 1); + assert_eq!(host.service().owed, Some(0)); + + // A service that never stops owing is bounded by the peer table. + host.service_mut().owed = None; + host.service_mut().asks = 0; + assert!(!drive(host.serve_next())); + let bound = host.pairs.len().div_ceil(8) as u32 + 1; + assert_eq!(host.service().asks, bound); + } + /// Walking return posts are host-owned reply-and-wait state: the first /// stands before readiness, and every successor is acknowledged before /// `serve_next` returns with its reply visible. diff --git a/native/crates/window/src/client.rs b/native/crates/window/src/client.rs index bd6892b..2fc2bd0 100644 --- a/native/crates/window/src/client.rs +++ b/native/crates/window/src/client.rs @@ -53,6 +53,9 @@ pub enum ClientError { EventMalformed, /// A menu the encoder refused before anything reached the compositor. Menu(crate::menu::MenuError), + /// A clipboard text past [`crate::clipboard::CLIPBOARD_BYTES`], or one + /// longer than its reader can hold: refused whole, never cut short. + TooLong, } impl ClientError { @@ -946,6 +949,101 @@ impl<'w, 't, T: Transport> WindowClient<'w, 't, T> { } } + /// Put `text` on this window's seat's clipboard (`res/CLIPBOARD.md`), a + /// request plane at a time. Only the seat's focused window may, on a key + /// press, button press or menu choice it was just delivered and has not + /// spent ([`SurfaceRefusal::Denied`] otherwise). An empty text is no + /// copy; one past the clipboard's bound is refused before anything is + /// sent, and one that is not UTF-8 the compositor refuses whole. + /// `OutOfMemory` leaves the clipboard and the gesture as they were, so a + /// retry may succeed. + pub async fn copy(&mut self, text: &[u8]) -> Result<(), ClientError> { + let total = u32::try_from(text.len()) + .ok() + .filter(|&total| total as usize <= crate::clipboard::CLIPBOARD_BYTES) + .ok_or(ClientError::TooLong)?; + if total == 0 { + return Err(ClientError::Protocol); + } + let mut offset = 0_u32; + for piece in text.chunks(crate::clipboard::CHUNK_BYTES.min(self.menu.len())) { + self.menu[..piece.len()].copy_from_slice(piece); + let len = piece.len() as u16; + match self + .surface_request(SurfaceRequest::CopyChunk { offset, len, total }) + .await? + { + SurfaceResponse::Copied { through } if through == offset + u32::from(len) => { + offset = through; + } + _ => return Err(ClientError::Protocol), + } + } + Ok(()) + } + + /// Read this window's seat's clipboard (`res/CLIPBOARD.md`): `begin` + /// learns the text's length before any of it, and may refuse it + /// (`false`: longer than the caller can hold, [`ClientError::TooLong`]); + /// `piece` then takes the text in order, a request plane at a time, and + /// answers whether it wants more (`false` ends the read there: a reader + /// that needs only the text's start reads no more of it). Answers the + /// length, zero when the clipboard holds no text. Gated as [`Self::copy`] + /// is; a copy landing while the read is under way refuses it + /// ([`SurfaceRefusal::Unknown`]) rather than mixing two texts. + pub async fn paste( + &mut self, + begin: impl FnOnce(u32) -> bool, + mut piece: impl FnMut(&[u8]) -> bool, + ) -> Result { + let mut begin = Some(begin); + let mut offset = 0_u32; + let mut buffer = [0_u8; PASTE_STAGE_BYTES]; + loop { + let (at, len, total) = match self.surface_request(SurfaceRequest::Paste { offset }).await? { + SurfaceResponse::Clipboard { offset: at, len, total } => (at, len, total), + SurfaceResponse::ClipboardEmpty if offset == 0 => return Ok(0), + _ => return Err(ClientError::Protocol), + }; + if at != offset || usize::from(len) > self.menu.len() { + return Err(ClientError::Protocol); + } + if let Some(begin) = begin.take() + && !begin(total) + { + return Err(ClientError::TooLong); + } + // The compositor wrote the plane before it answered; read it as + // the shared memory it is, after the reply's acquire. + let plane = shared_plane(self.menu); + for chunk in plane[..usize::from(len)].chunks(buffer.len()) { + for (byte, shared) in buffer.iter_mut().zip(chunk) { + *byte = shared.load(core::sync::atomic::Ordering::Relaxed); + } + if !piece(&buffer[..chunk.len()]) { + return Ok(total); + } + } + offset += u32::from(len); + if offset == total { + return Ok(total); + } + } + } + + /// Ask for the clipboard to be typed into this terminal window's shell + /// line (`res/CLIPBOARD.md` §Shell): queued for its controller, which + /// takes what the line can hold. Gated as [`Self::copy`] is, and only the + /// seat's terminal window may ask. Answers the text's length, zero when + /// the clipboard holds no text. + pub async fn paste_to_shell(&mut self) -> Result { + match self.surface_request(SurfaceRequest::PasteToShell).await? { + SurfaceResponse::PasteQueued { total } => Ok(total), + SurfaceResponse::ClipboardEmpty => Ok(0), + _ => Err(ClientError::Protocol), + } + } + async fn read_configuration(&mut self) -> Result { let reply = self .control @@ -1392,6 +1490,22 @@ impl<'w, 't, T: Transport> WindowClient<'w, 't, T> { } } +/// A paste's piece reaches its reader through this much stack at a time: +/// the plane is read atomically, a byte at a time, and handed on as a slice. +/// Small enough for any task's stack, large enough that a 4 KiB piece is +/// sixteen calls. +const PASTE_STAGE_BYTES: usize = 256; + +/// The request plane as the memory it is: shared with the compositor, which +/// writes it while it answers a paste. +fn shared_plane(plane: &[u8]) -> &[core::sync::atomic::AtomicU8] { + // SAFETY: `AtomicU8` has `u8`'s size and alignment, and every access the + // returned view makes is atomic, so the compositor's writes race with none + // of this process's plain accesses: the plane's plain writers run only + // between requests, never while this view is live. + unsafe { &*(core::ptr::from_ref(plane) as *const [core::sync::atomic::AtomicU8]) } +} + fn valid_configuration(previous: Option, next: Configure) -> bool { next.window != 0 && next.serial != 0 diff --git a/native/crates/window/src/clipboard.rs b/native/crates/window/src/clipboard.rs new file mode 100644 index 0000000..eca5220 --- /dev/null +++ b/native/crates/window/src/clipboard.rs @@ -0,0 +1,107 @@ +//! The seat's clipboard (`res/CLIPBOARD.md`): one text the compositor holds +//! for each seat, which every window of that seat can put text on and read +//! text from, and which no window can touch without the user asking. +//! +//! The text crosses a window's own pair lease, never a lent view: a piece at +//! a time through the request plane the window already maps +//! ([`crate::menu`]'s page), copied out by the compositor before it answers a +//! copy and copied in before it answers a paste. A lent view per copy or +//! paste would spend the window's map cursor, which no unmap gives back, and +//! carry a capability and a revocation for a few hundred bytes of text. +//! +//! **Gesture-gated.** Only the seat's focused window may begin a copy or a +//! paste, and only on a key press, button press or menu choice the +//! compositor delivered to it moments before — each such gesture begins one +//! copy or one paste. A window in the background can neither overwrite the +//! clipboard nor read it. + +/// The most the clipboard holds, in bytes: a copy past it is refused, never +/// cut short. Equal to `kore_edit::CLIPBOARD_BYTES`, the bound every text +/// field already copies within, so any selection a field can copy fits. +pub const CLIPBOARD_BYTES: usize = 512 << 10; + +/// The most one piece of a copy or a paste carries: the request plane whole. +pub const CHUNK_BYTES: usize = crate::menu::MENU_PLANE_BYTES; + +const _: () = assert!(CLIPBOARD_BYTES <= u32::MAX as usize && CHUNK_BYTES <= u16::MAX as usize); + +/// What a paste into a shell's line takes from the text (`res/CLIPBOARD.md` +/// §Shell): the printable ASCII before the first line break, and never a line +/// break, so a paste can never run a command the user has not read. Why the +/// rest was held back is told to the user, never silently dropped. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ShellStop { + /// The whole text was taken. + Whole, + /// The text goes on past a line break. + LineBreak, + /// A character the line cannot hold: a control byte, or a character + /// outside ASCII. + Unholdable, + /// The line is full. + Full, +} + +impl ShellStop { + /// What the user is told about the text held back: nothing when the + /// whole text was typed. + pub const fn describe(self) -> Option<&'static str> { + match self { + Self::Whole => None, + Self::LineBreak => Some("typed the first line; the other lines were held back"), + Self::Unholdable => { + Some("stopped at a tab, control or non-ASCII character the line cannot hold") + } + Self::Full => Some("the line is full; the rest was held back"), + } + } +} + +/// Scan `bytes`, the text from where the last scan stopped, for what a shell +/// paste may take: the length of the run it takes, and why it stops there. +/// `last` says `bytes` reaches the text's end; `room` bounds the run. `None` +/// means `bytes` ran out before the text did: scan the next piece with the +/// room that is left, even none, to learn what follows. +pub fn shell_run(bytes: &[u8], last: bool, room: usize) -> (usize, Option) { + let limit = bytes.len().min(room); + let taken = bytes[..limit] + .iter() + .position(|&byte| !(b' '..=b'~').contains(&byte)) + .unwrap_or(limit); + let stop = match bytes.get(taken) { + Some(b'\r' | b'\n') => Some(ShellStop::LineBreak), + Some(_) if taken < room => Some(ShellStop::Unholdable), + Some(_) => Some(ShellStop::Full), + None if last => Some(ShellStop::Whole), + None => None, + }; + (taken, stop) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A shell paste takes printable ASCII up to the first line break, the + /// first character the line cannot hold, or the line's room. + #[test] + fn a_shell_run_stops_before_what_the_line_cannot_take() { + assert_eq!(shell_run(b"ls -la", true, 255), (6, Some(ShellStop::Whole))); + assert_eq!(shell_run(b"ls -la", false, 255), (6, None)); + assert_eq!(shell_run(b"echo hi\nrm -rf /", true, 255), (7, Some(ShellStop::LineBreak))); + assert_eq!(shell_run(b"a\r\nb", true, 255), (1, Some(ShellStop::LineBreak))); + assert_eq!(shell_run(b"tab\there", true, 255), (3, Some(ShellStop::Unholdable))); + assert_eq!(shell_run("caf\u{e9}".as_bytes(), true, 255), (3, Some(ShellStop::Unholdable))); + assert_eq!(shell_run(b"abcdef", true, 4), (4, Some(ShellStop::Full))); + // A run that fills the room exactly at the text's end took it whole; + // short of the end, what follows decides. + assert_eq!(shell_run(b"abcd", true, 4), (4, Some(ShellStop::Whole))); + assert_eq!(shell_run(b"abcd", false, 4), (4, None)); + assert_eq!(shell_run(b"\nmore", true, 0), (0, Some(ShellStop::LineBreak))); + assert_eq!(shell_run(b"more", true, 0), (0, Some(ShellStop::Full))); + assert_eq!(shell_run(b"", true, 4), (0, Some(ShellStop::Whole))); + // A line break at the room's edge is still a line break: the line + // is not full, the text has another line. + assert_eq!(shell_run(b"abcd\n", true, 4), (4, Some(ShellStop::LineBreak))); + } +} diff --git a/native/crates/window/src/control.rs b/native/crates/window/src/control.rs index 8aec8e2..21ccca6 100644 --- a/native/crates/window/src/control.rs +++ b/native/crates/window/src/control.rs @@ -40,10 +40,17 @@ const SET_LAYOUT_HINTS: u8 = 0x4e; const PREPARE_CAPTURE: u8 = 0x4f; const REQUEST_CAPTURE: u8 = 0x50; const RELEASE_CAPTURE: u8 = 0x51; +const READ_CLIPBOARD: u8 = 0x54; const ACCEPTED: u8 = 0x40; const ADMITTED: u8 = 0x41; const REFUSED: u8 = 0x42; const SHELL_ACTION: u8 = 0x4d; +const CLIPBOARD_TEXT: u8 = 0x54; + +/// Bytes of clipboard text one [`DisplayRequest::ReadClipboard`] answer +/// carries inline: the reply record's four data words. A shell paste takes at +/// most one line of the shell's line editor, a few of these. +pub const CLIPBOARD_TEXT_BYTES: usize = 32; /// Bound on one window's published tab strip. pub const MAX_TABS: usize = 8; @@ -310,6 +317,12 @@ pub enum ShellAction { /// with `token` (never zero), by which it claims the pick. Data only; /// the compositor brokers the answer. Pick { token: u64, types: u64 }, + /// The user pasted into this controller's terminal window + /// (`res/CLIPBOARD.md`): read the clipboard with + /// [`DisplayRequest::ReadClipboard`] under `ticket` (never zero) and type + /// what the line can hold. The ticket reads the text the user pasted and + /// no other: a copy made since refuses it. + Paste { ticket: u64 }, } impl ShellAction { @@ -361,6 +374,7 @@ impl ShellAction { refusal: CaptureRefusal::from_code(b as u8)?, })), 6 if a != 0 && b != 0 => Some(Some(Self::Pick { token: a, types: b })), + 7 if a != 0 && b == 0 => Some(Some(Self::Paste { ticket: a })), _ => None, } } @@ -397,6 +411,7 @@ impl ShellAction { (5, mode.code() as u64, refusal.code() as u64) } Some(Self::Pick { token, types }) => (6, token, types), + Some(Self::Paste { ticket }) => (7, ticket, 0), } } } @@ -668,8 +683,65 @@ pub enum DisplayRequest { ReleaseCapture { ticket: u32, }, + /// Controller verb: read the clipboard text the user pasted into this + /// controller's terminal, from `offset`, under the ticket its + /// [`ShellAction::Paste`] carried. + ReadClipboard { ticket: u64, offset: u32 }, +} + +/// Clipboard text read for a controller: bytes `offset..offset + len` of a +/// `total`-byte text, the first `len` of `bytes` (the rest zero). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ClipboardText { + pub offset: u32, + pub len: u8, + pub total: u32, + pub bytes: [u8; CLIPBOARD_TEXT_BYTES], +} + +impl ClipboardText { + /// The text this answer carries. + pub fn text(&self) -> &[u8] { + &self.bytes[..usize::from(self.len)] + } + + /// The answer's word: `offset | total << 24 | len << 48`. + const fn word(&self) -> u64 { + self.offset as u64 | (self.total as u64) << 24 | (self.len as u64) << 48 + } + + fn from_words(word: u64, data: [u64; 4]) -> Option { + let (offset, total, len) = ((word & 0xff_ffff) as u32, (word >> 24 & 0xff_ffff) as u32, (word >> 48) as u16); + let mut bytes = [0; CLIPBOARD_TEXT_BYTES]; + for (word, chunk) in data.into_iter().zip(bytes.chunks_exact_mut(8)) { + chunk.copy_from_slice(&word.to_le_bytes()); + } + let len = u8::try_from(len).ok()?; + (len != 0 + && usize::from(len) <= CLIPBOARD_TEXT_BYTES + && total != 0 + && total as usize <= crate::clipboard::CLIPBOARD_BYTES + && u64::from(offset) + u64::from(len) <= u64::from(total) + && bytes[usize::from(len)..].iter().all(|&byte| byte == 0)) + .then_some(Self { + offset, + len, + total, + bytes, + }) + } + + fn data(&self) -> [u64; 4] { + core::array::from_fn(|word| { + u64::from_le_bytes(self.bytes[word * 8..word * 8 + 8].try_into().unwrap()) + }) + } } +const _: () = assert!( + crate::clipboard::CLIPBOARD_BYTES < 1 << 24 && CLIPBOARD_TEXT_BYTES == 4 * 8 +); + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum DisplayResponse { Desktop(DesktopState), @@ -689,6 +761,7 @@ pub enum DisplayResponse { Configured(Configure), ControlRefused(ControlRefusal), ShellAction(Option), + ClipboardText(ClipboardText), } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -848,6 +921,11 @@ pub fn encode_req(request: &DisplayRequest) -> SurReqWire { w.op = RELEASE_CAPTURE; w.subject = u64::from(ticket).into(); } + DisplayRequest::ReadClipboard { ticket, offset } => { + w.op = READ_CLIPBOARD; + w.subject = ticket.into(); + w.a = u64::from(offset).into(); + } } w } @@ -981,6 +1059,16 @@ pub fn decode_req(w: &SurReqWire) -> Option { .ok() .filter(|ticket| *ticket != 0)?, }, + READ_CLIPBOARD => { + let offset = u32::try_from(w.a.get()).ok()?; + if w.subject.get() == 0 || offset as usize >= crate::clipboard::CLIPBOARD_BYTES { + return None; + } + DisplayRequest::ReadClipboard { + ticket: w.subject.get(), + offset, + } + } op if op & OUTPUT != 0 => { DisplayRequest::Output(kore_svc_output::wire::decode_req(&OutReqWire { op: op & !OUTPUT, @@ -1053,6 +1141,15 @@ pub fn encode_rep(response: &DisplayResponse, grants: u8) -> SurRepWire { w.a = a.into(); w.b = b.into(); } + DisplayResponse::ClipboardText(text) => { + w.tag = CLIPBOARD_TEXT; + w.id = text.word().into(); + let [a, b, c, d] = text.data(); + w.a = a.into(); + w.b = b.into(); + w.c = c.into(); + w.d = d.into(); + } } w } @@ -1119,6 +1216,9 @@ pub fn decode_rep(w: &SurRepWire) -> Option<(DisplayResponse, u8)> { SHELL_ACTION => { DisplayResponse::ShellAction(ShellAction::from_wire(w.id.get(), w.a.get(), w.b.get())?) } + CLIPBOARD_TEXT if w.id.get() >> 56 == 0 => DisplayResponse::ClipboardText( + ClipboardText::from_words(w.id.get(), [w.a.get(), w.b.get(), w.c.get(), w.d.get()])?, + ), tag if tag & OUTPUT != 0 => { DisplayResponse::Output(kore_svc_output::wire::decode_rep(&OutRepWire { tag: tag & !OUTPUT, @@ -1193,6 +1293,10 @@ pub fn reply_matches(w: &SurReqWire, response: &DisplayResponse, grants: u8) -> DisplayResponse::Accepted, ) => true, (DisplayRequest::TakeShellAction, DisplayResponse::ShellAction(_)) => true, + ( + DisplayRequest::ReadClipboard { offset, .. }, + DisplayResponse::ClipboardText(text), + ) => text.offset == offset, (DisplayRequest::TakeAdmission, DisplayResponse::Admitted { .. }) => true, ( DisplayRequest::CancelAdmission, @@ -1220,7 +1324,8 @@ pub fn reply_matches(w: &SurReqWire, response: &DisplayResponse, grants: u8) -> | DisplayRequest::TakeShellAction | DisplayRequest::PrepareCapture { .. } | DisplayRequest::RequestCapture { .. } - | DisplayRequest::ReleaseCapture { .. }, + | DisplayRequest::ReleaseCapture { .. } + | DisplayRequest::ReadClipboard { .. }, DisplayResponse::ControlRefused(_), ) => true, (DisplayRequest::CancelAdmission, DisplayResponse::ControlRefused(_)) => true, @@ -1819,6 +1924,67 @@ mod tests { assert_eq!(decode_rep(&wire), None); } + /// A shell paste names its ticket; a clipboard read answers its own + /// offset with a piece inside its text, zero past its length, and + /// nothing else; and an unticketed or out-of-bound read refuses. + #[test] + fn a_shell_paste_reads_its_ticketed_text_and_fails_closed() { + let paste = DisplayResponse::ShellAction(Some(ShellAction::Paste { ticket: 0xfeed })); + assert_eq!(decode_rep(&encode_rep(&paste, 0)), Some((paste, 0))); + let mut bad = encode_rep(&paste, 0); + bad.a = 0.into(); + assert_eq!(decode_rep(&bad), None, "no ticket"); + bad.a = 1.into(); + bad.b = 1.into(); + assert_eq!(decode_rep(&bad), None, "a stray word"); + + let read = DisplayRequest::ReadClipboard { + ticket: 0xfeed, + offset: 32, + }; + let wire = encode_req(&read); + assert_eq!(decode_req(&wire), Some(read)); + for (ticket, offset) in [(0, 0), (1, crate::clipboard::CLIPBOARD_BYTES as u32)] { + assert_eq!( + decode_req(&encode_req(&DisplayRequest::ReadClipboard { ticket, offset })), + None, + "ticket {ticket} offset {offset}" + ); + } + let mut bytes = [0; CLIPBOARD_TEXT_BYTES]; + bytes[..5].copy_from_slice(b"ls -l"); + let text = ClipboardText { + offset: 32, + len: 5, + total: 37, + bytes, + }; + let answer = DisplayResponse::ClipboardText(text); + let reply = encode_rep(&answer, 0); + assert_eq!(decode_rep(&reply), Some((answer, 0))); + assert_eq!(text.text(), b"ls -l"); + assert!(reply_matches(&wire, &answer, 0)); + assert!(!reply_matches(&wire, &answer, 1)); + assert!(reply_matches(&wire, &DisplayResponse::ControlRefused(ControlRefusal::Stale), 0)); + let elsewhere = encode_req(&DisplayRequest::ReadClipboard { + ticket: 0xfeed, + offset: 0, + }); + assert!(!reply_matches(&elsewhere, &answer, 0)); + let mut tail = reply; + tail.d = (tail.d.get() | 1 << 56).into(); + assert_eq!(decode_rep(&tail), None, "a live byte past the piece"); + for (offset, len, total) in [(0u32, 0u16, 5u32), (0, 33, 64), (33, 5, 37), (0, 1, 0)] { + let mut bad = reply; + bad.id = (u64::from(offset) | u64::from(total) << 24 | u64::from(len) << 48).into(); + bad.a = 0.into(); + bad.b = 0.into(); + bad.c = 0.into(); + bad.d = 0.into(); + assert_eq!(decode_rep(&bad), None, "{offset} {len} {total}"); + } + } + #[test] fn the_ninth_dock_row_round_trips_publication_and_launch() { let request = DisplayRequest::PublishDockItem { diff --git a/native/crates/window/src/keysplit.rs b/native/crates/window/src/keysplit.rs new file mode 100644 index 0000000..2b3fc08 --- /dev/null +++ b/native/crates/window/src/keysplit.rs @@ -0,0 +1,315 @@ +//! The keys a terminal window keeps from its shell (`res/KEYBOARD.md` §8). +//! +//! A terminal's keys are its shell's line, except the chords that act on the +//! window itself: its [`TerminalKeys`] — by default Command+C to copy the +//! selection, Command+V to paste and Command+A to select all, as on a Mac, so +//! Control stays the shell's (Control+C interrupts), and Shift with Page Up, +//! Page Down, Home or End to scroll the window's history. With Command held +//! every key is the window's, bound or not: Command never types. +//! +//! A key belongs to whoever it was pressed for, until it is released: Command +//! let go while C is still held does not hand the shell a fresh `c`, and a +//! letter held down before Command was pressed stays the shell's. So the +//! split is a state machine over complete snapshots, one per source, run by +//! whatever stands between the keyboard and the line: input-stream for the +//! founded terminal, the terminal app for a program it hosts. + +use kore_abi::keyboard::{modifier, usage}; + +use crate::shortcut::{Chord, Keymap, Mods}; + +/// Keys one snapshot lists. +pub const SNAPSHOT_KEYS: usize = 6; + +/// What a terminal window does with a key it keeps. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TerminalAction { + /// Put the selection on the clipboard. + Copy, + /// Type the clipboard into the line. + Paste, + /// Select every row held. + SelectAll, + /// Scroll history a page back, a page forward, to its oldest row, or + /// back to the live grid. + PageUp, + PageDown, + Top, + Bottom, +} + +/// A terminal window's keys: the shortcuts it acts on, and whether Command +/// keeps every key from the line. Both sides of the split read one table — +/// input-stream, which takes the keys out of the founded shell's line, and +/// the window, which acts on them — so a keymap the user edits changes both +/// at once. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TerminalKeys { + pub keymap: Keymap, + /// With Command held, every key is the window's, bound or not. + pub command_reserved: bool, +} + +impl TerminalKeys { + pub const DEFAULT: Self = Self { + keymap: Keymap::new() + .with(Chord::new(usage::C, Mods::COMMAND), TerminalAction::Copy) + .with(Chord::new(usage::V, Mods::COMMAND), TerminalAction::Paste) + .with(Chord::new(usage::A, Mods::COMMAND), TerminalAction::SelectAll) + .with(Chord::new(usage::PAGE_UP, Mods::SHIFT), TerminalAction::PageUp) + .with(Chord::new(usage::PAGE_DOWN, Mods::SHIFT), TerminalAction::PageDown) + .with(Chord::new(usage::HOME, Mods::SHIFT), TerminalAction::Top) + .with(Chord::new(usage::END, Mods::SHIFT), TerminalAction::Bottom), + command_reserved: true, + }; + + /// Whether `key`, pressed with the report bitmap `modifiers` held, is the + /// window's rather than the line's. + pub fn owns(&self, key: u8, modifiers: u8) -> bool { + if key == 0 || modifier::bit(key).is_some() { + return false; + } + self.keymap.action(key, modifiers).is_some() + || (self.command_reserved && Mods::of(modifiers).contains(Mods::COMMAND)) + } +} + +impl Default for TerminalKeys { + fn default() -> Self { + Self::DEFAULT + } +} + +/// Sources a split tells apart at once: input-stream's own bound on the +/// keyboards one terminal reads (its `PrevTable`). A further source shares +/// the last place, which forgets its holder's keys as it changes hands — +/// degraded the way every per-source table there is, never dropped. +const SOURCES: usize = 4; + +/// What one source holds, by whom it was pressed for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Held { + source: u8, + window: [u8; SNAPSHOT_KEYS], + shell: [u8; SNAPSHOT_KEYS], + /// The window's view as last handed out: the modifiers and its keys. + shown: (u8, [u8; SNAPSHOT_KEYS]), +} + +impl Held { + /// A source as the window first knows it: nothing held, no modifiers — + /// the state a fresh window's keyboard starts in, so plain typing never + /// reaches the window at all. + const FRESH: Self = Self { + source: 0, + window: [0; SNAPSHOT_KEYS], + shell: [0; SNAPSHOT_KEYS], + shown: (0, [0; SNAPSHOT_KEYS]), + }; +} + +/// One snapshot, split. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Split { + /// The keys the shell's line reads: the snapshot without the window's. + pub shell: [u8; SNAPSHOT_KEYS], + /// The keys the window reads, with the snapshot's modifiers, when what + /// the window sees changed: a key of its own pressed or released, or a + /// modifier, which it needs for a Shift-click. + pub window: Option<[u8; SNAPSHOT_KEYS]>, +} + +/// The keys of `keys` that `side` holds, in their order, packed to the front +/// as a keyboard reports them. +fn pack(keys: [u8; SNAPSHOT_KEYS], side: &[u8; SNAPSHOT_KEYS]) -> [u8; SNAPSHOT_KEYS] { + let mut packed = [0; SNAPSHOT_KEYS]; + for (slot, key) in packed + .iter_mut() + .zip(keys.into_iter().filter(|key| *key != 0 && side.contains(key))) + { + *slot = key; + } + packed +} + +/// The split's state, per source, under one window's keys. +pub struct KeySplit { + keys: TerminalKeys, + held: [Held; SOURCES], + used: usize, +} + +impl KeySplit { + /// A split under `keys`: [`TerminalKeys::DEFAULT`] until the user's + /// keymap names others. + pub const fn new(keys: TerminalKeys) -> Self { + Self { + keys, + held: [Held::FRESH; SOURCES], + used: 0, + } + } + + /// `source`'s place in `held`, of which `used` are taken. + fn place<'h>(held: &'h mut [Held; SOURCES], used: &mut usize, source: u8) -> &'h mut Held { + let index = match held[..*used].iter().position(|held| held.source == source) { + Some(index) => index, + None => { + let index = (*used).min(SOURCES - 1); + *used = (*used + 1).min(SOURCES); + held[index] = Held { source, ..Held::FRESH }; + index + } + }; + &mut held[index] + } + + /// Split `source`'s complete snapshot: `modifiers` held, `keys` down. + pub fn split(&mut self, source: u8, modifiers: u8, keys: [u8; SNAPSHOT_KEYS]) -> Split { + let keys_owned = &self.keys; + let held = Self::place(&mut self.held, &mut self.used, source); + // A key no longer down is released, whoever held it. + for owned in held.window.iter_mut().chain(held.shell.iter_mut()) { + if *owned != 0 && !keys.contains(owned) { + *owned = 0; + } + } + // A key newly down is the owner's its press made it. + for &key in keys.iter().filter(|&&key| key != 0) { + if held.window.contains(&key) || held.shell.contains(&key) { + continue; + } + let side = if keys_owned.owns(key, modifiers) { + &mut held.window + } else { + &mut held.shell + }; + // The two sides partition one snapshot's keys, so a free place is + // always there. + if let Some(free) = side.iter_mut().find(|slot| **slot == 0) { + *free = key; + } + } + let shell = pack(keys, &held.shell); + let window = pack(keys, &held.window); + let view = (modifiers, window); + let changed = held.shown != view; + held.shown = view; + Split { + shell, + window: changed.then_some(window), + } + } + + /// A route boundary: whatever was held is forgotten, as the window + /// forgets it on the focus change that made the boundary. + pub fn reset(&mut self) { + *self = Self::new(self.keys); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use kore_abi::keyboard::modifier::{GUI, LEFT_GUI, LEFT_SHIFT}; + + const C: u8 = 0x06; + const V: u8 = 0x19; + const A: u8 = 0x04; + + fn keys_of(down: &[u8]) -> [u8; SNAPSHOT_KEYS] { + keys(down) + } + + fn keys(down: &[u8]) -> [u8; SNAPSHOT_KEYS] { + let mut keys = [0; SNAPSHOT_KEYS]; + keys[..down.len()].copy_from_slice(down); + keys + } + + /// Command chords and Shift with the scrolling keys are the window's; + /// everything else, Control chords included, is the shell's. + #[test] + fn the_window_keeps_command_chords_and_shifted_scrolling() { + let keys = TerminalKeys::DEFAULT; + assert!(keys.owns(C, LEFT_GUI)); + assert!(keys.owns(V, GUI)); + assert!(keys.owns(usage::PAGE_UP, LEFT_SHIFT)); + assert!(keys.owns(usage::END, LEFT_SHIFT | LEFT_GUI), "Command keeps every key"); + assert!(!keys.owns(usage::PAGE_UP, 0)); + assert!(!keys.owns(C, modifier::LEFT_CTRL)); + assert!(!keys.owns(A, LEFT_SHIFT)); + assert!(!keys.owns(usage::LEFT_GUI, LEFT_GUI), "a modifier's own usage is state"); + assert!(!keys.owns(0, LEFT_GUI)); + assert_eq!(keys.keymap.action(V, modifier::RIGHT_GUI), Some(TerminalAction::Paste)); + assert_eq!(keys.keymap.action(usage::HOME, LEFT_SHIFT), Some(TerminalAction::Top)); + } + + /// The split follows the keymap it was given: a user's binding takes its + /// key from the line, and without Command reserved, unbound Command keys + /// stay the line's. + #[test] + fn the_split_reads_the_keymap_it_was_given() { + let mut keys = TerminalKeys::DEFAULT; + keys.command_reserved = false; + keys.keymap + .bind(Chord::new(usage::INSERT, Mods::SHIFT).unwrap(), TerminalAction::Paste) + .unwrap(); + assert!(keys.owns(usage::INSERT, LEFT_SHIFT)); + assert!(!keys.owns(usage::Z, LEFT_GUI)); + let mut split = KeySplit::new(keys); + let paste = split.split(0, LEFT_SHIFT, keys_of(&[usage::INSERT])); + assert_eq!((paste.shell, paste.window), (keys_of(&[]), Some(keys_of(&[usage::INSERT])))); + } + + /// A key stays with whoever it was pressed for until it is released, and + /// the window hears its own keys and every modifier change, nothing else. + #[test] + fn a_key_belongs_to_whoever_it_was_pressed_for() { + let mut split = KeySplit::new(TerminalKeys::DEFAULT); + // `a` held, then Command: the `a` stays the shell's, and no Command+A. + let first = split.split(1, 0, keys(&[A])); + assert_eq!((first.shell, first.window), (keys(&[A]), None), "plain typing is the line's alone"); + let chord = split.split(1, LEFT_GUI, keys(&[A])); + assert_eq!((chord.shell, chord.window), (keys(&[A]), Some(keys(&[])))); + // Command+C, with `a` still down. + let copy = split.split(1, LEFT_GUI, keys(&[A, C])); + assert_eq!((copy.shell, copy.window), (keys(&[A]), Some(keys(&[C])))); + // Command let go while C is held: still the window's, no `c` typed. + let late = split.split(1, 0, keys(&[A, C])); + assert_eq!((late.shell, late.window), (keys(&[A]), Some(keys(&[C])))); + // Released: C pressed again now is the shell's. + let up = split.split(1, 0, keys(&[A])); + assert_eq!((up.shell, up.window), (keys(&[A]), Some(keys(&[])))); + let again = split.split(1, 0, keys(&[A, C])); + assert_eq!((again.shell, again.window), (keys(&[A, C]), None), "nothing changed for the window"); + // Another source is its own. + let other = split.split(2, LEFT_SHIFT, keys(&[usage::PAGE_UP])); + assert_eq!((other.shell, other.window), (keys(&[]), Some(keys(&[usage::PAGE_UP])))); + assert_eq!(split.split(1, 0, keys(&[A, C])).window, None); + } + + /// A source past the table's bound takes the last place, forgetting the + /// keys its holder had there rather than reading them as its own. + #[test] + fn a_source_past_the_bound_shares_the_last_place() { + let mut split = KeySplit::new(TerminalKeys::DEFAULT); + for source in 0..SOURCES as u8 { + let _ = split.split(source, LEFT_GUI, keys(&[C])); + } + let stranger = split.split(9, 0, keys(&[C])); + assert_eq!((stranger.shell, stranger.window), (keys(&[C]), None)); + let first = split.split(0, 0, keys(&[C])); + assert_eq!((first.shell, first.window), (keys(&[]), Some(keys(&[C]))), "the first sources keep theirs"); + } + + /// A boundary forgets every key, and the window hears the next state. + #[test] + fn a_reset_forgets_the_holders() { + let mut split = KeySplit::new(TerminalKeys::DEFAULT); + let _ = split.split(0, LEFT_GUI, keys(&[V])); + split.reset(); + let after = split.split(0, 0, keys(&[V])); + assert_eq!((after.shell, after.window), (keys(&[V]), None)); + } +} diff --git a/native/crates/window/src/lib.rs b/native/crates/window/src/lib.rs index b0e622d..98bc056 100644 --- a/native/crates/window/src/lib.rs +++ b/native/crates/window/src/lib.rs @@ -6,6 +6,7 @@ pub mod appearance; pub mod capture; #[cfg(feature = "runtime")] pub mod channel; +pub mod clipboard; #[cfg(feature = "runtime")] pub mod client; pub mod command; @@ -21,6 +22,8 @@ pub mod configure; pub mod control; #[cfg(feature = "runtime")] pub mod event; +pub mod keysplit; +pub mod shortcut; mod face; #[cfg(feature = "runtime")] pub mod relay; diff --git a/native/crates/window/src/relay.rs b/native/crates/window/src/relay.rs index 6ecf091..e6fe9a8 100644 --- a/native/crates/window/src/relay.rs +++ b/native/crates/window/src/relay.rs @@ -148,7 +148,25 @@ impl RelayProducer<'_> { /// Loss is explicit, bounded and nonblocking. While a Reset is pending, /// no snapshot is published, even if the consumer has freed ring slots. pub fn push(&mut self, focus: Focus, report: InputReport) -> bool { - if focus.target != FocusTarget::Window || focus.encode().is_none() || !keyboard(report) { + if focus.target != FocusTarget::Window { + return false; + } + self.publish(focus, report) + } + + /// On the terminal route, the keys its window keeps from the shell + /// ([`crate::keysplit`]): the same snapshot ring and loss rule, tagged + /// with the terminal route's epoch, so the compositor hands them to the + /// terminal window and to nothing a later route focuses. + pub fn push_window_keys(&mut self, focus: Focus, report: InputReport) -> bool { + if focus.target != FocusTarget::Terminal { + return false; + } + self.publish(focus, report) + } + + fn publish(&mut self, focus: Focus, report: InputReport) -> bool { + if focus.encode().is_none() || !keyboard(report) { return false; } if self.cell.reset_epoch.load(Ordering::Acquire) == 0 @@ -546,6 +564,7 @@ mod tests { malformed.data[0] = 1; assert!(!input.reports.push(focus, malformed)); assert!(!input.reports.push(Focus { epoch: 0, ..focus }, report)); + assert!(!input.reports.push_window_keys(focus, report), "a window route's keys are pushed whole"); assert_eq!(input.reports.dropped(), 0); assert!(!input.reports.needs_wake()); assert!(!compositor.reports.pending()); @@ -553,6 +572,25 @@ mod tests { assert_eq!(compositor.echo.pop(), None); } + /// The terminal route's window keys ride the ring under its own epoch. + #[test] + fn a_terminal_routes_window_keys_ride_under_its_epoch() { + let mut page = Page([0; 4096]); + let (mut input, mut compositor) = split(&mut page.0).unwrap(); + let chord = InputReport::keyboard(1, kore_abi::keyboard::modifier::LEFT_GUI, [6, 0, 0, 0, 0, 0]); + assert!(input.reports.push_window_keys(Focus::INITIAL, chord)); + assert_eq!( + compositor.reports.poll(), + Some(RelayEvent::Snapshot { + epoch: Focus::INITIAL.epoch, + report: chord + }) + ); + let mut malformed = chord; + malformed.data[0] = 1; + assert!(!input.reports.push_window_keys(Focus::INITIAL, malformed)); + } + /// The seat's lock state rides the snapshot to the window unchanged — a /// client resolves Caps Lock exactly as the terminal discipline does. #[test] diff --git a/native/crates/window/src/shortcut.rs b/native/crates/window/src/shortcut.rs new file mode 100644 index 0000000..a7535cc --- /dev/null +++ b/native/crates/window/src/shortcut.rs @@ -0,0 +1,283 @@ +//! Shortcuts as data (`res/KEYBOARD.md` §8): a [`Chord`] — one key and the +//! modifiers held with it — bound to an action in a [`Keymap`]. +//! +//! A window's shortcuts are a table it reads, never a match over usages: the +//! same table answers what a key does, what its menu says the shortcut is, +//! and — for a terminal — which keys leave the shell's line at all +//! ([`crate::keysplit`]). A keymap the user edits (Settings ▸ Keyboard & +//! Pointer) replaces a table; nothing that reads one changes. +//! +//! Modifiers are side-agnostic: either Shift satisfies a chord's Shift. A +//! chord matches its modifiers exactly, so Command+Shift+C is not Command+C. +//! Locks never take part: Caps Lock changes what a letter types, not which +//! shortcut its key is. + +use core::fmt; + +use kore_abi::keyboard::{modifier, usage}; + +/// The modifiers a chord needs, left and right alike. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Mods(u8); + +impl Mods { + pub const NONE: Self = Self(0); + pub const CTRL: Self = Self(1); + pub const SHIFT: Self = Self(1 << 1); + pub const ALT: Self = Self(1 << 2); + /// The GUI key: Command on a Mac keyboard, the Super (Windows) key on a + /// PC's. + pub const COMMAND: Self = Self(1 << 3); + + /// The modifiers a report's bitmap holds. + pub const fn of(modifiers: u8) -> Self { + const fn held(modifiers: u8, mask: u8, bit: Mods) -> u8 { + if modifiers & mask != 0 { bit.0 } else { 0 } + } + Self( + held(modifiers, modifier::CTRL, Self::CTRL) + | held(modifiers, modifier::SHIFT, Self::SHIFT) + | held(modifiers, modifier::ALT, Self::ALT) + | held(modifiers, modifier::GUI, Self::COMMAND), + ) + } + + pub const fn with(self, other: Self) -> Self { + Self(self.0 | other.0) + } + + pub const fn contains(self, other: Self) -> bool { + self.0 & other.0 == other.0 + } +} + +/// One key and the modifiers held with it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Chord { + key: u8, + mods: Mods, +} + +impl Chord { + /// `key` (a Keyboard/Keypad-page usage) with `mods` held. `None` for no + /// key or a modifier's own usage: a modifier is held with a chord, never + /// its key. + pub const fn new(key: u8, mods: Mods) -> Option { + if key == 0 || modifier::bit(key).is_some() { + return None; + } + Some(Self { key, mods }) + } + + pub const fn key(self) -> u8 { + self.key + } + + pub const fn mods(self) -> Mods { + self.mods + } + + /// Whether `key`, pressed with the report bitmap `modifiers` held, is + /// this chord. + pub const fn matches(self, key: u8, modifiers: u8) -> bool { + self.key == key && Mods::of(modifiers).0 == self.mods.0 + } + + /// The chord as a menu or the settings page spells it: `Cmd+C`, + /// `Shift+Page Up`. + pub fn write_label(self, out: &mut impl fmt::Write) -> fmt::Result { + for (bit, name) in [ + (Mods::CTRL, "Ctrl"), + (Mods::ALT, "Alt"), + (Mods::SHIFT, "Shift"), + (Mods::COMMAND, "Cmd"), + ] { + if self.mods.contains(bit) { + write!(out, "{name}+")?; + } + } + match self.key { + usage::A..=usage::Z => out.write_char(char::from(b'A' + (self.key - usage::A))), + usage::DIGIT_1..=usage::DIGIT_9 => out.write_char(char::from(b'1' + (self.key - usage::DIGIT_1))), + usage::DIGIT_0 => out.write_char('0'), + usage::F1..=usage::F12 => write!(out, "F{}", self.key - usage::F1 + 1), + key => out.write_str(match key { + usage::ENTER => "Enter", + usage::ESCAPE => "Esc", + usage::BACKSPACE => "Backspace", + usage::TAB => "Tab", + usage::SPACE => "Space", + usage::INSERT => "Insert", + usage::HOME => "Home", + usage::PAGE_UP => "Page Up", + usage::DELETE => "Delete", + usage::END => "End", + usage::PAGE_DOWN => "Page Down", + usage::RIGHT => "Right", + usage::LEFT => "Left", + usage::DOWN => "Down", + usage::UP => "Up", + _ => "Key", + }), + } + } +} + +/// The most bindings one keymap holds: a window's shortcuts, with room for a +/// user's own. +pub const MAX_BINDINGS: usize = 16; + +/// A keymap is full. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct KeymapFull; + +/// Chords bound to a window's actions: one action per chord, any number of +/// chords per action. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Keymap { + bindings: [Option<(Chord, A)>; MAX_BINDINGS], +} + +impl Default for Keymap { + fn default() -> Self { + Self::new() + } +} + +impl Keymap { + pub const fn new() -> Self { + Self { + bindings: [None; MAX_BINDINGS], + } + } + + /// This keymap with `chord` bound to `action`, for a table built as a + /// constant; a chord already bound or no room left is a build error. + pub const fn with(mut self, chord: Option, action: A) -> Self { + let Some(chord) = chord else { + panic!("a binding names a key"); + }; + let mut free = None; + let mut index = 0; + while index < MAX_BINDINGS { + match self.bindings[index] { + Some((bound, _)) if bound.key == chord.key && bound.mods.0 == chord.mods.0 => { + panic!("a chord is bound once"); + } + None if free.is_none() => free = Some(index), + _ => {} + } + index += 1; + } + let Some(free) = free else { + panic!("the keymap is full"); + }; + self.bindings[free] = Some((chord, action)); + self + } +} + +impl Keymap { + /// Bind `chord` to `action`, replacing what it was bound to. + pub fn bind(&mut self, chord: Chord, action: A) -> Result<(), KeymapFull> { + if let Some(bound) = self.bindings.iter_mut().flatten().find(|(bound, _)| *bound == chord) { + bound.1 = action; + return Ok(()); + } + let free = self.bindings.iter_mut().find(|slot| slot.is_none()).ok_or(KeymapFull)?; + *free = Some((chord, action)); + Ok(()) + } + + /// Leave `chord` unbound. + pub fn unbind(&mut self, chord: Chord) { + for slot in &mut self.bindings { + if slot.is_some_and(|(bound, _)| bound == chord) { + *slot = None; + } + } + } + + /// What `key` pressed with the report bitmap `modifiers` held does. + pub fn action(&self, key: u8, modifiers: u8) -> Option { + self.bindings + .iter() + .flatten() + .find(|(chord, _)| chord.matches(key, modifiers)) + .map(|&(_, action)| action) + } + + /// The chord a menu shows for `action`: the first bound to it. + pub fn chord(&self, action: A) -> Option { + self.bindings + .iter() + .flatten() + .find(|(_, bound)| *bound == action) + .map(|&(chord, _)| chord) + } +} + +#[cfg(test)] +mod tests { + extern crate std; + use super::*; + use std::string::String; + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum Act { + Copy, + Paste, + } + + const MAP: Keymap = Keymap::new() + .with(Chord::new(usage::C, Mods::COMMAND), Act::Copy) + .with(Chord::new(usage::V, Mods::COMMAND), Act::Paste) + .with(Chord::new(usage::INSERT, Mods::SHIFT), Act::Paste); + + /// A chord matches its modifiers exactly, either side's, and no lock. + #[test] + fn a_chord_matches_its_modifiers_exactly_either_side() { + assert_eq!(MAP.action(usage::C, modifier::LEFT_GUI), Some(Act::Copy)); + assert_eq!(MAP.action(usage::C, modifier::RIGHT_GUI), Some(Act::Copy)); + assert_eq!(MAP.action(usage::C, modifier::LEFT_GUI | modifier::LEFT_SHIFT), None); + assert_eq!(MAP.action(usage::C, modifier::LEFT_CTRL), None); + assert_eq!(MAP.action(usage::INSERT, modifier::RIGHT_SHIFT), Some(Act::Paste)); + assert_eq!(Chord::new(usage::LEFT_SHIFT, Mods::NONE), None, "a modifier is never a chord's key"); + assert_eq!(Chord::new(0, Mods::COMMAND), None); + } + + /// Bindings change as data: rebinding replaces, unbinding removes, and a + /// menu reads the chord an action is bound to. + #[test] + fn a_keymap_is_rebound_as_data() { + let mut map = MAP; + assert_eq!(map.chord(Act::Paste), Chord::new(usage::V, Mods::COMMAND)); + let ctrl_c = Chord::new(usage::C, Mods::CTRL.with(Mods::SHIFT)).unwrap(); + map.bind(ctrl_c, Act::Copy).unwrap(); + assert_eq!(map.action(usage::C, modifier::LEFT_CTRL | modifier::LEFT_SHIFT), Some(Act::Copy)); + map.bind(ctrl_c, Act::Paste).unwrap(); + assert_eq!(map.action(usage::C, modifier::LEFT_CTRL | modifier::LEFT_SHIFT), Some(Act::Paste)); + map.unbind(Chord::new(usage::V, Mods::COMMAND).unwrap()); + assert_eq!(map.action(usage::V, modifier::LEFT_GUI), None); + assert_eq!(map.chord(Act::Paste), Chord::new(usage::INSERT, Mods::SHIFT)); + let mut full = Keymap::::new(); + for key in 0..MAX_BINDINGS as u8 { + full.bind(Chord::new(usage::A + key, Mods::COMMAND).unwrap(), Act::Copy).unwrap(); + } + assert_eq!(full.bind(Chord::new(usage::F1, Mods::NONE).unwrap(), Act::Copy), Err(KeymapFull)); + } + + /// A chord spells itself as menus show it. + #[test] + fn a_chord_labels_itself() { + let label = |chord: Option| { + let mut text = String::new(); + chord.unwrap().write_label(&mut text).unwrap(); + text + }; + assert_eq!(label(Chord::new(usage::C, Mods::COMMAND)), "Cmd+C"); + assert_eq!(label(Chord::new(usage::PAGE_UP, Mods::SHIFT)), "Shift+Page Up"); + assert_eq!(label(Chord::new(usage::DIGIT_0, Mods::CTRL.with(Mods::ALT))), "Ctrl+Alt+0"); + assert_eq!(label(Chord::new(usage::F1 + 11, Mods::NONE)), "F12"); + } +} diff --git a/native/crates/window/src/wire.rs b/native/crates/window/src/wire.rs index 3581b10..0432959 100644 --- a/native/crates/window/src/wire.rs +++ b/native/crates/window/src/wire.rs @@ -16,6 +16,7 @@ use kore_abi::session::DISPLAY_FORMAT_XRGB8888; use kore_abi::svc::{CommitWire, MAX_COMMIT_RECTS, SurRectWire, SurRepWire, SurReqWire}; use crate::capture::{CaptureFrame, CaptureMode}; +use crate::clipboard::{CHUNK_BYTES, CLIPBOARD_BYTES}; use crate::cursor::Cursor; use crate::document::DocumentKind; use crate::menu::MENU_PLANE_BYTES; @@ -210,6 +211,21 @@ pub enum SurfaceRequest { /// stable identity, or zero if the user cancelled; its name is the /// first `len` bytes of this window's menu plane (none when cancelled). Picked { token: u64, object: u64, len: u8 }, + /// Put text on this window's seat's clipboard ([`crate::clipboard`]), a + /// piece at a time: bytes `offset..offset + len` of a `total`-byte text + /// are at the head of this window's request plane. Offset zero begins the + /// copy (focused window, unspent gesture); the piece that reaches `total` + /// ends it, and the text then replaces the clipboard's. + CopyChunk { offset: u32, len: u16, total: u32 }, + /// Read the seat's clipboard from `offset` into this window's request + /// plane. Offset zero begins the read (focused window, unspent gesture); + /// later offsets continue it until it reaches the end. + Paste { offset: u32 }, + /// The seat's terminal window asks for the clipboard to be typed into its + /// shell's line: queued as an inert + /// [`ShellAction::Paste`](crate::control::ShellAction::Paste) for its + /// controller (focused window, unspent gesture). + PasteToShell, } /// The surface-class reply vocabulary. @@ -263,6 +279,17 @@ pub enum SurfaceResponse { /// The answer is delivered and the pick has ended; the plane may be /// reused. The token, echoed. PickAnswered { token: u64 }, + /// The copy has taken the text through `through`; when that is the whole + /// text, the text is the seat's clipboard. + Copied { through: u32 }, + /// Bytes `offset..offset + len` of the clipboard's `total`-byte text are + /// at the head of the request plane. + Clipboard { offset: u32, len: u16, total: u32 }, + /// The seat's clipboard holds no text. + ClipboardEmpty, + /// The shell paste is queued for this window's controller; the text is + /// `total` bytes. + PasteQueued { total: u32 }, /// The refusal, echoing the subject the verb named (zero if none). Refused { refusal: SurfaceRefusal, @@ -288,6 +315,9 @@ pub enum SurfaceRefusal { Unknown, /// Authority delivery or revocation has not settled; retry is safe. Busy, + /// The user did not ask: a clipboard verb from a window that is not its + /// seat's focused window, or with no unspent gesture behind it. + Denied, } impl SurfaceRefusal { @@ -299,6 +329,7 @@ impl SurfaceRefusal { Self::OutOfMemory => SurRepWire::REFUSE_OUT_OF_MEMORY, Self::Unknown => SurRepWire::REFUSE_UNKNOWN, Self::Busy => SurRepWire::REFUSE_BUSY, + Self::Denied => SurRepWire::REFUSE_DENIED, } } @@ -310,6 +341,7 @@ impl SurfaceRefusal { SurRepWire::REFUSE_OUT_OF_MEMORY => Self::OutOfMemory, SurRepWire::REFUSE_UNKNOWN => Self::Unknown, SurRepWire::REFUSE_BUSY => Self::Busy, + SurRepWire::REFUSE_DENIED => Self::Denied, _ => return None, }) } @@ -457,10 +489,49 @@ pub fn encode_req(req: &SurfaceRequest) -> SurReqWire { w.b = U64::new(object); w.c = U64::new(u64::from(len)); } + SurfaceRequest::CopyChunk { offset, len, total } => { + w.op = SurReqWire::OP_COPY_CHUNK; + w.a = U64::new(piece_word(offset, len)); + w.b = U64::new(u64::from(total)); + } + SurfaceRequest::Paste { offset } => { + w.op = SurReqWire::OP_PASTE; + w.a = U64::new(u64::from(offset)); + } + SurfaceRequest::PasteToShell => { + w.op = SurReqWire::OP_PASTE_TO_SHELL; + } } w } +/// The piece word a copy and a paste share: `offset | len << 32`. +const fn piece_word(offset: u32, len: u16) -> u64 { + offset as u64 | (len as u64) << 32 +} + +/// A clipboard text's length: 1 to [`CLIPBOARD_BYTES`]. +fn text_length(word: u64) -> Option { + u32::try_from(word) + .ok() + .filter(|&len| len != 0 && len as usize <= CLIPBOARD_BYTES) +} + +/// A piece of a clipboard text, lifted: `offset..offset + len` of `total` +/// bytes, `len` 1 to [`CHUNK_BYTES`] and `total` 1 to [`CLIPBOARD_BYTES`]. +fn piece(word: u64, total: u64) -> Option<(u32, u16, u32)> { + let (offset, len) = (word as u32, (word >> 32) as u16); + let total = u32::try_from(total).ok()?; + let fits = u64::from(offset) + u64::from(len) <= u64::from(total); + (word >> 48 == 0 + && len != 0 + && usize::from(len) <= CHUNK_BYTES + && total != 0 + && total as usize <= CLIPBOARD_BYTES + && fits) + .then_some((offset, len, total)) +} + /// Structural lift; stray bits are the re-encode comparison's to catch. fn raw_decode_req(w: &SurReqWire) -> Option { Some(match w.op { @@ -532,6 +603,18 @@ fn raw_decode_req(w: &SurReqWire) -> Option { } SurfaceRequest::Picked { token, object, len } } + SurReqWire::OP_COPY_CHUNK => { + let (offset, len, total) = piece(w.a.get(), w.b.get())?; + SurfaceRequest::CopyChunk { offset, len, total } + } + SurReqWire::OP_PASTE => { + let offset = u32::try_from(w.a.get()).ok()?; + if offset as usize >= CLIPBOARD_BYTES { + return None; + } + SurfaceRequest::Paste { offset } + } + SurReqWire::OP_PASTE_TO_SHELL => SurfaceRequest::PasteToShell, _ => return None, }) } @@ -674,6 +757,22 @@ pub fn encode_rep(rep: &SurfaceResponse, grants: u8) -> SurRepWire { w.tag = SurRepWire::TAG_PICK_ANSWERED; w.a = U64::new(token); } + SurfaceResponse::Copied { through } => { + w.tag = SurRepWire::TAG_COPIED; + w.a = U64::new(u64::from(through)); + } + SurfaceResponse::Clipboard { offset, len, total } => { + w.tag = SurRepWire::TAG_CLIPBOARD; + w.a = U64::new(piece_word(offset, len)); + w.b = U64::new(u64::from(total)); + } + SurfaceResponse::ClipboardEmpty => { + w.tag = SurRepWire::TAG_CLIPBOARD_EMPTY; + } + SurfaceResponse::PasteQueued { total } => { + w.tag = SurRepWire::TAG_PASTE_QUEUED; + w.a = U64::new(u64::from(total)); + } SurfaceResponse::Refused { refusal, subject } => { w.tag = SurRepWire::TAG_REFUSED; w.refusal = refusal.code(); @@ -751,6 +850,17 @@ fn raw_decode_rep(w: &SurRepWire) -> Option { }, SurRepWire::TAG_PICK_CLAIMED => SurfaceResponse::PickClaimed { token: w.a.get() }, SurRepWire::TAG_PICK_ANSWERED => SurfaceResponse::PickAnswered { token: w.a.get() }, + SurRepWire::TAG_COPIED => SurfaceResponse::Copied { + through: text_length(w.a.get())?, + }, + SurRepWire::TAG_CLIPBOARD => { + let (offset, len, total) = piece(w.a.get(), w.b.get())?; + SurfaceResponse::Clipboard { offset, len, total } + } + SurRepWire::TAG_CLIPBOARD_EMPTY => SurfaceResponse::ClipboardEmpty, + SurRepWire::TAG_PASTE_QUEUED => SurfaceResponse::PasteQueued { + total: text_length(w.a.get())?, + }, SurRepWire::TAG_REFUSED => SurfaceResponse::Refused { refusal: SurfaceRefusal::from_code(w.refusal)?, subject: w.id.get(), @@ -867,6 +977,20 @@ pub fn reply_matches(request: &SurReqWire, response: &SurfaceResponse, grants: u | (SurfaceRequest::Picked { token, .. }, SurfaceResponse::PickAnswered { token: got }) => { token == got } + // The copy echoes how far it has taken the text: exactly to the end + // of the piece just sent. + (SurfaceRequest::CopyChunk { offset, len, .. }, SurfaceResponse::Copied { through }) => { + u64::from(through) == u64::from(offset) + u64::from(len) + } + // A read answers from the offset asked, and an empty clipboard + // answers only the read's first piece: a read under way that finds + // the clipboard changed is refused instead. + (SurfaceRequest::Paste { offset }, SurfaceResponse::Clipboard { offset: got, .. }) => { + offset == got + } + (SurfaceRequest::Paste { offset }, SurfaceResponse::ClipboardEmpty) => offset == 0, + (SurfaceRequest::PasteToShell, SurfaceResponse::PasteQueued { .. }) => true, + (SurfaceRequest::PasteToShell, SurfaceResponse::ClipboardEmpty) => true, _ => false, } } @@ -1167,7 +1291,7 @@ mod tests { } } - fn every_request() -> [SurfaceRequest; 14] { + fn every_request() -> [SurfaceRequest; 19] { [ SurfaceRequest::Query, SurfaceRequest::CreateSurface { @@ -1217,10 +1341,25 @@ mod tests { object: u64::MAX, len: PICK_NAME_BYTES as u8, }, + SurfaceRequest::CopyChunk { + offset: 0, + len: 1, + total: 1, + }, + SurfaceRequest::CopyChunk { + offset: (CLIPBOARD_BYTES - CHUNK_BYTES) as u32, + len: CHUNK_BYTES as u16, + total: CLIPBOARD_BYTES as u32, + }, + SurfaceRequest::Paste { offset: 0 }, + SurfaceRequest::Paste { + offset: CLIPBOARD_BYTES as u32 - 1, + }, + SurfaceRequest::PasteToShell, ] } - fn every_response() -> [SurfaceResponse; 13] { + fn every_response() -> [SurfaceResponse; 19] { [ SurfaceResponse::Info { width: 1920, @@ -1260,9 +1399,104 @@ mod tests { SurfaceResponse::PickQueued { serial: 3 }, SurfaceResponse::PickClaimed { token: 0x7071_ce4e }, SurfaceResponse::PickAnswered { token: 0x7071_ce4e }, + SurfaceResponse::Copied { through: 1 }, + SurfaceResponse::Copied { + through: CLIPBOARD_BYTES as u32, + }, + SurfaceResponse::Clipboard { + offset: 4096, + len: 17, + total: 4113, + }, + SurfaceResponse::ClipboardEmpty, + SurfaceResponse::PasteQueued { total: 9 }, + SurfaceResponse::Refused { + refusal: SurfaceRefusal::Denied, + subject: 0, + }, ] } + /// A clipboard piece names a span inside its text, the text inside the + /// clipboard's bound and the piece inside the plane; nothing crosses + /// with a grant; and each reply answers only the verb and offset asked. + #[test] + fn clipboard_pieces_stay_inside_their_text_and_answer_their_own_offset() { + let copy = SurfaceRequest::CopyChunk { + offset: 4096, + len: 100, + total: 4196, + }; + let wire = encode_req(©); + assert_eq!(SurfaceWire::request_grants(&wire), 0); + assert_eq!(decode_req(&wire), Some(copy)); + assert!(reply_matches(&wire, &SurfaceResponse::Copied { through: 4196 }, 0)); + assert!(!reply_matches(&wire, &SurfaceResponse::Copied { through: 4096 }, 0)); + assert!(!reply_matches(&wire, &SurfaceResponse::Copied { through: 4196 }, 1)); + for (offset, len, total) in [ + (0, 0, 1), + (0, 1, 0), + (1, 1, 1), + (0, CHUNK_BYTES as u16 + 1, CLIPBOARD_BYTES as u32), + (0, 1, CLIPBOARD_BYTES as u32 + 1), + (u32::MAX, 1, CLIPBOARD_BYTES as u32), + ] { + let wire = encode_req(&SurfaceRequest::CopyChunk { offset, len, total }); + assert_eq!(decode_req(&wire), None, "{offset} {len} {total}"); + } + let mut stray = encode_req(©); + stray.a = (stray.a.get() | 1 << 50).into(); + assert_eq!(decode_req(&stray), None); + + let read = encode_req(&SurfaceRequest::Paste { offset: 0 }); + let piece = SurfaceResponse::Clipboard { + offset: 0, + len: 12, + total: 12, + }; + assert!(reply_matches(&read, &piece, 0)); + assert!(reply_matches(&read, &SurfaceResponse::ClipboardEmpty, 0)); + let later = encode_req(&SurfaceRequest::Paste { offset: 12 }); + assert!(!reply_matches(&later, &piece, 0)); + assert!( + !reply_matches(&later, &SurfaceResponse::ClipboardEmpty, 0), + "a read under way never learns the clipboard emptied: it is refused" + ); + let past = encode_req(&SurfaceRequest::Paste { + offset: CLIPBOARD_BYTES as u32, + }); + assert_eq!(decode_req(&past), None); + for piece in [ + (0, 13, 12), + (0, 0, 12), + (0, CHUNK_BYTES as u16 + 1, CLIPBOARD_BYTES as u32), + ] { + let mut w = encode_rep(&SurfaceResponse::ClipboardEmpty, 0); + w.tag = SurRepWire::TAG_CLIPBOARD; + w.a = piece_word(piece.0, piece.1).into(); + w.b = u64::from(piece.2).into(); + assert_eq!(decode_rep(&w), None, "{piece:?}"); + } + + let shell = encode_req(&SurfaceRequest::PasteToShell); + assert!(reply_matches(&shell, &SurfaceResponse::PasteQueued { total: 5 }, 0)); + assert!(reply_matches(&shell, &SurfaceResponse::ClipboardEmpty, 0)); + assert!(!reply_matches(&shell, &SurfaceResponse::Copied { through: 5 }, 0)); + let denied = SurfaceResponse::Refused { + refusal: SurfaceRefusal::Denied, + subject: 0, + }; + for request in [copy, SurfaceRequest::Paste { offset: 0 }, SurfaceRequest::PasteToShell] { + assert!(reply_matches(&encode_req(&request), &denied, 0)); + } + for zero in [ + SurfaceResponse::Copied { through: 0 }, + SurfaceResponse::PasteQueued { total: 0 }, + ] { + assert_eq!(decode_rep(&encode_rep(&zero, 0)), None); + } + } + /// A pick is answered only by its own serial's echo, a claim and an /// answer only by their token's, none carries a grant, and a pick of no /// types, a zero serial or token, or an answer whose name contradicts diff --git a/native/provenance.json b/native/provenance.json index a203a69..732d0e6 100644 --- a/native/provenance.json +++ b/native/provenance.json @@ -1,6 +1,6 @@ { "repository": "https://github.com/MicroPerceptron/kore", - "revision": "4a56f8279c79fd4d65918e581fe382f0775dfb51", + "revision": "eb59823a29cc9b0cfe0ea27a386c3fac266534ef", "packages": { "crates/abi": "kore-abi", "crates/app": "kore-app", @@ -64,7 +64,7 @@ "crates/abi/src/processor_status.rs": "c963e48f9b07046ab6c82d4cc2735ee2cc6c94561509a4e6e15927c837c0b0c6", "crates/abi/src/session.rs": "8679f287e28556b1caff70126e98cc1b99d76dd9489271f8cc0973990bbd46a1", "crates/abi/src/stor.rs": "eabad4e5588fd343472660e15cccf32cfcc8d8f4c25eb18dffbd1598b6007db2", - "crates/abi/src/svc.rs": "12749268a416577521bf1762cabe0cece8b4adae16572beb13ea3f8228539be3", + "crates/abi/src/svc.rs": "2a1ee57ffd5db82121a1f05cae03e24981cdae62a5e9e6c7c8168b84d65a705f", "crates/abi/src/task.rs": "1e403147de46734c9f217be6d5fc8edf4dd826d49385a7d4628d0773cd2a0acf", "crates/abi/src/uring.rs": "e98d9db77fbe00d5547b582700a1104d2c5b2546bbe57742dfff8362c32dcc2e", "crates/app/Cargo.toml": "fb793b25eec583da7b5e404b19bd656a6b8bcee2a86a20b0da399be2210a9398", @@ -108,23 +108,24 @@ "crates/dagfs/src/entry.rs": "ae077f13df5f0949fbc7c867b5f026aa494843ae605dde4fda845c2a531c33a3", "crates/dagfs/src/error.rs": "f8b93e779e3cc7966db4eaee3098fac4d3615d89441a6fc7ed34061eb6cbaf88", "crates/dagfs/src/face.rs": "b22d6f071397395a8ffda7487f5b876a03fc39590e4a1adefea622d5ae737bc9", - "crates/dagfs/src/fs.rs": "0e0f98c347c6e71363e7f022445e1dc3ccfe6921d2e9ad94f092fcc0296ce1b3", + "crates/dagfs/src/fs.rs": "de64b3e50009141d1be13e6cab3f510ab05cd4ea3d3814642ba2a3b39d388549", "crates/dagfs/src/graph.rs": "833a50a1e6a722ac0d5cefb34b101b9553a9ddd59a5c3d7af0fb723141a063c3", "crates/dagfs/src/ids.rs": "a6cd3127adbbfb3e02c7467f5736e2cabe6b53b9047d98f1cee2ba5de2c78227", "crates/dagfs/src/index.rs": "cd20892b18859bcdf19f63152f994003556bc048b0b0de3816bce250e99d9b81", "crates/dagfs/src/io.rs": "a91034153e6f82d32c9e5b5c8ce21f8b53f35c96adaeb2d507b56b07c75ea577", - "crates/dagfs/src/lib.rs": "aee6c0dd3bec2d44667691a110623a4526a50ff1af3133869b2c7b110e578132", + "crates/dagfs/src/lib.rs": "9d01b5ebc80ba67c7698670e3e9da5c2386d7ba2b4cbe16e3ed2ad4538470bd9", "crates/dagfs/src/name.rs": "a59b8464b96e3a4bb65bd1056e3db5ea80bc3a99da528624ee77757cff4efaf9", "crates/dagfs/src/object.rs": "7c6821771008a5f83b45d8fa53fd9aa00bba405f7a26abec2274962adc1cc00c", "crates/dagfs/src/path.rs": "db0bef7330be961a483e7582df14647d043f2a8fe722eb6320ece488d2fc243d", - "crates/dagfs/src/protocol.rs": "e9a35a3a2d28b1b09e44dc45200323fa258c3ef240d5494afd0b07ea19e17ece", + "crates/dagfs/src/protocol.rs": "de2613cacd587fe64bf613ff5d6de53ed8ccb403d5278bccadc7666e484512f4", "crates/dagfs/src/refusal.rs": "71d612406e9a86509056e6a755e3b3530396232007790588868e8756a5797fe8", "crates/dagfs/src/search.rs": "cfc79463a6367d2fc62a9598ca3fe20d6771c3d9aad07709192da93338a9e4ab", "crates/dagfs/src/stor.rs": "06a270d01e2f3a2dcdb41f803926d863d5fca6229aa14e0cc84337c651acdddb", - "crates/dagfs/src/text.rs": "878ee0abc41bfd30ece0f0a7c6be5935487fb945248773d3eebda8066f8f6032", + "crates/dagfs/src/text.rs": "56517a357cc933ff0f485a8a250d2177baa567d95521b107cc38b558101e7119", "crates/dagfs/src/view.rs": "24d661589ce1bb05e79112cda5ce1027166c7794adc3bcbcfdb3d63e663dfd69", "crates/dagfs/src/walk.rs": "3903476532c8f0933a0db3bf7cae9075c6db8dc06953b479709497329ed7e6a4", - "crates/dagfs/src/wire.rs": "3b5da5293e34e8a6c8b6133228e32b5477c6fef8550c58755ec174b7734feaee", + "crates/dagfs/src/watch.rs": "5070799a20aa65f1c074ec6f605973841547bc378731ea667ee48aac28ab995f", + "crates/dagfs/src/wire.rs": "1777e39074f4da19def29359ce4fe7d079377e4b6911489e37ab540959a2b802", "crates/driver/Cargo.toml": "15154ecb6517360f34580dba34799de5a38d546d8be8a64a626562c59e25e158", "crates/driver/src/accel.rs": "c2355916f1f6647d4bcf8414806c3bb9da45799e458133da70e31190316febfb", "crates/driver/src/block/image.rs": "5bb433b99cafc1a51812469ef8ea637dcc84ad2d2b8a79a3385fb8972884ffb8", @@ -248,7 +249,7 @@ "crates/rt/src/reserve.rs": "b2a9cb91873cc404555818d66740e54d4517d7676ad1c1efb620cfd3e43fcdc0", "crates/rt/src/room/mod.rs": "c6f56dda5064ebe75ee85ef122d82c81a507f3fe498b6f9a8463a977a0566c04", "crates/rt/src/room/x86_64.rs": "122e283074fa754a49c73beb733a92660a5d8c53701b5223297ded797c61fc70", - "crates/rt/src/service/mod.rs": "b59017ca341af1f2267dfc1b06d1f11a6e505255b8c327bccee1dfe5d04356fe", + "crates/rt/src/service/mod.rs": "2f31433c345c77bb773036ec1ac259d0a81e74df3b8b24fba0769cc31d50cf8b", "crates/rt/src/session/builder.rs": "4bea448f727d93bca9f956d7d4b5dda6982390ea616d06fb42b0804281e803b1", "crates/rt/src/session/flows.rs": "7205afbbe1d859868b313a24dccff8902e6ba3c1c8c45031e9531f6286259cae", "crates/rt/src/session/mod.rs": "466c2fbd8af228b3277075ad90a6d81031c1cb0a4c877f75669fff80e15338ea", @@ -261,7 +262,7 @@ "crates/rt/src/svc/client.rs": "062b52a0544eb11b9d4f8a938ebe75299bfd6c2ff7f42a1d8d21e8cb487c16d7", "crates/rt/src/svc/mod.rs": "3c62c81946943268c24aee3a0a8e3778442a5475dc7bf3bdb42780aeaaa958e9", "crates/rt/src/svc/recyclable.rs": "9e5af8ad316bdd89e001fef7fbb6914b5a936dcdfbab2122740e03c1cf0ee7ec", - "crates/rt/src/svc/serve.rs": "a22172638a94e3ab52b88095dff9d78c88a1d693c81c6242af4932881d268706", + "crates/rt/src/svc/serve.rs": "01ead15cbff6004fe7d13990c373cb6d50c659526942d1b1cf883f05ec72c8d6", "crates/rt/src/task_watch.rs": "96b646f8785e427640519191cbf1f294c6908e5062a8082db271364048aa5b9a", "crates/rt/src/termination.rs": "920685ebb2071db58ae667e57bd02a6127166e8224ed13ec81216465ca315a55", "crates/rt/src/thread.rs": "06d0eefa92c46fe31392db8b10e891e008e95c3ee4162f9f4034f7dec7d9b35c", @@ -288,21 +289,24 @@ "crates/window/src/appearance.rs": "b22e65286b94215bc4265a09ef280a5118f368ac45120311b870a56f7cc40a81", "crates/window/src/capture.rs": "d793f70fff35dfb9b13547f016b5abf8d851a23e53cfe28c1c24ad9949e6ef44", "crates/window/src/channel.rs": "6c96578badb21ddf6f3eb587e65b78af275bd8bcc0e03fd359c88eba6d91138f", - "crates/window/src/client.rs": "2e82a529684f4413cbb1d1475f5d6f108a8eb64f961aa0caec441045c4862d35", + "crates/window/src/client.rs": "485d4bc67d4178c38d1ea53ea7b1c979ea0cde6662df888163f6b2b61a0d82c7", + "crates/window/src/clipboard.rs": "c10c8d7d198b3562900e789077d16761db9cdb69e6425c02bb4a97991a44e55e", "crates/window/src/command.rs": "3ad4867ce62f3a430a68e4d105991229938e131e8cfde8afbbb0f522f99355d3", "crates/window/src/configure.rs": "5731662e919827aa6c9b189dce1d4ae06ebc7f7940c57fafd8662fc33bc46154", - "crates/window/src/control.rs": "0ccad3310bc1abc5efa44ff26d929ee7f7e54c6b83d1a3467c8dca5b3b3f8ca3", + "crates/window/src/control.rs": "3ab2400d32503f75b3419310049252167035179e76f8c838a89094d17946c9a5", "crates/window/src/cursor.rs": "2c320f4f19af4a5f9cd0d4059d1452661ac8cea23f0f6a44e0833cccd4d5f4bd", "crates/window/src/desktop.rs": "2aca94383249fac9f770c4edae35f634ad89388c463b17e4484125190757532b", "crates/window/src/document.rs": "1c4fb5a4bd3ce170a8a79ea97e7efd9aa013b1d2678b5ca1dde06ea1508ddaca", "crates/window/src/event.rs": "cf6565ea882e6c77672f31f0b354b03c57f25591a558893bb9466070b3f503a4", "crates/window/src/face.rs": "1078d8664e6da529c9d4fd43a7e3df77f287d0061f418ed21859d289e86765a0", - "crates/window/src/lib.rs": "310eeb5e3fdafe4e881008d311d0de4346bc1f7a78d417cba2c1e59e354670d6", + "crates/window/src/keysplit.rs": "044360a955b04a5229cba55956e9307a04d7d78facf2dfcf9903a43e3012e3fb", + "crates/window/src/lib.rs": "8b61e7f5e21e981069fc7409d2a7b5fb7add9c3a05aa5db29aa5685cbd5dc479", "crates/window/src/menu.rs": "8baf547a2dd58f83eef635e16e8658a011c917b60125b5d041c885b9eb69db1e", "crates/window/src/notify.rs": "8215605e26509fd615a983eab746ddcec18a156543349f52ddff6f301bdfe569", "crates/window/src/pick.rs": "d861daa5137f7513bc142737da8bd104f84c423e9c26af4f48ef19a3afe1b5aa", - "crates/window/src/relay.rs": "8f56568bcf643825906c74180ee13a14acb8299f6fb08f968919370e82669348", - "crates/window/src/wire.rs": "572daa34bc6233540779c542cd47533ec9c294123562de88ea6a5c2fb54572bf", + "crates/window/src/relay.rs": "bd96d827e9074dfdaddc4bb5a0ea5f924b4c63e5fd2bbffba033ab12f5c6b10b", + "crates/window/src/shortcut.rs": "ce72430d30a58833ddb4bc5c95ff12802b09719bd99383296a52a4d55af70e97", + "crates/window/src/wire.rs": "43789b5b72b003ae74b05b79ae4d1c25a3d015a3798dcddd31c664fe6cbb421f", "drivers/nvme/Cargo.toml": "21acf81448d0cc403eb10ada1c407baf7ff8cea4778ccdeb6c4f0d3508a66fbd", "drivers/nvme/src/block.rs": "4df5122b9c8f28ae878bffb6eeacfdba94569f73182c147a555f345a4d877723", "drivers/nvme/src/commands.rs": "a4cdbfb2b7822143beb7f351f4bba7b4643da1bffa80ffe443f065e47545c097", @@ -315,13 +319,13 @@ "drivers/nvme/src/regs.rs": "d9410b5c64bb70f99e042f7209119c0f0fdb57f859c6a1c07eba90199f1e98c1", "drivers/nvme/src/swap.rs": "f5e1099b83b2cdeb9d288f4d337f1a234d9dfd16c31e9d9303c10f6f14972141", "services/block/Cargo.toml": "2729b36369486a6f5c1c29200eaa4603b25c26352f06f52301b70d9d57260e43", - "services/block/boot.toml": "33e5fbd50b82878632c4fa3fd5a36aebabc9ef1e313fd06c91db9f78bf064543", + "services/block/boot.toml": "99615c152634c9eb62532eb87c917aa3da99fc26d31a1409c4d269043ceace00", "services/block/src/client.rs": "6aed79a404620f89ffa13078f615e846fdf6f3db5629c5cee5fbccbeaa7636cf", "services/block/src/lib.rs": "cf45960460cf6c9dba72f7a6727b51497275c686166a90be531d675097c275f1", "services/block/src/service.rs": "191e9d887d3f3267d6e2aa96e81996f01321bb2febb85e68581159f6e098b4b5", "services/block/src/wire.rs": "a9d64aef9ec3d58d37ca55b841933fc51b76ab94e18b245278b4519e96c65939", "services/dagfs/Cargo.toml": "d229a126616b1d7638f0f5a6edb4ac37d9d116f32afaa1897ffe484fbb286e5e", - "services/dagfs/boot.toml": "4202704fd02113c2e36ab588e6f0e00c7d96da31c746cd644a6d238e93fd52e6", + "services/dagfs/boot.toml": "58eda46ef6e6f45bba43df5b72bd7b1c5f45fb039957f9ddedb5d333f10b6b4d", "services/dagfs/examples/fsdemo.rs": "1666a14ef62fd4fc46f7f9efa2eaa5021ed79823f273a77f53758307f884e7d0", "services/dagfs/examples/fswire.rs": "994b97d699796b317741e2c87cbe812cc1776ddcd01173323e72a5f74c8ac267", "services/dagfs/examples/model_volume.rs": "0e3623e962da02b2126062a19d198b9e0a8f6e91896fd9a6f7d13f6f30ddc5f1", @@ -330,12 +334,12 @@ "services/dagfs/src/bounds.rs": "c825fcab0f30777e772c21fcf68cd092b10cf57a741eac22747de87ec3742e87", "services/dagfs/src/client.rs": "3532931af9484f548f7ff60bfd58f56fe392d8a770779f766f4e0edb0580d7d4", "services/dagfs/src/digests.rs": "88a256596ab314714ef523a2bea44dd8d5265436b284534d25d4538535454f3a", - "services/dagfs/src/durable.rs": "93df70108d12fd28134dbaf2434a8e067df2a415bbcd0cdb969a84b86403495d", - "services/dagfs/src/engine.rs": "3c52e78d1acf1bdd89d4bc9a77d95e97c139cebdb2009c2cd25921caa634365e", + "services/dagfs/src/durable.rs": "3caf0dc7bd6c7c13cde2c1a83f1db6d70169efb92f448b54ace80f07b6057456", + "services/dagfs/src/engine.rs": "e7cfda6e4f05a532cfc5bf15854b80254f64f40d25a6e7910bee38bfc0ba52c8", "services/dagfs/src/error.rs": "0a0cac522b38baa34221adda4ccca010fd8119c5aaf1f7b6015ab7a5f602ac33", "services/dagfs/src/extraction.rs": "502c7ba6cc8a37016a6c4a43f46664c3f27fe4c01d2cac21f8eb2cc2591a531d", - "services/dagfs/src/lib.rs": "9e4c0e957c81a9504e4217e0c16a83a0dad48fe35ead5a5785e7d139105d6cc8", - "services/dagfs/src/namespace.rs": "638a66619320f580de82b082f5520f35d47d8ab9b39b21ce413365dbf89dacaa", + "services/dagfs/src/lib.rs": "34140702b546448b1b3e326b0112f1be23e1beeb13af836b7f5377b529bb4e76", + "services/dagfs/src/namespace.rs": "72714f185853fbc2e0b67ab59bca00bf4665896adfc120e42fe5d5e3cdb40173", "services/dagfs/src/object.rs": "ce3468a2a536069eddd3598e8593c7eddc8e5107bd96f6e939af0133eb02fb91", "services/dagfs/src/parent_index.rs": "a46346a378ff2e9dd2ffe2d8a9514d8c777a7d0170ff3b90a9345def3b452a02", "services/dagfs/src/path.rs": "98ddd0026e43f6a4957b896bfa0c33ec217b016229662ebc0402283d1e8b0518", @@ -345,11 +349,12 @@ "services/dagfs/src/provider/mod.rs": "1e40047a77fc1f5140626bc25c9d57e681a38302d8b7a30c9e1c01c140cb3db8", "services/dagfs/src/provider/ram.rs": "fa3175f2d98854f2dc99f41cec1eb47ae7e4a560f9307de5f2c3778f95b7f7f1", "services/dagfs/src/reach.rs": "0b72f192ef37320d0a9a6883b7bdba2cd1a291f76dc343f24c52059d588e94ac", - "services/dagfs/src/router.rs": "07e9ceb72b8af7ab3ec944212df2009fe89d276b9179013d32baaa1e70f7f8d4", + "services/dagfs/src/router.rs": "a598a55029a034a95c14bcf242c1bcb86f3d80759dd797122d4f026af5bc0e26", "services/dagfs/src/search.rs": "98e3807b60e039082e2f16cd975d4637520642b380ea5ee3b7a25a4b843de448", - "services/dagfs/src/service/contract.rs": "32219b508a02f9136416618d2e2ab2db1f3bda46f18f8654683caf43e7eb4a66", - "services/dagfs/src/service/mod.rs": "c815acf69647b61a3b907b19b46e1456894d19237d7f19206892f336c9b1b3e0", + "services/dagfs/src/service/contract.rs": "f978333c7da9cb7797e08b9c71690e09c5b1a58b7eedc3a48256671c7f7577d2", + "services/dagfs/src/service/mod.rs": "3403eaf8628cf0e0bc0ce6ff8d2d9ba7a80ea64d550e2822f680757a6d2672e2", "services/dagfs/src/slot.rs": "e8f8751c09a4e6006bcaccd2d189cbb6762da1be6c4ea56bd331fbdc7f5cf827", + "services/dagfs/src/watches.rs": "9ae8277cd4ea1eadbbc8cc992b53edb6e476f247cf208e43ea6e13af3702b74c", "services/dagfs/src/window.rs": "8be4bd5c8f1e51cfa5744801e12a9406e7dafb351d0340a625ca8b8bddeb3364", "services/dagfs/src/wire.rs": "3c85fd1cdf0e35bb3a85cef10093f1b0d08626a9198fe219ff68fe09ba64595b", "services/dagfs/tests/authority.rs": "1432f45aef2bdffd63b83a2e93481673ba3412b14d4dc2abc1ef91f105172641", @@ -363,7 +368,7 @@ "services/dagfs/tests/provider.rs": "ba4070af34ae034c109e391e97bcc4755c6bd70b8e2fb229aaf9d747fef71e2d", "services/dagfs/tests/query_experiment.rs": "6fe147810474b6754ef31ad0fb95493e0b3c0886fbbafd78deb3729d410e8155", "services/device/Cargo.toml": "8f35c9ddd701b58a538f151e95e93ce618f0b346960491adad6d843287165d1c", - "services/device/boot.toml": "22c2a78e9be0e6784260668c31b1f8e49f47bcab0c684b6ac1a8d02249c3cb90", + "services/device/boot.toml": "4a0c84d22eb06e749e5d0803d51b659e3f8af81c52acb14491f58cec4246e752", "services/device/src/client.rs": "71660deb4a9f51edb7a72fbb3edac94170bf4cccf9ce182cbd478ee3daf406eb", "services/device/src/lib.rs": "215c7c7e81d5d51664ac77b72b2edb0bab739d5a54b30bc9ad08f9dd3a3cfea4", "services/device/src/service/mod.rs": "6038ba0b9375e3c6bbb25c49bf0849a5878a402f4a3a691dc9df1494849d252b", @@ -405,7 +410,7 @@ "services/extract/vendor/jsmn/jsmn.h": "babfcb7413354a43dcd539c600a1e00bd2d1579ad0a6f6c7d8b81dccedb16cd0", "services/extract/vendor/jsmn/kore_jsmn.c": "a666060a7af03c819d1faaccc0d6faa19b76bcfe78e4ef61aeab273ad052c95c", "services/mm/Cargo.toml": "25809b6a03916d96b8de64a5988d005c71c49314fafbe0d920c3ec06f2dc5951", - "services/mm/boot.toml": "6cc0fd1224b6387ef80b0146b8048b45f2f5d15c4b6f658d473556c71ebfde6d", + "services/mm/boot.toml": "8753f22f227455e5fff90674c7295a933b1d035ee6f3c27b7474ca9b99e149c4", "services/mm/src/client.rs": "c6dcc0dc3ed59e0a4cf7532fd306af4c1dc4d4e5ad1edd1d86887def791c5aa6", "services/mm/src/heap.rs": "e55210f3609f2e3e90f30d5497355ddab5801acc60ccbe3f8acbbffff71dbfde", "services/mm/src/lib.rs": "d3820740666ed993e547ee1b3b579231bf439a813d9766860e3b1b57fe815bd6", @@ -417,7 +422,7 @@ "services/output/src/service/mod.rs": "d80c3beb50979b3e1e500abdfa3c0b9d38e354c24e2e34aea443732abbead6f1", "services/output/src/wire.rs": "3752e994eb18714518facae1b78a9ee2b34f99aad8687653f0b89dc8fdeca621", "services/swap/Cargo.toml": "9c0af01403a6ad5a7f4ff69a02b18c8b1b2624a684814d6dc1ac07968c5bbe53", - "services/swap/boot.toml": "7dd8ff4c10f9dc04ddb969cf9f3a12220023a2233c476fbde6bd776bb562206f", + "services/swap/boot.toml": "bba1d75a7f4418ffa066453fa0b753eebc5ae9d7e69ac5dca2b2a0ed694479eb", "services/swap/src/client.rs": "04f735d059b491765d5ba63a4f5f640332735bf336e62dcbae86c17647583e2c", "services/swap/src/extent.rs": "d6705f77951df1c42cda51fc4526ba6262cab6af4a0545405ce06d2d2e1db7b4", "services/swap/src/lib.rs": "883684df54a87ae66aa7895ea19a872cabc8ab7fd4aded4ea65b7e3dd51fe0cf", diff --git a/native/services/block/boot.toml b/native/services/block/boot.toml index 2e00c72..65fdc1b 100644 --- a/native/services/block/boot.toml +++ b/native/services/block/boot.toml @@ -12,5 +12,5 @@ chain = true endowment = "kore_svc_block::BlockEndowment" protocol = "kore_svc_block::BlockService" endows = ["DMA_POOL@1"] -digest_x86_64 = "blake3:6658f8f4c97b44cf5da083aabc866fca9066790c84104f8a2e2f5e980dbef60c" -digest_aarch64 = "blake3:1a549b72358df27ddf6d56fd804980e15dc2c9d7e9a392922b74516e3b4947d9" +digest_x86_64 = "blake3:d9f8829568372dbcdccc3028bd4b302850c814eb4d44233290c5bdb165c67cef" +digest_aarch64 = "blake3:d004acf08fb53d2d42656f0df24bf54318e3eda87ec79a6778c2e186b61ade79" diff --git a/native/services/dagfs/boot.toml b/native/services/dagfs/boot.toml index a7765b2..37866c1 100644 --- a/native/services/dagfs/boot.toml +++ b/native/services/dagfs/boot.toml @@ -47,5 +47,5 @@ exec_client = true # `dagfs_heap_supply_holds_every_session` holds this row to it; the rest is # room for the proofs open handles carry, allocated per open at their depth. heap_supply_bytes = 0x00D0_0000 -digest_x86_64 = "blake3:21134f67366457e67f9cb95a21642283ba04896df77e1dd0a745fbb5cd9f9e47" -digest_aarch64 = "blake3:64dd2f658d903d9c55ef36c3aa3488c7ebb545c5e83793b8823298eee05aa7d9" +digest_x86_64 = "blake3:dc2fe387db38e271eac4f5d93ac4b5d815060a82cee8cdc81c16bd363d6e9eed" +digest_aarch64 = "blake3:d27bf4cc470afee6a1b5c7c80127dc7e0f8366569b56b74b09c75719e45a3e23" diff --git a/native/services/dagfs/src/durable.rs b/native/services/dagfs/src/durable.rs index adf8b89..864a3bd 100644 --- a/native/services/dagfs/src/durable.rs +++ b/native/services/dagfs/src/durable.rs @@ -79,7 +79,8 @@ use kore_svc_block::{BlockClient, BlockRefusal, BlockService, ProvisionRequest, use crate::persistence::{PersistentFs, VolumeProbe, bootstrap_data_start}; use crate::{ - FsEndowment, FsError, FsRequest, FsResponse, FsService, ProviderError, RamService, SessionId, + FsEndowment, FsError, FsRequest, FsResponse, FsResult, FsService, Handle, ObjectId, + ProviderError, RamService, SessionId, persistence::{CommitStage, PersistenceError, PersistenceState, commit_engine}, provider::BlockProvider, router::{self, RoutedRequest, Store}, @@ -1248,6 +1249,65 @@ impl MountedEngine { } } + fn attach_watch_plane(&mut self, peer: PeerId, aux: &'static mut [u8]) -> bool { + match self { + Self::Durable { fs, .. } => fs.attach_watch_plane(peer, aux), + Self::Volatile(fs) => fs.attach_watch_plane(peer, aux), + } + } + + fn detach_watch_plane(&mut self, peer: PeerId) { + match self { + Self::Durable { fs, .. } => fs.detach_watch_plane(peer), + Self::Volatile(fs) => fs.detach_watch_plane(peer), + } + } + + fn watch_target(&self, session: SessionId, handle: Handle) -> FsResult<(ObjectId, u64)> { + match self { + Self::Durable { fs, .. } => fs.watch_target(session, handle), + Self::Volatile(fs) => fs.watch_target(session, handle), + } + } + + fn watch_epoch(&self, session: SessionId, handle: Handle, dir: ObjectId) -> FsResult { + match self { + Self::Durable { fs, .. } => fs.watch_epoch(session, handle, dir), + Self::Volatile(fs) => fs.watch_epoch(session, handle, dir), + } + } + + #[allow(clippy::too_many_arguments)] + fn arm_watch( + &mut self, + via: u16, + session: SessionId, + slot: u8, + tag: u32, + store: Store, + handle: Handle, + target: FsResult<(ObjectId, u64)>, + ) -> FsResponse { + match self { + Self::Durable { fs, .. } => fs.arm_watch(via, session, slot, tag, store, handle, target), + Self::Volatile(fs) => fs.arm_watch(via, session, slot, tag, store, handle, target), + } + } + + fn settle_watches(&mut self, system: impl FnMut(SessionId, Handle, ObjectId) -> FsResult) { + match self { + Self::Durable { fs, .. } => fs.settle_watches(system), + Self::Volatile(fs) => fs.settle_watches(system), + } + } + + fn owed_watch_wakes(&mut self, owed: &mut [Option]) -> usize { + match self { + Self::Durable { fs, .. } => fs.owed_watch_wakes(owed), + Self::Volatile(fs) => fs.owed_watch_wakes(owed), + } + } + async fn serve_request_async( &mut self, request: FsRequest, @@ -1417,6 +1477,16 @@ impl MountedFs { self.system.is_some() } + /// Publish what every armed watch saw: the primary frontend holds the + /// planes, and each watch is read through the engine of its own store. + fn settle_watches(&mut self) { + let system = self.system.as_ref(); + self.user.settle_watches(|session, handle, dir| match system { + Some(system) => system.watch_epoch(session, handle, dir), + None => Err(FsError::AccessLost), + }); + } + fn durable(&self) -> bool { self.user.durable() || self.system.as_ref().is_some_and(MountedEngine::durable) } @@ -1711,6 +1781,24 @@ impl MountedFs { } FsResponse::Done } + // A watch on the user volume is the primary frontend's own verb. + RoutedRequest::Watch(Store::User, request) => { + self.user.serve_request_async(request, sv).await + } + // One on the system volume is resolved by the system engine and + // recorded in the pair's own plane: the client holds one plane. + RoutedRequest::Watch(Store::System, FsRequest::Watch { handle, slot, tag }) => { + let Some(system) = self.system.as_ref() else { + return FsResponse::Refused(FsError::InvalidHandle); + }; + let Some(session) = system.session_for_via(sv.via()) else { + return FsResponse::Refused(FsError::AccessDenied); + }; + let target = system.watch_target(session, handle); + self.user + .arm_watch(sv.via(), session, slot, tag, Store::System, handle, target) + } + RoutedRequest::Watch(Store::System, _) => FsResponse::Refused(FsError::Unsupported), RoutedRequest::Both(_) => FsResponse::Refused(FsError::Unsupported), // `DurableService::serve_async` answers the ring verbs before the // engines are consulted; reaching here means this row has none. @@ -3319,6 +3407,28 @@ impl SystemService for DurableService { self.advance_stor(); } + /// A recyclable peer's pair carries its watch plane after the rings; the + /// primary frontend keeps it, whichever volume a watch names. + fn attach_peer_aux(&mut self, peer: PeerId, aux: &'static mut [u8]) -> bool { + self.fs + .as_mut() + .is_some_and(|fs| fs.user.attach_watch_plane(peer, aux)) + } + + fn peer_detaching(&mut self, peer: PeerId) { + if let Some(fs) = self.fs.as_mut() { + fs.user.detach_watch_plane(peer); + } + } + + fn take_owed_wakes(&mut self, owed: &mut [Option]) -> usize { + let Some(fs) = self.fs.as_mut() else { + return 0; + }; + fs.settle_watches(); + fs.user.owed_watch_wakes(owed) + } + fn peer_closed(&mut self, via: u16, reason: PeerClose) -> bool { // A departed session's ring goes first: its targets are client // memory mapped into the controller, and a command still in flight @@ -3481,6 +3591,14 @@ impl SvcProtocol for DurableService { const REPLY_GRANTS: usize = ::REPLY_GRANTS; const SERVE_PAIRS: usize = ::SERVE_PAIRS; + fn init_pair_window( + window: &mut [u8], + req_cap: u32, + rep_cap: u32, + ) -> Result<(), kore_ring::spsc::RingError> { + ::init_pair_window(window, req_cap, rep_cap) + } + fn reply_matches(request: &Self::ReqWire, response: &FsResponse, grants: u8) -> bool { ::reply_matches(request, response, grants) } diff --git a/native/services/dagfs/src/engine.rs b/native/services/dagfs/src/engine.rs index dd2adb0..66a635d 100644 --- a/native/services/dagfs/src/engine.rs +++ b/native/services/dagfs/src/engine.rs @@ -762,6 +762,33 @@ impl Engine

{ }) } + /// What a watch on `handle` follows: the directory it names, and that + /// directory's change epoch now. Watching is observing what a listing + /// shows, so it takes exactly a listing's authority — `READ` on a live + /// route to a directory. + pub(crate) fn watch_target(&self, session: SessionId, handle: Handle) -> FsResult<(ObjectId, u64)> { + let route = self.route(session, handle)?; + route.rights.require(Rights::READ)?; + if !self.ns.kind(route.object)?.is_dir() { + return Err(FsError::NotDirectory); + } + Ok((route.object, self.ns.change_epoch(route.object)?)) + } + + /// The change epoch of the directory a watch armed on `handle` follows, + /// read only through a route that still proves to that same directory: + /// a retired edge, a closed handle or a reclaimed directory is + /// `AccessLost`, and the watch ends instead of counting for a client + /// that could no longer list what it counts. + pub(crate) fn watch_epoch(&self, session: SessionId, handle: Handle, dir: ObjectId) -> FsResult { + let route = self.route(session, handle)?; + route.rights.require(Rights::READ)?; + if route.object != dir { + return Err(FsError::AccessLost); + } + self.ns.change_epoch(dir) + } + /// One page of a directory's entries. pub fn list(&self, session: SessionId, handle: Handle, cursor: u32) -> FsResult { let route = self.route(session, handle)?; diff --git a/native/services/dagfs/src/lib.rs b/native/services/dagfs/src/lib.rs index 0373d1c..ecc50b5 100644 --- a/native/services/dagfs/src/lib.rs +++ b/native/services/dagfs/src/lib.rs @@ -158,6 +158,7 @@ mod router; pub mod search; pub mod service; pub mod slot; +mod watches; pub mod window; pub mod wire; @@ -185,6 +186,9 @@ pub use kore_dagfs::DagfsProtocol; /// The storage-ring client (`res/STORAGE_TRANSPORT.md` §4), for rows that /// read through the filesystem's transport — exec's image loads. pub use kore_dagfs::stor; +/// The watch plane every filesystem pair carries after its rings +/// (`kore_dagfs::watch`), for the founders that format pair windows. +pub use kore_dagfs::watch; pub use namespace::{EdgeId, ListEntry, Name}; pub use object::{ANY_REVISION, ObjectId, ObjectKind, ObjectSubtype, Revision, StableObjectId}; pub use provider::{ diff --git a/native/services/dagfs/src/namespace.rs b/native/services/dagfs/src/namespace.rs index b5ac08d..8adf1db 100644 --- a/native/services/dagfs/src/namespace.rs +++ b/native/services/dagfs/src/namespace.rs @@ -33,7 +33,7 @@ //! while exact edge generations govern route validity. use alloc::{boxed::Box, vec::Vec}; -use core::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use core::sync::atomic::{AtomicU32, AtomicU64, Ordering, fence}; use crate::{ ServiceAlloc, @@ -312,13 +312,29 @@ struct ObjectSnapshotView { /// a metadata commit. A reader never spins behind a preempted writer: it /// returns typed [`FsError::Busy`] and lets the bounded request retry policy /// decide when to revisit the snapshot. +/// +/// `epoch` and `owed` ride the same line but outside the seqlock: each is one +/// word with its own atomic meaning. +/// +/// - `epoch` is a directory's **change epoch** — advanced after every change +/// to what a listing of it shows: an edge made, removed or renamed in it, +/// a child's length, revision or incoming count moved, or an entry made or +/// removed in a child folder (that folder's line: its count, and whether +/// it holds folders). It is never reset, so it stays monotone across slot +/// reuse; the generation is what tells occupants apart. Watches compare it +/// ([`Namespace::change_epoch`]). +/// - `owed` marks a change to what the object's parents show of it — a +/// file's content, a folder's entries — that could not be passed on +/// because another commit held the object's parent row; the holder settles +/// it on release ([`HeldParents`]). #[repr(align(64))] struct ObjectSnapshot { sequence: AtomicU64, generation: AtomicU32, - _pad: AtomicU32, + owed: AtomicU32, len: AtomicU64, dir: AtomicU64, + epoch: AtomicU64, } impl ObjectSnapshot { @@ -326,9 +342,10 @@ impl ObjectSnapshot { Self { sequence: AtomicU64::new(0), generation: AtomicU32::new(0), - _pad: AtomicU32::new(0), + owed: AtomicU32::new(0), len: AtomicU64::new(0), dir: AtomicU64::new(0), + epoch: AtomicU64::new(0), } } @@ -380,6 +397,64 @@ impl ObjectSnapshot { dir: (dir != 0).then(|| DirId(SlotKey::from_bits(dir))), }) } + + /// `id`'s change epoch, validated against the slot's occupant: a slot + /// reused by another object answers `AccessLost`, never its successor's + /// count. + fn read_epoch(&self, id: ObjectId) -> FsResult { + let before = self.sequence.load(Ordering::Acquire); + if before & 1 != 0 { + return Err(FsError::Busy); + } + let generation = self.generation.load(Ordering::Relaxed); + // Acquire: a caller that sees the epoch move sees the change behind + // it (`Namespace::touch` advances it with release order). + let epoch = self.epoch.load(Ordering::Acquire); + let after = self.sequence.load(Ordering::Acquire); + if before != after { + return Err(FsError::Busy); + } + if generation != id.key().generation() || generation == 0 { + return Err(FsError::AccessLost); + } + Ok(epoch) + } +} + +/// A claim on one object's parent row that settles, on release, a change +/// owed to the object's parents ([`Namespace::note_shown`]). +/// +/// Every row claim in this module goes through it, so whichever commit holds +/// the row when a change lands is the one that passes it on: the changer +/// marks the change owed and tries the row; a holder, after letting go, +/// looks for the mark. The two sides store before they look, across a +/// sequentially consistent fence each, so at least one of them sees the +/// other — the change is never lost and never waited for. +pub(crate) struct HeldParents<'a, K> { + ns: &'a Namespace, + object: ObjectId, + claim: Option>, +} + +impl<'a, K> core::ops::Deref for HeldParents<'a, K> { + type Target = ParentClaim<'a>; + fn deref(&self) -> &Self::Target { + self.claim.as_ref().expect("the row is held until the guard drops") + } +} + +impl core::ops::DerefMut for HeldParents<'_, K> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.claim.as_mut().expect("the row is held until the guard drops") + } +} + +impl Drop for HeldParents<'_, K> { + fn drop(&mut self) { + drop(self.claim.take()); + fence(Ordering::SeqCst); + self.ns.settle_owed(self.object); + } } /// One vacant snapshot per object slot, heap-backed for the reason recorded on @@ -396,6 +471,87 @@ fn vacant_snapshots() -> Box<[ObjectSnapshot]> { snapshots.into_boxed_slice() } +/// Change epochs and the parent-row guard. Nothing here depends on the +/// provider's key type: it touches only the snapshot lines and the rows. +impl Namespace { + fn snapshot_line(&self, id: ObjectId) -> Option<&ObjectSnapshot> { + usize::try_from(id.key().index()) + .ok() + .and_then(|index| self.snapshots.get(index)) + } + + /// Advance `dir`'s change epoch, after the change it reports is + /// published (release order; [`ObjectSnapshot::read_epoch`] acquires). + fn touch(&self, dir: ObjectId) { + if let Some(line) = self.snapshot_line(dir) { + line.epoch.fetch_add(1, Ordering::Release); + } + } + + /// Advance the epoch of every directory that names the row's object: what + /// each shows of it — its length, revision, or folder count — moved. + fn touch_parents(&self, row: &ParentClaim<'_>) { + for edge in row.iter() { + self.touch(edge.parent); + } + } + + /// Claim `object`'s parent row through the guard that settles a content + /// change owed while it is held. + fn claim_parents(&self, object: ObjectId) -> FsResult> { + let claim = self.parents_of.claim(object)?; + Ok(HeldParents { + ns: self, + object, + claim: Some(claim), + }) + } + + /// What every directory naming `object` shows of it just changed — a + /// file's length and revision, or a folder's entries (its line's count, + /// and whether it holds folders). Marked owed first, so a commit holding + /// the row passes the change on when it lets go ([`HeldParents`]). + fn note_shown(&self, object: ObjectId) { + let Some(line) = self.snapshot_line(object) else { + return; + }; + line.owed.store(1, Ordering::Relaxed); + fence(Ordering::SeqCst); + self.settle_owed(object); + } + + /// Pass an owed change on to `object`'s parents, if the row is free; a + /// row another commit holds is that commit's to settle. + /// + /// Each pass ends by letting the row go and looking again, because a + /// writer that found the row held during the pass left its mark for this + /// side. Every pass that continues consumed such a mark, so the loop does + /// the writers' work and never waits for anything. + fn settle_owed(&self, object: ObjectId) { + let Some(line) = self.snapshot_line(object) else { + return; + }; + while line.owed.load(Ordering::Relaxed) != 0 { + let Ok(row) = self.parents_of.claim(object) else { + return; + }; + if line.owed.swap(0, Ordering::AcqRel) != 0 { + self.touch_parents(&row); + } + drop(row); + fence(Ordering::SeqCst); + } + } + + /// `dir`'s change epoch: equal readings bracket no change to what a + /// listing of it shows. `AccessLost` once the directory is gone. + pub(crate) fn change_epoch(&self, dir: ObjectId) -> FsResult { + self.snapshot_line(dir) + .ok_or(FsError::AccessLost)? + .read_epoch(dir) + } +} + impl Namespace { pub(crate) fn new() -> Self { // Bootstrap-scale and fixed after startup, exactly like @@ -454,6 +610,7 @@ impl Namespace { debug_assert_eq!(object.id(), id.key()); object.commit_len(len); self.snapshots[id.key().index() as usize].publish(id, len, object.dir()); + self.note_shown(id); object.revision() } @@ -1066,7 +1223,7 @@ impl Namespace { // for the edge, before publishing it, so contention cannot leave a // visible name without its matching count or reverse entry. let mut child_object = self.object_mut(child)?; - let mut parents = self.parents_of.claim(child)?; + let mut parents = self.claim_parents(child)?; parents.reserve()?; let entry = entries .insert(DirEntry { name: *name, child }) @@ -1075,6 +1232,12 @@ impl Namespace { child_object.incoming_inc(); let edge = EdgeId { parent, entry }; parents.add(edge); + // `parent` gained an entry, and every other folder holding the child + // shows one more folder count: the row names them all. `parent`'s own + // folders see its line change (one more entry, perhaps a first + // folder). + self.touch_parents(&parents); + self.note_shown(parent); Ok(edge) } @@ -1093,12 +1256,15 @@ impl Namespace { .ok_or(FsError::NotFound)?; let edge = EdgeId { parent, entry }; let mut child_object = self.object_mut(child)?; - let mut parents = self.parents_of.claim(child)?; + let mut parents = self.claim_parents(child)?; entries .try_remove(edge.entry) .map_err(|error| fs_access(error, FsError::AccessLost))?; child_object.incoming_dec(); parents.remove(edge); + self.touch(parent); + self.touch_parents(&parents); + self.note_shown(parent); Ok((edge, child)) } @@ -1130,7 +1296,7 @@ impl Namespace { return Err(FsError::NotEmpty); } let mut child_object = self.object_mut(child)?; - let mut parents = self.parents_of.claim(child)?; + let mut parents = self.claim_parents(child)?; claims .get_mut(parent)? .try_remove(entry) @@ -1138,6 +1304,9 @@ impl Namespace { child_object.incoming_dec(); let edge = EdgeId { parent, entry }; parents.remove(edge); + self.touch(parent); + self.touch_parents(&parents); + self.note_shown(parent); Ok((edge, child)) } @@ -1166,6 +1335,9 @@ impl Namespace { .try_write(edge.entry) .map_err(|error| fs_access(error, FsError::AccessLost))?; entry.name = *new; + // Only `parent` spells the edge: the child's other folders show it + // under their own names, unchanged. + self.touch(parent); Ok(edge) } @@ -1243,7 +1415,7 @@ impl Namespace { // The child's parent row moves with the edge; it is claimed on its // own, so a file mid-transaction can still be moved. - let mut parents = self.parents_of.claim(child)?; + let mut parents = self.claim_parents(child)?; // Commit: retire the source edge, then create the destination edge. claims @@ -1261,6 +1433,16 @@ impl Namespace { // is deliberately not touched here; the parent row swaps in place. let dst_edge = EdgeId { parent: dst, entry }; parents.replace(src_edge, dst_edge); + // The source lost an entry; the row now names the destination and + // every other folder, whose "located in" for the child moved. + self.touch(src); + self.touch_parents(&parents); + // Across folders, each one's line moved (one entry fewer, one more); + // within one folder only a spelling changed. + if src != dst { + self.note_shown(src); + self.note_shown(dst); + } Ok((src_edge, dst_edge)) } @@ -1360,7 +1542,7 @@ impl Namespace { /// `object`'s incoming edges, copied out of its parent row so no claim /// is held while they are walked. fn incoming_edges(&self, object: ObjectId) -> FsResult> { - let row = self.parents_of.claim(object)?; + let row = self.claim_parents(object)?; let mut edges = BoundedVec::try_new_in(row.len(), crate::service_alloc()) .map_err(|_| FsError::OutOfObjects)?; for edge in row.iter() { @@ -1486,7 +1668,7 @@ impl Namespace { child_ids.as_mut_slice().sort_unstable_by_key(|id| id.key()); let mut children = heapless::Vec::< - (ObjectId, ObjectWrite<'_, K>, ParentClaim<'_>), + (ObjectId, ObjectWrite<'_, K>, HeldParents<'_, K>), MAX_DIR_ENTRIES, >::new(); for child in child_ids { @@ -1494,14 +1676,14 @@ impl Namespace { .objects .try_write(child.key()) .map_err(|error| fs_access(error, FsError::AccessLost))?; - let parents = self.parents_of.claim(child)?; + let parents = self.claim_parents(child)?; children .push((child, claimed, parents)) .map_err(|_| FsError::OutOfObjects)?; } // The reclaimed object names nothing once it is gone; its own row // is emptied with it (it is already empty: nothing names it). - let mut own_parents = self.parents_of.claim(id)?; + let mut own_parents = self.claim_parents(id)?; // All-or-nothing commit: decrement once per removed edge, and drop // its reverse entry, then record only the child generations that @@ -1521,12 +1703,14 @@ impl Namespace { } own_parents.clear(); let mut became_reclaimable = heapless::Vec::new(); - for (id, child, _) in children.iter() { + for (id, child, parents) in children.iter() { if child.is_reclaimable() { became_reclaimable .push(*id) .map_err(|_| FsError::OutOfObjects)?; } + // A child that keeps a folder elsewhere shows one fewer there. + self.touch_parents(parents); } if let Some(entries) = directory.take() { @@ -2743,6 +2927,211 @@ mod tests { assert_eq!(ns.leaked_placements(), 2); } + // ── change epochs ─────────────────────────────────────────────────────── + + /// Whether `dir`'s change epoch moved since `before`, and the new reading. + fn moved(ns: &Ns, dir: ObjectId, before: &mut u64) -> bool { + let now = ns.change_epoch(dir).unwrap(); + let moved = now != *before; + *before = now; + moved + } + + #[test] + fn every_change_a_listing_or_a_folder_line_shows_moves_its_epoch_and_nothing_else_does() { + let (mut ns, root) = ns_with_root(); + let a = ns.create_dir_object().unwrap(); + let b = ns.create_dir_object().unwrap(); + ns.link(root, &name("a"), a).unwrap(); + ns.link(root, &name("b"), b).unwrap(); + let file = ns.create_file_object().unwrap(); + let (mut at_a, mut at_b, mut at_root) = ( + ns.change_epoch(a).unwrap(), + ns.change_epoch(b).unwrap(), + ns.change_epoch(root).unwrap(), + ); + + // An entry made in `a`: `a` shows it, and `root` shows `a`'s line + // with one more entry. `b` shows nothing new. + ns.link(a, &name("f"), file).unwrap(); + assert!(moved(&ns, a, &mut at_a)); + assert!(moved(&ns, root, &mut at_root)); + assert!(!moved(&ns, b, &mut at_b)); + + // A second folder for the file: `b` gains an entry (and its line in + // `root` moves), and `a` shows one more folder count for it. + ns.link(b, &name("g"), file).unwrap(); + assert!(moved(&ns, b, &mut at_b)); + assert!(moved(&ns, a, &mut at_a)); + assert!(moved(&ns, root, &mut at_root)); + + // A rename is one folder's spelling only: its line's count holds. + ns.rename(b, &name("g"), &name("h")).unwrap(); + assert!(moved(&ns, b, &mut at_b)); + assert!(!moved(&ns, a, &mut at_a)); + assert!(!moved(&ns, root, &mut at_root)); + + // A content commit reaches every folder naming the file, and no + // folder line: the folders' entries are unchanged. + { + let mut object = ns.object_mut(file).unwrap(); + ns.commit_file_len(file, &mut object, 12); + } + assert!(moved(&ns, a, &mut at_a)); + assert!(moved(&ns, b, &mut at_b)); + assert!(!moved(&ns, root, &mut at_root)); + + // A move: the source loses an entry, the destination gains one, the + // file's other folder sees its location move, and both folders' + // lines move in `root`. + let c = ns.create_dir_object().unwrap(); + ns.link(root, &name("c"), c).unwrap(); + assert!(moved(&ns, root, &mut at_root)); + let mut at_c = ns.change_epoch(c).unwrap(); + ns.relocate(b, &name("h"), c, &name("h")).unwrap(); + assert!(moved(&ns, b, &mut at_b)); + assert!(moved(&ns, c, &mut at_c)); + assert!(moved(&ns, a, &mut at_a)); + assert!(moved(&ns, root, &mut at_root)); + + // A move within one folder is a spelling: its line holds. + ns.relocate(c, &name("h"), c, &name("i")).unwrap(); + assert!(moved(&ns, c, &mut at_c)); + assert!(!moved(&ns, root, &mut at_root)); + + // Removing a name: the folder loses it, the other shows one fewer, + // and the folder's line moves. + ns.detach(c, &name("i")).unwrap(); + assert!(moved(&ns, c, &mut at_c)); + assert!(moved(&ns, a, &mut at_a)); + assert!(moved(&ns, root, &mut at_root)); + ns.unlink_empty(a, &name("f")).unwrap(); + assert!(moved(&ns, a, &mut at_a)); + assert!(moved(&ns, root, &mut at_root)); + + // A refused change moves nothing. + assert_eq!(ns.rename(a, &name("absent"), &name("x")).unwrap_err(), FsError::NotFound); + assert_eq!(ns.detach(a, &name("absent")).unwrap_err(), FsError::NotFound); + assert!(!moved(&ns, a, &mut at_a)); + assert!(!moved(&ns, root, &mut at_root)); + } + + #[test] + fn a_folder_line_change_meeting_its_held_parent_row_is_passed_on_by_the_holder() { + let (mut ns, root) = ns_with_root(); + let a = ns.create_dir_object().unwrap(); + ns.link(root, &name("a"), a).unwrap(); + let file = ns.create_file_object().unwrap(); + let mut at_root = ns.change_epoch(root).unwrap(); + + // A commit holds `a`'s own row (a move of `a`, a parents walk) while + // an entry lands in `a`: the linker cannot reach `a`'s folders. + let held = ns.claim_parents(a).unwrap(); + ns.link(a, &name("f"), file).unwrap(); + assert!(!moved(&ns, root, &mut at_root), "the linker left the change owed"); + drop(held); + assert!(moved(&ns, root, &mut at_root), "the holder passed it on"); + drop(ns.claim_parents(a).unwrap()); + assert!(!moved(&ns, root, &mut at_root), "owed once, settled once"); + } + + #[test] + fn a_reclaimed_directory_answers_access_lost_and_its_children_s_other_folders_move() { + let (mut ns, root) = ns_with_root(); + let doomed = ns.create_dir_object().unwrap(); + let kept = ns.create_dir_object().unwrap(); + let file = ns.create_file_object().unwrap(); + ns.link(root, &name("doomed"), doomed).unwrap(); + ns.link(root, &name("kept"), kept).unwrap(); + ns.link(doomed, &name("f"), file).unwrap(); + ns.link(kept, &name("f"), file).unwrap(); + let mut at_kept = ns.change_epoch(kept).unwrap(); + + let (_, child) = ns.detach(root, &name("doomed")).unwrap(); + let mut placements: BoundedVec = + BoundedVec::try_new_in(MAX_OBJECTS, crate::service_alloc()).unwrap(); + let mut objects: BoundedVec = + BoundedVec::try_new_in(MAX_OBJECTS, crate::service_alloc()).unwrap(); + assert_eq!(ns.reclaim(child, &mut placements, &mut objects), 1); + assert_eq!(ns.change_epoch(doomed), Err(FsError::AccessLost)); + assert!(moved(&ns, kept, &mut at_kept), "the file is in one folder fewer"); + + // A slot reused by another directory never answers for the old one. + let successor = ns.create_dir_object().unwrap(); + assert_eq!(ns.change_epoch(doomed), Err(FsError::AccessLost)); + assert!(ns.change_epoch(successor).is_ok()); + } + + #[test] + fn a_content_change_meeting_a_held_parent_row_is_passed_on_by_the_holder() { + let (mut ns, root) = ns_with_root(); + let a = ns.create_dir_object().unwrap(); + ns.link(root, &name("a"), a).unwrap(); + let file = ns.create_file_object().unwrap(); + ns.link(a, &name("f"), file).unwrap(); + let mut at_a = ns.change_epoch(a).unwrap(); + + // A commit holds the file's row (a move, a parents walk) while the + // file's bytes commit: the writer cannot touch the parents itself. + let held = ns.claim_parents(file).unwrap(); + { + let mut object = ns.object_mut(file).unwrap(); + ns.commit_file_len(file, &mut object, 3); + } + assert!(!moved(&ns, a, &mut at_a), "the writer left the change owed"); + drop(held); + assert!(moved(&ns, a, &mut at_a), "the holder passed it on"); + // Owed once, settled once. + drop(ns.claim_parents(file).unwrap()); + assert!(!moved(&ns, a, &mut at_a)); + } + + #[test] + fn concurrent_writers_and_row_holders_never_lose_a_content_change() { + use std::sync::Arc; + use std::sync::atomic::AtomicBool; + + let (mut ns, root) = ns_with_root(); + let a = ns.create_dir_object().unwrap(); + ns.link(root, &name("a"), a).unwrap(); + let file = ns.create_file_object().unwrap(); + ns.link(a, &name("f"), file).unwrap(); + let ns = Arc::new(ns); + let stop = Arc::new(AtomicBool::new(false)); + // A holder thread keeps taking and letting go of the row, as parents + // walks and moves do. + let holder = { + let (ns, stop) = (Arc::clone(&ns), Arc::clone(&stop)); + std::thread::spawn(move || { + while !stop.load(Ordering::Relaxed) { + if let Ok(row) = ns.claim_parents(file) { + core::hint::spin_loop(); + drop(row); + } + } + }) + }; + for len in 0..2_000u64 { + let before = ns.change_epoch(a).unwrap(); + { + let mut object = ns.object_mut(file).unwrap(); + ns.commit_file_len(file, &mut object, len); + } + // Either side settles it; once both have let go, the change is in. + let mut settled = false; + for _ in 0..1_000_000 { + if ns.change_epoch(a).unwrap() != before { + settled = true; + break; + } + std::thread::yield_now(); + } + assert!(settled, "write {len} was never passed on"); + } + stop.store(true, Ordering::Relaxed); + holder.join().unwrap(); + } + /// Write `value` as decimal ASCII into `buf`, returning the byte count. /// Test-local so name generation needs no formatting machinery. fn write_index(buf: &mut [u8; 8], value: usize) -> usize { diff --git a/native/services/dagfs/src/router.rs b/native/services/dagfs/src/router.rs index 8540f77..89c7e10 100644 --- a/native/services/dagfs/src/router.rs +++ b/native/services/dagfs/src/router.rs @@ -58,6 +58,10 @@ pub(crate) enum RoutedRequest { /// A storage-ring verb: neither engine's; served by the row that owns /// the controller (`res/STORAGE_TRANSPORT.md` §4). Stor(FsRequest), + /// Arm a watch on a directory of one store. The request's handle is + /// native to that store; the plane it publishes into is the pair's one, + /// which the primary frontend holds. + Watch(Store, FsRequest), } #[inline] @@ -435,6 +439,13 @@ pub(crate) fn route(request: FsRequest, has_system: bool) -> Result { + let (store, handle) = split_handle(handle); + RoutedRequest::Watch(store, Watch { handle, slot, tag }) + } + // Disarming names no handle: the pair's one plane, in the primary + // frontend, holds every slot, whichever store the slot watched. + Unwatch { .. } => RoutedRequest::One(Store::User, request), LendVersion { handle, window_offset, @@ -541,7 +552,11 @@ pub(crate) fn route(request: FsRequest, has_system: bool) -> Result Option<&'static (dyn core::alloc::Allocator + Sync)> { Some(crate::service_alloc()) } + + /// A recyclable peer's pair carries its watch plane after the rings. + fn attach_peer_aux(&mut self, peer: PeerId, aux: &'static mut [u8]) -> bool { + self.attach_watch_plane(peer, aux) + } + + fn peer_detaching(&mut self, peer: PeerId) { + self.detach_watch_plane(peer); + } + + /// Publish what every armed watch saw since the last unit of work. A lone + /// engine has no system volume, so nothing can be watched on one. + fn take_owed_wakes(&mut self, owed: &mut [Option]) -> usize { + self.settle_watches(|_, _, _| Err(FsError::AccessLost)); + self.owed_watch_wakes(owed) + } } impl FsService

{ @@ -429,6 +445,19 @@ impl FsService

{ Err(error) => FsResponse::Refused(error), }; } + FsRequest::Watch { handle, slot, tag } => { + let target = self.watch_target(session, handle); + self.arm_watch( + sv.via(), + session, + slot, + tag, + crate::router::Store::User, + handle, + target, + ) + } + FsRequest::Unwatch { slot } => self.disarm_watch(sv.via(), session, slot), FsRequest::MatchView { handle, announced } => { let Some(mut input) = sv.returned_input(announced) else { return FsResponse::Refused(FsError::AccessDenied); @@ -501,6 +530,14 @@ impl SvcProtocol for RamService { const REPLY_GRANTS: usize = ::REPLY_GRANTS; const SERVE_PAIRS: usize = ::SERVE_PAIRS; + fn init_pair_window( + window: &mut [u8], + req_cap: u32, + rep_cap: u32, + ) -> Result<(), kore_ring::spsc::RingError> { + DagfsProtocol::init_pair_window(window, req_cap, rep_cap) + } + fn reply_matches(request: &FsReqWire, response: &FsResponse, grants: u8) -> bool { DagfsProtocol::reply_matches(request, response, grants) } diff --git a/native/services/dagfs/src/service/mod.rs b/native/services/dagfs/src/service/mod.rs index 123f0c9..51e04a5 100644 --- a/native/services/dagfs/src/service/mod.rs +++ b/native/services/dagfs/src/service/mod.rs @@ -65,7 +65,7 @@ use kore_rt::{ use kore_svc_extract::schema::InputIdentity; use crate::{ - ServiceAlloc, + ObjectId, ServiceAlloc, authority::{GrantId, Handle, Retired, Rights, SessionId}, block_on, bounds::{FIRST_TASK_VIEW_SESSION, MAX_SESSIONS}, @@ -369,6 +369,9 @@ pub struct FsService { /// immediately before delegating a status request; the default zeros /// honestly describe the bare volatile posture every other harness runs. backend: FsBackendStatus, + /// Which slots' watch planes have a watch armed or owe a wake + /// ([`crate::watches`]); the planes live in the slots' own records. + watches: crate::watches::WatchIndex, } /// What the mounted backend reports about itself for `SystemStatus` — set @@ -484,6 +487,11 @@ struct SessionSlot { /// The session's one private provider placement. The service already /// serializes dispatch; no additional lock or namespace object. replacement: Option>, + /// The recyclable peer's watch plane and its armed watches + /// ([`crate::watches`]), adopted from the pair window's tail at admission + /// and dropped before the host unmaps it. Only the primary frontend's + /// slots carry one: a secondary engine's watches are recorded here too. + watch: Option, } impl SessionSlot { @@ -495,6 +503,7 @@ impl SessionSlot { task_view: None, deferring: false, replacement: None, + watch: None, } } } @@ -601,9 +610,151 @@ impl FsService

{ pending_reclaims: Vec::new_in(crate::service_alloc()), pending_aborts: Vec::new_in(crate::service_alloc()), backend: FsBackendStatus::default(), + watches: crate::watches::WatchIndex::new(), } } + /// Adopt `peer`'s watch plane from its pair window's tail into the slot's + /// record, claiming the record as admission does. Refused for a fixed + /// pair, a heap that cannot hold the record, or a slot whose previous + /// plane never detached — a host/service desync admission must not paper + /// over. A tail without a formatted plane admits the peer with none: the + /// plane is what watches publish into, not what the pair needs to serve, + /// so such a client's `Watch` answers `Unsupported`. + pub(crate) fn attach_watch_plane(&mut self, peer: PeerId, aux: &'static mut [u8]) -> bool { + let slot = peer.slot(); + if slot == 0 { + return false; + } + let Ok(state) = self.slots.claim(slot) else { + return false; + }; + if state.watch.is_some() { + return false; + } + state.watch = crate::watches::Plane::adopt(peer, aux); + true + } + + /// Drop `peer`'s watch plane before its window is unmapped. A late call + /// for an earlier occupant is inert. + pub(crate) fn detach_watch_plane(&mut self, peer: PeerId) { + let slot = peer.slot(); + if let Some(state) = self.slots.get_mut(slot) + && state.watch.as_ref().is_some_and(|plane| plane.peer() == peer) + { + state.watch = None; + self.watches.forget(slot); + } + } + + /// The plane of the pair that delivered a request (`via`) for `session`; + /// `Unsupported` for a fixed pair or a peer admitted without one. + fn watch_plane_mut(&mut self, via: u16, session: SessionId) -> FsResult<&mut crate::watches::Plane> { + self.slots + .get_mut(usize::from(session.index())) + .and_then(|state| state.watch.as_mut()) + .filter(|plane| plane.peer().arrival() == via) + .ok_or(FsError::Unsupported) + } + + /// Arm a watch on a directory of `store`, resolved by that store's engine + /// (`target`), into the plane of the pair that asked. + #[allow(clippy::too_many_arguments)] + pub(crate) fn arm_watch( + &mut self, + via: u16, + session: SessionId, + slot: u8, + tag: u32, + store: crate::router::Store, + handle: Handle, + target: FsResult<(ObjectId, u64)>, + ) -> FsResponse { + let armed = self + .watch_plane_mut(via, session) + .and_then(|plane| { + let owed = plane.arm(slot, tag, store, handle, target?)?; + Ok((plane.armed(), owed)) + }); + match armed { + Ok((armed, owed)) => { + self.watches.note(usize::from(session.index()), armed, owed); + FsResponse::Done + } + Err(error) => FsResponse::Refused(error), + } + } + + pub(crate) fn disarm_watch(&mut self, via: u16, session: SessionId, slot: u8) -> FsResponse { + let disarmed = self.watch_plane_mut(via, session).and_then(|plane| { + let owed = plane.disarm(slot)?; + Ok((plane.armed(), owed)) + }); + match disarmed { + Ok((armed, owed)) => { + self.watches.note(usize::from(session.index()), armed, owed); + FsResponse::Done + } + Err(error) => FsResponse::Refused(error), + } + } + + /// The change epoch a watch on `handle` follows, read through this + /// engine — the secondary half of a dual-router settle. + pub(crate) fn watch_epoch(&self, session: SessionId, handle: Handle, dir: ObjectId) -> FsResult { + self.engine.watch_epoch(session, handle, dir) + } + + /// What a watch on `handle` would follow in this engine. + pub(crate) fn watch_target(&self, session: SessionId, handle: Handle) -> FsResult<(ObjectId, u64)> { + self.engine.watch_target(session, handle) + } + + /// Publish every armed watch whose directory changed. This engine answers + /// for the user store; `system` for the dual router's system volume (a + /// lone engine passes one that refuses, which ends any such watch). + pub(crate) fn settle_watches( + &mut self, + mut system: impl FnMut(SessionId, Handle, ObjectId) -> FsResult, + ) { + if self.watches.idle() { + return; + } + let engine = &self.engine; + for slot in self.watches.armed() { + let Some(plane) = self.slots.get_mut(slot).and_then(|state| state.watch.as_mut()) else { + self.watches.forget(slot); + continue; + }; + let owed = plane.settle(SessionId::new(slot as u16), |store, session, handle, dir| { + match store { + crate::router::Store::User => engine.watch_epoch(session, handle, dir), + crate::router::Store::System => system(session, handle, dir), + } + }); + self.watches.note(slot, plane.armed(), owed); + } + } + + /// Peers owed a watch-plane wake, for the serving host: up to + /// `out.len()`, each handed over once. + pub(crate) fn owed_watch_wakes(&mut self, out: &mut [Option]) -> usize { + let slots = &self.slots; + let mut taken = 0; + self.watches.take_owed(|slot| { + if taken == out.len() { + return false; + } + if let Some(plane) = slots.get(slot).and_then(|state| state.watch.as_ref()) { + out[taken] = Some(plane.peer()); + taken += 1; + } + true + }); + taken + } + /// Replace the backend posture snapshot folded into `SystemStatus` /// answers. The mount owner calls this immediately before delegating a /// status request, so the record reads as one moment. @@ -1140,6 +1291,7 @@ impl FsService

{ if let Some(state) = self.slots.get_mut(slot) { *state = SessionSlot::vacant(); } + self.watches.forget(slot); true } @@ -2466,6 +2618,13 @@ impl FsService

{ self.engine.sync(session, handle).await?; FsResponse::Done } + // A watch publishes into the asking pair's plane, which only the + // pair-aware layer above names (`serve_request_async`, or the dual + // router); reaching the session-only dispatch is a composition + // that forwarded the verb past its owner. + FsRequest::Watch { .. } | FsRequest::Unwatch { .. } => { + return Err(FsError::Unsupported); + } FsRequest::LendVersion { handle, window_offset, @@ -3659,6 +3818,233 @@ mod recyclable_session_tests { assert_eq!(native.stable_object, ls.stable_object); } + /// A recyclable peer's watch plane as exec formats it after the rings, + /// leaked like the host's mapping of it, with the client's half beside + /// the service's. + fn watch_plane() -> ( + &'static mut [u8], + kore_dagfs::watch::WatchSink<'static>, + ) { + #[repr(align(64))] + struct Plane([u8; kore_dagfs::watch::PLANE_BYTES]); + let plane = &mut alloc::boxed::Box::leak(alloc::boxed::Box::new(Plane( + [0; kore_dagfs::watch::PLANE_BYTES], + ))) + .0; + kore_ring::event::init::( + plane, + kore_abi::svc::FS_WATCH_RECORDS, + ) + .unwrap(); + let (base, len) = (plane.as_mut_ptr(), plane.len()); + // SAFETY: the two halves alias the plane only through the channel + // protocol, as the client's and the service's mappings do on metal. + let client = unsafe { core::slice::from_raw_parts_mut(base, len) }; + (plane, kore_dagfs::watch::sink(client).unwrap()) + } + + /// Two peers of one task view: a watcher holding its root, and a writer + /// that changes it. + fn watched_view() -> (RamService, SessionId, SessionId) { + let mut service = RamService::default(); + assert!(service.establish_admin_source()); + service + .register_private_invocation_view( + 0xD0, + &Name::new("terminal-0").unwrap(), + Rights::ALL, + None, + ) + .unwrap(); + let watcher = admit_view(&mut service, 0x106, 0xD0, Rights::ALL); + let writer = admit_view(&mut service, 0x105, 0xD0, Rights::ALL); + (service, watcher, writer) + } + + fn settle(service: &mut RamService, owed: &mut [Option]) -> usize { + ::take_owed_wakes(service, owed) + } + + #[test] + fn a_watch_counts_another_peer_s_change_and_owes_a_parked_client_one_wake() { + use kore_dagfs::watch::{Cursor, Seen}; + let (mut service, watcher, writer) = watched_view(); + let peer = PeerId::from_arrival(0x106).unwrap(); + let (plane, sink) = watch_plane(); + assert!(service.attach_watch_plane(peer, plane)); + let root = service.engine.open_root(watcher, Rights::READ).unwrap(); + let target = service.watch_target(watcher, root.handle); + assert_eq!( + service.arm_watch(0x106, watcher, 0, 7, crate::router::Store::User, root.handle, target), + FsResponse::Done + ); + let mut cursor = Cursor::new(0, 7).unwrap(); + assert_eq!(cursor.observe(&sink.state().unwrap()), Seen::Same); + + // Nothing changed: nothing published, nobody owed. + let mut owed = [None; 4]; + assert_eq!(settle(&mut service, &mut owed), 0); + + // The client parks; the other peer makes a file in the shared view. + let seen = sink.seq(); + assert!(sink.park(seen)); + let writer_root = service.engine.open_root(writer, Rights::ALL).unwrap(); + crate::block_on(service.engine.create( + writer, + writer_root.handle, + &Name::new("new.txt").unwrap(), + ObjectKind::File, + )) + .unwrap(); + assert_eq!(settle(&mut service, &mut owed), 1); + assert_eq!(owed[0], Some(peer)); + assert_eq!(settle(&mut service, &mut owed), 0, "one change, one wake"); + assert_ne!(sink.seq(), seen); + assert_eq!(cursor.observe(&sink.state().unwrap()), Seen::Changed); + + // A running client is owed nothing for the next change; it reads it. + let created = crate::block_on(service.engine.create( + writer, + writer_root.handle, + &Name::new("more.txt").unwrap(), + ObjectKind::File, + )) + .unwrap(); + assert_eq!(settle(&mut service, &mut owed), 0); + assert_eq!(cursor.observe(&sink.state().unwrap()), Seen::Changed); + + // A child's bytes changing is a change to what the folder shows. + crate::block_on(service.engine.write_at( + writer, + created.handle, + 0, + b"bytes", + crate::ANY_REVISION, + )) + .unwrap(); + assert_eq!(settle(&mut service, &mut owed), 0); + assert_eq!(cursor.observe(&sink.state().unwrap()), Seen::Changed); + + // Closing the watched handle ends the watch: the plane stops + // counting for a route the client let go of. + service.engine.close(watcher, root.handle).unwrap(); + assert_eq!(settle(&mut service, &mut owed), 0); + assert_eq!(cursor.observe(&sink.state().unwrap()), Seen::Ended); + assert_eq!(service.watches.armed_count(), 0); + } + + /// A plane lives in its serving slot's own record, so a peer far past + /// the host's bootstrap table — the seventh program reading files, and + /// the two-hundredth — watches as the first does. + #[test] + fn a_peer_in_a_high_slot_has_its_own_plane() { + use kore_dagfs::watch::{Cursor, Seen}; + let (mut service, _, writer) = watched_view(); + let high = admit_view(&mut service, 0x1C8, 0xD0, Rights::ALL); + assert_eq!(high, SessionId::new(0xC8)); + let peer = PeerId::from_arrival(0x1C8).unwrap(); + let (plane, sink) = watch_plane(); + assert!(service.attach_watch_plane(peer, plane)); + let root = service.engine.open_root(high, Rights::READ).unwrap(); + let target = service.watch_target(high, root.handle); + assert_eq!( + service.arm_watch(0x1C8, high, 3, 11, crate::router::Store::User, root.handle, target), + FsResponse::Done + ); + let mut cursor = Cursor::new(3, 11).unwrap(); + let writer_root = service.engine.open_root(writer, Rights::ALL).unwrap(); + crate::block_on(service.engine.create( + writer, + writer_root.handle, + &Name::new("far.txt").unwrap(), + ObjectKind::File, + )) + .unwrap(); + let mut owed = [None; 4]; + settle(&mut service, &mut owed); + assert_eq!(cursor.observe(&sink.state().unwrap()), Seen::Changed); + // Closing the peer wipes the slot, plane and index together. + service.detach_watch_plane(peer); + assert!(service.peer_closed(0x1C8, PeerClose::Orderly)); + assert_eq!(service.watches.armed_count(), 0); + } + + #[test] + fn a_watch_takes_a_listing_s_authority_and_a_plane_of_the_asking_pair() { + let (mut service, watcher, _) = watched_view(); + let peer = PeerId::from_arrival(0x106).unwrap(); + let root = service.engine.open_root(watcher, Rights::ALL).unwrap(); + let file = crate::block_on(service.engine.create( + watcher, + root.handle, + &Name::new("f").unwrap(), + ObjectKind::File, + )) + .unwrap(); + let user = crate::router::Store::User; + + // No plane attached yet: nothing to publish into. + let target = service.watch_target(watcher, root.handle); + assert_eq!( + service.arm_watch(0x106, watcher, 0, 1, user, root.handle, target), + FsResponse::Refused(FsError::Unsupported) + ); + let (plane, sink) = watch_plane(); + assert!(service.attach_watch_plane(peer, plane)); + // A second plane for the occupied slot is refused. + let (again, _) = watch_plane(); + assert!(!service.attach_watch_plane(peer, again)); + // A tail that carries no plane admits its peer without one. + let bare: &'static mut [u8] = alloc::boxed::Box::leak(alloc::boxed::Box::new([0u8; 64])); + assert!(service.attach_watch_plane(PeerId::from_arrival(0x105).unwrap(), bare)); + // Another generation of the slot names no plane of its own. + let target = service.watch_target(watcher, root.handle); + assert_eq!( + service.arm_watch(0x206, watcher, 0, 1, user, root.handle, target), + FsResponse::Refused(FsError::Unsupported) + ); + // A file is not watched: a listing is what a watch follows. + let target = service.watch_target(watcher, file.handle); + assert_eq!( + service.arm_watch(0x106, watcher, 0, 1, user, file.handle, target), + FsResponse::Refused(FsError::NotDirectory) + ); + // A slot past the plane, or the zero tag, is out of range. + for (slot, tag) in [(kore_abi::svc::FS_WATCH_SLOTS as u8, 1), (0, 0)] { + let target = service.watch_target(watcher, root.handle); + assert_eq!( + service.arm_watch(0x106, watcher, slot, tag, user, root.handle, target), + FsResponse::Refused(FsError::Range) + ); + } + assert_eq!(service.watches.armed_count(), 0); + + // Re-arming a slot replaces its watch; disarming clears its tag. + for tag in [3, 4] { + let target = service.watch_target(watcher, root.handle); + assert_eq!( + service.arm_watch(0x106, watcher, 1, tag, user, root.handle, target), + FsResponse::Done + ); + } + assert_eq!(service.watches.armed_count(), 1); + assert_eq!(sink.state().unwrap().tag[1], 4); + assert_eq!(service.disarm_watch(0x106, watcher, 1), FsResponse::Done); + assert_eq!(sink.state().unwrap().tag[1], 0); + assert_eq!(service.watches.armed_count(), 0); + + // Detaching the plane before the window goes drops its watches. + let target = service.watch_target(watcher, root.handle); + assert_eq!( + service.arm_watch(0x106, watcher, 2, 9, user, root.handle, target), + FsResponse::Done + ); + service.detach_watch_plane(PeerId::from_arrival(0x206).unwrap()); + assert_eq!(service.watches.armed_count(), 1, "a late detach for another generation is inert"); + service.detach_watch_plane(peer); + assert_eq!(service.watches.armed_count(), 0); + } + /// A dead writer's staged replacement is released on the service's next /// dispatch, never inside the synchronous peer-death hook. A release the /// provider refuses stays pending — custody retained — while the dead diff --git a/native/services/dagfs/src/watches.rs b/native/services/dagfs/src/watches.rs new file mode 100644 index 0000000..c7800aa --- /dev/null +++ b/native/services/dagfs/src/watches.rs @@ -0,0 +1,310 @@ +//! Directory watches: the service half of the watch plane +//! (`kore_dagfs::watch`, `res/DAGFS.md` §4). +//! +//! A session arms up to [`FS_WATCH_SLOTS`] watches, each on a directory +//! handle it holds. After every unit of service work each armed directory's +//! change epoch ([`Namespace::change_epoch`]) is read **through the handle's +//! route** — revalidated as every operation's is — and the plane republished +//! when one moved. Only a client parked on its plane is owed a wake; the +//! serving host sends each one notice (`SystemService::take_owed_wakes`). +//! +//! Nothing here is global. A peer's [`Plane`] lives in its serving slot's own +//! record, beside the slot's window and staged replacement, allocated with it +//! and wiped with it; the service keeps only [`WatchIndex`] — which slots have +//! a watch armed, and which owe a wake — so a settle visits exactly the slots +//! that need it, however large the slot space grows. A watch on the dual +//! router's system volume is recorded in the same plane with the store it +//! lives in, so the one plane a client holds carries both. +//! +//! [`Namespace::change_epoch`]: crate::namespace::Namespace::change_epoch + +use kore_abi::svc::{FS_WATCH_SLOTS, FsWatchState}; +use kore_dagfs::watch::{self, WatchSource, state}; +use kore_rt::svc::PeerId; + +use crate::{ + ObjectId, + authority::{Handle, SessionId}, + bounds::PAIR_SESSIONS, + error::{FsError, FsResult}, + router::Store, +}; + +/// One armed slot. +#[derive(Clone, Copy, Debug)] +struct Watch { + store: Store, + handle: Handle, + dir: ObjectId, + /// The epoch the plane last reported for this watch. + seen: u64, +} + +/// One recyclable peer's watch plane and the watches published into it. +pub(crate) struct Plane { + peer: PeerId, + source: WatchSource<'static>, + slots: [Option; FS_WATCH_SLOTS], + /// The plane's state changed since its last commit. + dirty: bool, +} + +impl Plane { + /// `peer`'s plane over its pair window's tail (`aux`, as the host hands it + /// to `attach_peer_aux`), or `None` for a tail without a formatted plane. + /// Starts from nothing armed, whatever the window last carried. + pub(crate) fn adopt(peer: PeerId, aux: &'static mut [u8]) -> Option { + let mut source = watch::source(aux).ok()?; + if source.replace_state(FsWatchState::default()) { + let _ = source.commit(); + } + Some(Self { + peer, + source, + slots: [None; FS_WATCH_SLOTS], + dirty: false, + }) + } + + pub(crate) const fn peer(&self) -> PeerId { + self.peer + } + + /// Whether any slot is armed. + pub(crate) fn armed(&self) -> bool { + self.slots.iter().any(Option::is_some) + } + + fn edit(&mut self, edit: impl FnOnce(&mut FsWatchState)) { + self.source.state(edit); + self.dirty = true; + } + + /// Publish a batch: `true` when its commit found the client parked, and + /// so owes it one notice. + fn commit(&mut self) -> bool { + core::mem::take(&mut self.dirty) && self.source.commit() + } + + /// Arm `slot` under `tag` on the directory `dir` at epoch `seen` (its + /// baseline: only a later change counts), replacing what the slot held. + /// Answers whether a parked client is owed a wake. + pub(crate) fn arm( + &mut self, + slot: u8, + tag: u32, + store: Store, + handle: Handle, + (dir, seen): (ObjectId, u64), + ) -> FsResult { + let slot = usize::from(slot); + if slot >= FS_WATCH_SLOTS || tag == 0 { + return Err(FsError::Range); + } + self.slots[slot] = Some(Watch { + store, + handle, + dir, + seen, + }); + self.edit(|s| state::arm(s, slot, tag)); + Ok(self.commit()) + } + + /// Disarm `slot`, armed or not. Answers whether a parked client is owed + /// a wake. + pub(crate) fn disarm(&mut self, slot: u8) -> FsResult { + let slot = usize::from(slot); + if slot >= FS_WATCH_SLOTS { + return Err(FsError::Range); + } + self.slots[slot] = None; + self.edit(|s| state::disarm(s, slot)); + Ok(self.commit()) + } + + /// Read every armed watch's epoch through `epoch` — the engine of its + /// store, revalidating its route — and publish what moved. A watch whose + /// route no longer proves ends; one whose directory was mid-publish + /// (`Busy`) counts as changed and keeps its baseline, so the next reading + /// reports the change again rather than losing it. Answers whether a + /// parked client is owed a wake. + pub(crate) fn settle( + &mut self, + session: SessionId, + mut epoch: impl FnMut(Store, SessionId, Handle, ObjectId) -> FsResult, + ) -> bool { + for slot in 0..FS_WATCH_SLOTS { + let Some(watch) = self.slots[slot].as_mut() else { + continue; + }; + match epoch(watch.store, session, watch.handle, watch.dir) { + Ok(now) if now == watch.seen => {} + Ok(now) => { + watch.seen = now; + self.edit(|s| state::changed(s, slot)); + } + Err(FsError::Busy) => self.edit(|s| state::changed(s, slot)), + Err(_) => { + self.slots[slot] = None; + self.edit(|s| state::end(s, slot)); + } + } + } + self.commit() + } +} + +/// Words of a set over every pair session's slot. +const WORDS: usize = PAIR_SESSIONS.div_ceil(64); + +/// A set of pair-session slots: one bit each, scanned word by word. +#[derive(Clone, Copy, Debug, Default)] +struct SlotSet([u64; WORDS]); + +impl SlotSet { + fn insert(&mut self, slot: usize) { + if let Some(word) = self.0.get_mut(slot / 64) { + *word |= 1 << (slot % 64); + } + } + + fn remove(&mut self, slot: usize) { + if let Some(word) = self.0.get_mut(slot / 64) { + *word &= !(1 << (slot % 64)); + } + } + + fn is_empty(&self) -> bool { + self.0.iter().all(|word| *word == 0) + } + + /// The members, lowest first, as of the call. + fn members(&self) -> impl Iterator + use<> { + let words = self.0; + (0..WORDS).flat_map(move |at| { + let mut word = words[at]; + core::iter::from_fn(move || { + (word != 0).then(|| { + let bit = word.trailing_zeros() as usize; + word &= word - 1; + at * 64 + bit + }) + }) + }) + } +} + +/// Which slots' planes have a watch armed, and which owe their client a wake. +/// Derived from the planes and kept beside them: a settle and a wake +/// collection cost the slots they name, and nothing when the sets are empty. +#[derive(Debug, Default)] +pub(crate) struct WatchIndex { + armed: SlotSet, + owed: SlotSet, +} + +impl WatchIndex { + pub(crate) const fn new() -> Self { + Self { + armed: SlotSet([0; WORDS]), + owed: SlotSet([0; WORDS]), + } + } + + /// Record what an edit of `slot`'s plane left: whether a watch is still + /// armed there, and whether its client is owed a wake. + pub(crate) fn note(&mut self, slot: usize, armed: bool, owed: bool) { + if armed { + self.armed.insert(slot); + } else { + self.armed.remove(slot); + } + if owed { + self.owed.insert(slot); + } + } + + /// Forget `slot`: its plane is gone. + pub(crate) fn forget(&mut self, slot: usize) { + self.armed.remove(slot); + self.owed.remove(slot); + } + + /// The slots with a watch armed. + pub(crate) fn armed(&self) -> impl Iterator + use<> { + self.armed.members() + } + + pub(crate) fn idle(&self) -> bool { + self.armed.is_empty() + } + + /// Hand `out` each slot owing a wake, lowest first, taking it off the set; + /// `out` answers `false` when its batch is full, and the rest stay owed. + pub(crate) fn take_owed(&mut self, mut out: impl FnMut(usize) -> bool) { + for slot in self.owed.members() { + if !out(slot) { + return; + } + self.owed.remove(slot); + } + } + + #[cfg(test)] + pub(crate) fn armed_count(&self) -> usize { + self.armed.members().count() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_slot_set_names_its_members_lowest_first_across_words() { + let mut set = SlotSet::default(); + for slot in [PAIR_SESSIONS - 1, 3, 64, 65, 0] { + set.insert(slot); + } + set.insert(PAIR_SESSIONS); // past the space: ignored + let members: std::vec::Vec = set.members().collect(); + assert_eq!(members, [0, 3, 64, 65, PAIR_SESSIONS - 1]); + set.remove(64); + set.remove(0); + assert_eq!(set.members().collect::>(), [3, 65, PAIR_SESSIONS - 1]); + assert!(!set.is_empty()); + for slot in [3, 65, PAIR_SESSIONS - 1] { + set.remove(slot); + } + assert!(set.is_empty()); + } + + #[test] + fn owed_wakes_are_taken_once_and_a_full_batch_keeps_the_rest() { + let mut index = WatchIndex::new(); + for slot in [2, 7, 9] { + index.note(slot, true, true); + } + let mut taken = std::vec::Vec::new(); + index.take_owed(|slot| { + if taken.len() == 2 { + return false; + } + taken.push(slot); + true + }); + assert_eq!(taken, [2, 7]); + taken.clear(); + index.take_owed(|slot| { + taken.push(slot); + true + }); + assert_eq!(taken, [9], "the batch's leftover, and nothing twice"); + index.forget(9); + assert_eq!(index.armed().collect::>(), [2, 7]); + index.note(2, false, false); + index.note(7, false, false); + assert!(index.idle()); + } +} diff --git a/native/services/device/boot.toml b/native/services/device/boot.toml index 81a3a77..96c7f02 100644 --- a/native/services/device/boot.toml +++ b/native/services/device/boot.toml @@ -47,5 +47,5 @@ protocol = "kore_svc_device::DeviceBroker" # row's own claimant band. instances = 8 endows = ["ROOT_COMPLEX@0", "PLATFORM_BUS@0", "FACE_SUPPLY@0"] -digest_x86_64 = "blake3:1c106650a75981ec2767aec603e78c0552a14484a1d64d848703a3d53a16102f" # res/x86_64/; make boot-digests -digest_aarch64 = "blake3:6ebaa69d4e354314c2c3fad76611aec9cde219b2d9f2afef993e91d9392f11cf" # res/aarch64/; make boot-digests-aarch64 +digest_x86_64 = "blake3:5a3ff2252bef814e79727f873c89de62565ab491f8065599ada4db348c829223" # res/x86_64/; make boot-digests +digest_aarch64 = "blake3:f00fd8b3aad6308541c51f785e3651dc793b60652a821ffee9aa7d79b49eb615" # res/aarch64/; make boot-digests-aarch64 diff --git a/native/services/mm/boot.toml b/native/services/mm/boot.toml index 600d1fb..b6412e0 100644 --- a/native/services/mm/boot.toml +++ b/native/services/mm/boot.toml @@ -54,5 +54,5 @@ supply_quota_bytes = 27917287424 # 26 GiB endows = ["MEM_SUPPLY@0", "MEM_REGION@0", "SYSTEM_OBSERVE@0"] # review mirror; asserted === PROFILE.roles shell_client = true exec_client = true -digest_x86_64 = "blake3:b68f42add3eea14c162334ca3a26e5357f1777298d46a1a969ca94d96f9e4ca0" # res/x86_64/; make boot-digests -digest_aarch64 = "blake3:ccbc42fb7b4188f46196c88c56ec42be4e756e7f654f24617f31675a4ae0fcc1" # res/aarch64/; make boot-digests-aarch64 +digest_x86_64 = "blake3:041a33515a21fff195c4cab320e9c8e64cbf775f04f01b7dbe40fa90be5a31a7" # res/x86_64/; make boot-digests +digest_aarch64 = "blake3:e17d59e57476b1db9554bc889640ffd3f02b6e2c98d509d93dea1c7efadb2a2a" # res/aarch64/; make boot-digests-aarch64 diff --git a/native/services/swap/boot.toml b/native/services/swap/boot.toml index be7578d..0a1028a 100644 --- a/native/services/swap/boot.toml +++ b/native/services/swap/boot.toml @@ -9,5 +9,5 @@ image = "kore-svc-swap" endowment = "kore_svc_swap::ZramEndowment" protocol = "kore_svc_swap::ZramSwap" endows = ["MEM_SUPPLY@0"] -digest_x86_64 = "blake3:5b7a9e35bb0732de36b6dd29d909025820bcb69df7438c7ae9eac9fef6ca7211" # res/x86_64/; make boot-digests -digest_aarch64 = "blake3:cbe8173eee0e76e47bc497fc2bcbf4cc9ee66710c6788cd15f610dd5eb3d57c3" # res/aarch64/; make boot-digests-aarch64 +digest_x86_64 = "blake3:bc3ff6fbb123b3b44645e959346e320519b9ab5f43b5c999d0685ee9c7fed141" # res/x86_64/; make boot-digests +digest_aarch64 = "blake3:6495b930db0d4e4ca6ce41b0d19580ea1d816748358cc9c8bceef150ec0ccc30" # res/aarch64/; make boot-digests-aarch64 From cbe7b0af681ff9c4b6f6d457e50182edc446ddfe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 05:32:09 +0000 Subject: [PATCH 5/5] native: re-export at Kore 7c0be6e8, the merge of #1494's platform round Kore's #1494 moved to `fe9603e3`: the kernel offers a function to drivers only on positive evidence, and a claim names its holder. MicroPerceptron/kore#1534 merges that at `7c0be6e8`, and this re-exports the closure there with `tools/sync-native.py`. libkore#32's branch has not moved, so nothing else changes. kore-abi's device state changes and gains its device classes (`device/class.rs`). The closure is 27 packages and 408 files. Against Kore `fe9603e3`, only kore-rt's `futex.rs` and `thread_controller.rs` differ, and `services/swap/boot.toml`, whose digests are this change's reseal over the ioctl round. `native/Cargo.lock` is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ACHC7hXQ3xRtyu6Yt31jXQ --- docs/provenance.md | 10 ++ native/crates/abi/src/device.rs | 2 + native/crates/abi/src/device/class.rs | 153 ++++++++++++++++++++++++++ native/crates/abi/src/device/state.rs | 15 +-- native/provenance.json | 9 +- native/services/swap/boot.toml | 4 +- 6 files changed, 180 insertions(+), 13 deletions(-) create mode 100644 native/crates/abi/src/device/class.rs diff --git a/docs/provenance.md b/docs/provenance.md index b040192..f94d2d6 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -457,3 +457,13 @@ was run by in its process page. libkore#32 exported it first, at Kore Against libkore#32's export, five services' `boot.toml` digests differ as well, from #1494's reseals that followed it. No exported manifest changed, so `native/Cargo.lock` did not. + +It then moved to Kore `7c0be6e84ac2e8ee67591b44c0f42d46ab596f83`, the merge +of #1494's next round into this change. That round has the kernel offer a +function to drivers only on positive evidence and name each claim's holder, +which kore-abi's device state and its new device classes carry. The closure +is now 27 packages and 408 files. Against Kore `fe9603e3`, only kore-rt's +`futex.rs` and `thread_controller.rs` differ, and `services/swap/boot.toml`: +swap links kore-rt's futex, so its digests are this change's reseal over +the ioctl round (`b04325e9`). No exported manifest changed, so +`native/Cargo.lock` did not. diff --git a/native/crates/abi/src/device.rs b/native/crates/abi/src/device.rs index 463489e..f739f5e 100644 --- a/native/crates/abi/src/device.rs +++ b/native/crates/abi/src/device.rs @@ -20,9 +20,11 @@ //! route) — and teardown is an ordinary `Revoke` of the leaf. mod census; +mod class; mod i2c_admission; mod state; pub use census::*; +pub use class::*; pub use i2c_admission::*; pub use state::*; diff --git a/native/crates/abi/src/device/class.rs b/native/crates/abi/src/device/class.rs new file mode 100644 index 0000000..e43aa63 --- /dev/null +++ b/native/crates/abi/src/device/class.rs @@ -0,0 +1,153 @@ +//! What a PCI function's **class code** says it is to a driver. +//! +//! A function is offered to drivers on positive evidence only. The class +//! code is the one identification every vendor's function carries, so it is +//! the evidence asked first, and it is asked in one direction: a class named +//! here as a peripheral admits a function; no class, and no absence of one, +//! proves a function safe to hand out. Everything this table does not name +//! is [`Platform`](PciClassRole::Platform), the functions a machine keeps +//! for itself: its management engine, flash and SMBus controllers, power +//! and thermal telemetry, shared memory, and whatever a vendor adds next. +//! Those differ on every platform and their classes are mostly the +//! catch-all "other" subclasses, so they are not enumerated: they are what +//! is left. A new or unknown function is therefore kept, never offered. +//! +//! The kernel decides ([`DeviceRecord::DRIVER_ENDPOINT`]); a reader of a +//! census row uses the same table to say *why* a function is withheld. +//! Codes are the PCI-SIG's (PCI Code and ID Assignment Specification, +//! "Class Codes"). +//! +//! [`DeviceRecord::DRIVER_ENDPOINT`]: super::DeviceRecord::DRIVER_ENDPOINT + +/// What a class code alone admits a function as. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PciClassRole { + /// Bus fabric: a bridge of any kind (`06`), or an IOMMU (`08/06`). It + /// carries other functions' traffic and no driver takes it. + Fabric, + /// A display controller (`03`), or the legacy VGA-compatible spelling + /// (`00/01`). A peripheral, but one firmware may still be scanning out + /// of: the kernel also requires proof of its boot ownership. + Display, + /// A device with a standard programming model that a driver is written + /// against, and that a machine keeps running without. + Peripheral, + /// Anything else: a function of the platform itself. Not offered. + Platform, +} + +impl PciClassRole { + /// The role `class`/`subclass`/`progif` admit. + pub const fn of(class: u8, subclass: u8, progif: u8) -> Self { + match (class, subclass) { + (0x06, _) | (0x08, 0x06) => Self::Fabric, + (0x03, _) | (0x00, 0x01) => Self::Display, + // Mass storage, network, multimedia (audio, video, telephony), + // input, wireless and processing accelerators: every subclass + // is a device someone attaches or a driver brings up, including + // the vendor-specific "other" ones (a Wi-Fi radio is `02/80`). + (0x01 | 0x02 | 0x04 | 0x09 | 0x0d | 0x12, _) => Self::Peripheral, + // A UART with a named register model: 16450 through 16950. + // Programming interface 0 ("generic XT") names none, and is + // what a platform stamps on functions that are not UARTs at + // all (a sensor hub, a management engine's serial redirection). + (0x07, 0x00) if matches!(progif, 0x01..=0x06) => Self::Peripheral, + // An SD host controller: the one base-system peripheral that is + // a slot for removable media rather than part of the machine. + (0x08, 0x05) => Self::Peripheral, + // Host controllers of the external peripheral buses: IEEE 1394, + // USB (every interface, USB4 included), Fibre Channel and + // InfiniBand. SMBus (`0c/05`), IPMI (`0c/08`), I3C (`0c/0a`) + // and "other" (`0c/80`: a platform's I2C, SPI and flash + // controllers) are the machine's own buses. + (0x0c, 0x00 | 0x03 | 0x04 | 0x07) => Self::Peripheral, + _ => Self::Platform, + } + } +} + +#[cfg(test)] +mod tests { + use super::PciClassRole::{self, Display, Fabric, Peripheral, Platform}; + + #[test] + fn a_class_admits_a_peripheral_and_everything_unnamed_is_the_platforms() { + for (class, subclass, progif, role, what) in [ + (0x06, 0x00, 0x00, Fabric, "host bridge"), + (0x06, 0x01, 0x00, Fabric, "ISA/LPC bridge"), + (0x06, 0x04, 0x00, Fabric, "PCI-to-PCI bridge"), + (0x08, 0x06, 0x00, Fabric, "IOMMU"), + (0x03, 0x00, 0x00, Display, "VGA controller"), + (0x03, 0x80, 0x00, Display, "display, other"), + (0x00, 0x01, 0x00, Display, "legacy VGA-compatible"), + (0x01, 0x08, 0x02, Peripheral, "NVMe"), + (0x01, 0x06, 0x01, Peripheral, "AHCI"), + (0x02, 0x00, 0x00, Peripheral, "Ethernet"), + (0x02, 0x80, 0x00, Peripheral, "Wi-Fi radio"), + (0x04, 0x03, 0x00, Peripheral, "HD Audio"), + (0x09, 0x00, 0x00, Peripheral, "keyboard"), + (0x0d, 0x11, 0x00, Peripheral, "Bluetooth"), + (0x12, 0x00, 0x00, Peripheral, "neural accelerator"), + (0x07, 0x00, 0x02, Peripheral, "16550 UART"), + (0x08, 0x05, 0x01, Peripheral, "SD host controller"), + (0x0c, 0x03, 0x30, Peripheral, "xHCI"), + (0x0c, 0x03, 0x40, Peripheral, "USB4 host interface"), + // What a machine keeps. The identities are the ones one Lunar + // Lake laptop and one AMD laptop enumerate. + (0x05, 0x00, 0x00, Platform, "PMC shared SRAM"), + (0x07, 0x80, 0x00, Platform, "management engine HECI"), + ( + 0x07, + 0x00, + 0x00, + Platform, + "sensor hub stamped generic serial", + ), + ( + 0x0c, + 0x80, + 0x00, + Platform, + "SPI flash or LPSS I2C controller", + ), + (0x0c, 0x05, 0x00, Platform, "SMBus"), + (0x0c, 0x0a, 0x00, Platform, "I3C"), + (0x11, 0x80, 0x00, Platform, "thermal and power tuning"), + (0x10, 0x80, 0x00, Platform, "platform security processor"), + (0x13, 0x00, 0x00, Platform, "instrumentation"), + (0x08, 0x80, 0x00, Platform, "system peripheral, other"), + (0x00, 0x00, 0x00, Platform, "unclassified"), + (0x00, 0xff, 0x00, Platform, "unclassified, vendor"), + (0xff, 0x00, 0x00, Platform, "unassigned"), + ] { + assert_eq!( + PciClassRole::of(class, subclass, progif), + role, + "{what} ({class:02x}/{subclass:02x}.{progif:02x})" + ); + } + } + + /// The table only ever admits by naming: no class outside the ones it + /// names is a peripheral, whatever its subclass or interface. + #[test] + fn no_unnamed_class_is_ever_a_peripheral() { + for class in 0..=u8::MAX { + if matches!( + class, + 0x01 | 0x02 | 0x04 | 0x07 | 0x08 | 0x09 | 0x0c | 0x0d | 0x12 + ) { + continue; + } + for subclass in 0..=u8::MAX { + for progif in [0x00, 0x01, 0x02, 0x30, 0x80, 0xff] { + assert_ne!( + PciClassRole::of(class, subclass, progif), + Peripheral, + "{class:02x}/{subclass:02x}.{progif:02x}" + ); + } + } + } + } +} diff --git a/native/crates/abi/src/device/state.rs b/native/crates/abi/src/device/state.rs index 5dab25c..dacf681 100644 --- a/native/crates/abi/src/device/state.rs +++ b/native/crates/abi/src/device/state.rs @@ -3,7 +3,7 @@ //! //! The census is boot-frozen identity. This is what changes after boot, and //! what a driver writer asks before touching a device: whether a driver -//! claimed the function, which founded row drives its DMA, which BARs exist, +//! claimed the function, which founded row holds that claim, which BARs exist, //! which of them a driver may map and which are mapped now, and how the //! function can interrupt. A BAR is its index, its size and the length a //! driver may map; **no address crosses** (res/DEVICE_SERVICE.md §1.1), so @@ -33,7 +33,7 @@ use super::census::{DeviceCensus, DeviceCensusPage, MAX_DEVICE_CENSUS_ROWS}; pub const DEVICE_BARS: usize = 6; /// Every BAR's bit, for the three per-BAR bitmaps of a [`DeviceState`]. pub const DEVICE_BAR_MASK: u8 = (1 << DEVICE_BARS) - 1; -/// `DeviceState::driver` when no founded row drives the function's DMA. +/// `DeviceState::driver` when no running founded row holds the function. pub const DEVICE_ROW_NONE: u8 = 0xFF; /// States per vended page: 8 header bytes + 128 = 136 bytes, the census /// page's size, so a page rides the monitor and exec reply wires as one. @@ -77,11 +77,12 @@ pub const fn device_bar_of(operand: u16) -> Option<(u16, u8)> { pub struct DeviceState { /// [`Self::PRESENT`] and the bits below it. pub flags: u8, - /// The founded row (the founding record's index) whose task activated - /// DMA on this function, or [`DEVICE_ROW_NONE`]. The kernel's own - /// shutdown ledger, so it names who drives the device rather than who - /// claimed it: a function a row claimed and delegated reads the row it - /// was delegated to, and a driver that never activates DMA reads none. + /// The founded row (the founding record's index) whose task holds this + /// function's claim, or [`DEVICE_ROW_NONE`]. The kernel records whom a + /// claim's grant is sealed for when the claim commits, and follows that + /// exact grant if it is carried on, so this names the claimant whether + /// or not it does DMA. A claim whose task is not a running founded row + /// (it ended, or it is a program) reads none: the claim still stands. pub driver: u8, /// Bit `n`: BAR `n` exists as memory the kernel resolved. An I/O BAR is /// not one: no driver may reach port space through a BAR. diff --git a/native/provenance.json b/native/provenance.json index 7a31638..f15b663 100644 --- a/native/provenance.json +++ b/native/provenance.json @@ -1,6 +1,6 @@ { "repository": "https://github.com/MicroPerceptron/kore", - "revision": "5c09f155b03682e95f24f924cdd65188fc9ebc60", + "revision": "7c0be6e84ac2e8ee67591b44c0f42d46ab596f83", "packages": { "crates/abi": "kore-abi", "crates/app": "kore-app", @@ -38,10 +38,11 @@ "crates/abi/src/clock.rs": "cce1c2839cba2377d3cc8696d60812f9195f071f422bd97bb4530ea0368972fc", "crates/abi/src/command.rs": "e994b1527c989cc106281aa127df9a54dc03e1c85f3b15c49ba70ad5e5d65721", "crates/abi/src/crash.rs": "f3c9e5a954e06743f6993b0a5eeb6937d2199c50af5fe95b6be446393ccf999b", - "crates/abi/src/device.rs": "06e8891917210870b64fa2ec3d2018067233ad333cf9d1bd13b3ce848fca97ed", + "crates/abi/src/device.rs": "9767bc5990be3e7e13c0542e1f96d738f8f0220d38dbf342742cb4dd3dd1a989", "crates/abi/src/device/census.rs": "6fb54ecaade94b116f41e045b098dc5926e8e1c75eeea33870dd4be01bd1ce23", + "crates/abi/src/device/class.rs": "dacb939e32c175cde259ec2b8ba05132b810583fe6af4615479a0843cc8cc2dc", "crates/abi/src/device/i2c_admission.rs": "1ea2fc04cdb0b15186d2f1d80bde53187860e2197263e225f330965d96e40876", - "crates/abi/src/device/state.rs": "8caf4b751b4cae54f3d78626440bd847a39f1138a4dbc6055d6e8a1a1576ac32", + "crates/abi/src/device/state.rs": "a67c721ce8280c77251eac6d63a3513c8503236e89d2e5ce9d1c9e9245440f9b", "crates/abi/src/execution.rs": "106d679a3f993c3f4e7ad4eb625d40f4399f5b9b50947cb0336ba44a1d6b51af", "crates/abi/src/founding.rs": "801b8e9771763ff9223c3537aabb5653713d1883d1df62ff673f43982aa3e802", "crates/abi/src/guest/aarch64/mod.rs": "1fd24be3fa6cbc344e9873a0e3e75fabd1b919326e477ec4ea4cf148c01b66f8", @@ -423,7 +424,7 @@ "services/output/src/service/mod.rs": "d80c3beb50979b3e1e500abdfa3c0b9d38e354c24e2e34aea443732abbead6f1", "services/output/src/wire.rs": "3752e994eb18714518facae1b78a9ee2b34f99aad8687653f0b89dc8fdeca621", "services/swap/Cargo.toml": "9c0af01403a6ad5a7f4ff69a02b18c8b1b2624a684814d6dc1ac07968c5bbe53", - "services/swap/boot.toml": "fd0b712488ceb0bc035992dd42d685992fa748fdfc955c757b507d8def862dfb", + "services/swap/boot.toml": "4a459ffb0365de3f761f768d393fcca67fe649a96591b4d8561a79bb46235a89", "services/swap/src/client.rs": "04f735d059b491765d5ba63a4f5f640332735bf336e62dcbae86c17647583e2c", "services/swap/src/extent.rs": "d6705f77951df1c42cda51fc4526ba6262cab6af4a0545405ce06d2d2e1db7b4", "services/swap/src/lib.rs": "883684df54a87ae66aa7895ea19a872cabc8ab7fd4aded4ea65b7e3dd51fe0cf", diff --git a/native/services/swap/boot.toml b/native/services/swap/boot.toml index 9dabf37..117cdb7 100644 --- a/native/services/swap/boot.toml +++ b/native/services/swap/boot.toml @@ -9,5 +9,5 @@ image = "kore-svc-swap" endowment = "kore_svc_swap::ZramEndowment" protocol = "kore_svc_swap::ZramSwap" endows = ["MEM_SUPPLY@0"] -digest_x86_64 = "blake3:aa0fa749d355437dbd3a239e58b299cd9ff7d33854f60e15905b1b6bc7aabd30" # res/x86_64/; make boot-digests -digest_aarch64 = "blake3:efaa5dddb597e5abadc3b71bf4a45af9f63264aab7c9772ce1574b62ef14cb85" # res/aarch64/; make boot-digests-aarch64 +digest_x86_64 = "blake3:c64857d1f04b3d2fcf5fcb547fc4a64eddd367df7e326341adb64e7798488dce" # res/x86_64/; make boot-digests +digest_aarch64 = "blake3:bfdbd6b573e15ad82edb567ef3cfab5112e298fbdccd284418b9c29809bdf4de" # res/aarch64/; make boot-digests-aarch64