diff --git a/builtin/runtime/async.lua b/builtin/runtime/async.lua index af74cc1..000be49 100644 --- a/builtin/runtime/async.lua +++ b/builtin/runtime/async.lua @@ -88,6 +88,28 @@ function Handle:await() error("await: cannot await inside pmacs.window.commit_to; " .. "await first, then commit") end + -- Worker identity Stage 1 (Q#W-2 rule 1): `pmacs.workers.dispatch` + -- pushes the registered handler's name for the dynamic extent of the + -- handler call, so that jobs allocated inside it are attributable to + -- the third party that asked for them. Parking here would leave the + -- name pushed while this coroutine is suspended, and every job + -- allocated in the meantime --- in any coroutine, on any later tick + -- --- would inherit it. Same hazard, same shape, same remedy as the + -- commit-scope refusal above. + -- + -- Two properties this placement buys, both load-bearing: + -- + -- * it rejects BEFORE parking (ahead of the `_is_complete` check and + -- the `coroutine.yield`), because a guard consulted after the yield + -- has already happened guards nothing; + -- * it rejects UNCONDITIONALLY, not only when a yield would really + -- occur. A guard that fires only for an incomplete handle would + -- pass or fail depending on whether the job happened to settle + -- first --- green under test, intermittent in production. + if async_mod._in_dispatch_name_scope() then + error("await: cannot await inside pmacs.workers.dispatch; " .. + "await first, then dispatch") + end if not async_mod._is_complete(self._id) then -- Yield self so pmacs.async's step() can park us. R46 carve-out: -- this `coroutine.yield` is runtime code; package code uses @@ -240,7 +262,28 @@ setmetatable(async_public, { end, }) +-- The SECOND supported yield API. `Handle:await()` is the first; any +-- rule about a non-yieldable dynamic extent has to cover both, or the +-- extent stays open through a second door. +-- +-- Both refusals below are that rule. The commit-scope one is a +-- **pre-existing gap being closed** (worker identity framing Q#W-7): +-- Journey Stage 1a's Q#JR14b invariant was enforced on `:await()` only, +-- so a coroutine inside `pmacs.window.commit_to` could park through here +-- and produce exactly the misrouting that guard exists to prevent. +-- +-- Placement is the whole point: both fire *before* the `coroutine.yield` +-- below, and both fire unconditionally. A refusal sited after the yield +-- would never run in the case it exists for. function async_public.yield_to_next_tick() + if async_mod._in_commit_scope() then + error("yield_to_next_tick: cannot yield inside pmacs.window.commit_to; " .. + "yield first, then commit") + end + if async_mod._in_dispatch_name_scope() then + error("yield_to_next_tick: cannot yield inside pmacs.workers.dispatch; " .. + "yield first, then dispatch") + end coroutine.yield({ _is_pmacs_next_tick = true }) end @@ -366,12 +409,63 @@ local handlers = { end, } +-- Worker identity Stage 1 (Q#W-2): `name` used to die here. +-- +-- The audit's "every third-party job renders under a builtin's label" is +-- exact, and the reason is this function: the handler is arbitrary Lua, +-- nothing below it takes a name, and a handler that reaches straight for +-- `pmacs._async._dispatch_*` bypasses the wrapper layer entirely. So the +-- name is pushed onto a runtime-owned stack for the dynamic extent of +-- the handler call and read at `allocate`, the single funnel every job +-- passes through. Seven rules govern it; five are visible here: +-- +-- 1. The extent is NON-YIELDABLE, and that is enforced rather than +-- assumed --- see the refusals in `Handle:await` and +-- `pmacs.async.yield_to_next_tick`. +-- 3. Nesting is a stack; innermost wins. +-- 4. Fan-out shares the name: five jobs dispatched by one handler are +-- five jobs named alike. They *were* all dispatched under it. +-- 5. UNWIND-SAFE, and this is the one that makes a naive version worse +-- than none. A handler that raises must still pop --- otherwise one +-- failure poisons every subsequent dispatch in the session with a +-- stale name, and the feature starts lying silently instead of +-- failing loudly. Hence pcall, pop, rethrow. +-- 7. Outside any extent nothing changes: a builtin invoked directly +-- records its own purpose. +-- +-- Rule 2 (work dispatched later, from an `on_complete` callback or a +-- resumed coroutine, is deliberately NOT covered) and rule 6 +-- (composition, `": "`) live on the Rust side. +-- +-- The pop/rethrow half, hoisted so it is written once and allocates +-- nothing per dispatch. +-- +-- Varargs across a function boundary, NOT `local ok, result = pcall(…)`: +-- this function used to be `return handler(args, opts)`, which +-- propagates EVERY return value, and bracketing it must not silently +-- truncate a handler that returns more than one. `table.pack` / +-- `table.unpack` would say the same thing but are Lua 5.2 surface, and +-- LuaJIT is this project's default backend (`Cargo.toml`: +-- `default = ["luajit"]`). +local function finish_dispatch(ok, ...) + async_mod._pop_dispatch_name() + if not ok then + -- Level 0: the handler's error travels unchanged. R45's structured + -- errors are tables, and a re-raise that appended position info + -- would corrupt a plain-string error and be silently ignored for a + -- table one --- so neither shape is served by the default level. + error((...), 0) + end + return ... +end + function pmacs.workers.dispatch(name, args, opts) local handler = handlers[name] if handler == nil then error("pmacs.workers.dispatch: unknown handler '" .. tostring(name) .. "'") end - return handler(args, opts) + async_mod._push_dispatch_name(name) + return finish_dispatch(pcall(handler, args, opts)) end function pmacs.workers.register(name, handler) @@ -581,6 +675,60 @@ function pmacs._async.tick() end end +-- --------------------------------------------------------------------------- +-- Statusline activity indicator (worker identity Stage 1, Q#W-3/Q#W-6). +-- --------------------------------------------------------------------------- +-- +-- `COHERENCE.md` §9 records that no progress indicator exists anywhere +-- --- no spinner, no busy count --- which makes §3's promise of "visible +-- asynchronous work" false unless the user knows to run +-- `M-x editor.list-workers`. This is the fourth `pmacs.statusline.register` +-- adopter (after `mode`, `terminal` and `lsp`) and the first thing that +-- makes background work visible without a command. +-- +-- No wire change: `pmacs.statusline.register` rides the existing +-- `StatuslineSegments` vector, so a fourth provider adds an ELEMENT, not +-- a variant. That is what lets this lane run beside the two holding the +-- protocol-bump slot. + +-- A visibility toggle, and only that (Q#W-6). A permanently-visible +-- statusline element is different in kind from an internal behaviour: it +-- costs modeline width on every frame, and "I do not want this in my +-- modeline" is a preference someone genuinely holds on day one. There is +-- deliberately NO setting for purpose capture itself --- that is +-- substrate, not preference. +pmacs.config.define { + name = "ui.activity-indicator", + description = "Show a modeline count of in-flight background jobs, with the oldest job's purpose. Absent entirely when nothing is running.", + type = "boolean", + default = true, + mutability = "live", +} + +pmacs.statusline.register { + name = "activity", + side = "right", + -- Above `terminal` (10) and `lsp` (0): when the modeline is too narrow + -- for everything, "the editor is busy, on this" is the segment worth + -- keeping. Right-side display order is priority-ascending, so it also + -- lands nearest the protected cursor/scroll group. + priority = 20, + face = "ui.modeline.activity", + fn = function(_ctx) + if pmacs.config.get("ui.activity-indicator") ~= true then return nil end + -- `_activity_summary` rather than `pmacs.workers.snapshot()`: this + -- runs once per visible window per frame, and a snapshot would clone + -- the whole 64-entry completed ring that the indicator never reads. + local summary = async_mod._activity_summary() + -- nil, not "" and not "0 jobs": the evaluator treats an empty string + -- as "no segment" too, but a zero-count string would be a segment + -- that costs width forever to say nothing is happening. Absence is + -- the design (Q#W-3), so absence is what this returns. + if summary == nil then return nil end + return "⋯" .. tostring(summary.in_flight) .. " " .. summary.purpose + end, +} + -- Diagnostic / test helpers: number of parked coroutines, number of -- pending Rust-side jobs. Used by Rust integration tests to drive the -- runtime to quiescence. diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 78eb64a..8f5d817 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -14939,6 +14939,95 @@ mod tests { assert_eq!(after[2].1, Color::rgb(20, 220, 40)); } + /// Worker identity Stage 1 (`docs/worker-identity-framing.md` §6): + /// the GPU half of "both frontends render the segment". + /// + /// The activity indicator adds no wire message — it rides the + /// existing `StatuslineSegments` vector as a fourth provider's + /// element. But that is a claim about the **producer**, and says + /// nothing about whether a consumer draws it, which is why this + /// exists on the consumer side. + /// + /// Two properties specific to this segment, neither of which the + /// existing rich-runs test covers: + /// + /// * its face (`ui.modeline.activity`) is **deliberately absent + /// from `ThemeFacts`** — no theme sets it, and `theme_facts_msg` + /// ships only faces that resolve — so a consumer that dropped + /// segments with an unknown face would silently lose the one + /// thing telling the user the editor is busy; + /// * its text leads with a non-ASCII `⋯`, which a byte-oriented + /// composition step would mangle. + #[test] + fn the_activity_segment_survives_an_unthemed_face_and_a_non_ascii_lead() { + let Some(mut state) = headless_or_skip(500, 280, "text") else { + return; + }; + let buffer_id = BufferId::next(); + state.current_buffer_id = Some(buffer_id); + state.status_facts = Some(status_facts(buffer_id, None)); + state.own_cursor = Some(OwnCursor { buffer_id, byte: 0 }); + // One themed face, and NOT the activity one: the point is that + // the theme has an opinion about some segments and none about + // this one. + apply_faces( + &mut state, + vec![theme_face( + "ui.modeline.lsp", + CellStyle { + fg: CellColor::Rgb(20, 220, 40), + ..CellStyle::default() + }, + )], + ); + apply_statusline( + &mut state, + buffer_id, + Vec::new(), + vec![ + statusline_segment("LSP:rust", "ui.modeline.lsp"), + statusline_segment("⋯2 lsp textDocument/definition", "ui.modeline.activity"), + ], + ); + + let right = state.compose_status_runs(); + let text: String = right.iter().map(|(text, _)| text.as_str()).collect(); + assert!( + text.contains("⋯2 lsp textDocument/definition"), + "the activity segment must reach the composed right runs \ + intact: {text:?}" + ); + let activity = right + .iter() + .find(|(run, _)| run.contains('⋯')) + .expect("activity run"); + assert_eq!( + activity.1, + state.status_right_base_color(), + "an unthemed modeline face falls back to the base colour \ + rather than dropping the segment" + ); + assert_eq!( + right[0].1, + Color::rgb(20, 220, 40), + "and its themed neighbour still takes its own colour" + ); + + // And it survives the real shaping pass, not only composition. + let _ = state.render_offscreen(); + let shaped: String = state + .status_runs + .as_ref() + .expect("right shaped") + .iter() + .map(|(text, _)| text.as_str()) + .collect(); + assert!( + shaped.contains("⋯2 lsp textDocument/definition"), + "{shaped:?}" + ); + } + #[test] fn modal_left_precedence_suppresses_custom_left_but_preserves_right() { let Some(mut state) = headless_or_skip(420, 260, "text") else { diff --git a/src/async_runtime.rs b/src/async_runtime.rs index 3620a32..e60d0ae 100644 --- a/src/async_runtime.rs +++ b/src/async_runtime.rs @@ -408,6 +408,47 @@ struct PendingJob { /// job→buffer link already lives in a side map and §9 names that as /// the defect. resource: Option, + /// What this job is doing, in words a user can read (worker + /// identity Stage 1, `COHERENCE.md` §9). + /// + /// **Not an owner.** It records *what work* is running and — when + /// the job was born inside a `pmacs.workers.dispatch` extent — the + /// registered handler name it ran under. Neither is the package + /// responsible for it; that slot is deliberately empty until P3 can + /// fill it with a real package signal (framing §3). + /// + /// Non-optional by construction: [`JobSpec`] has no `Default`, so a + /// dispatcher that supplies none does not compile. + purpose: String, +} + +/// Everything one job is born with. +/// +/// **Private, and deliberately so** (framing Q#W-1). The two-function +/// `allocate` / `allocate_with_resource` split existed only because one +/// prior lane needed one extra parameter; a second lane doing the same +/// produces `allocate_with_resource_and_identity`. Collapsing the pair +/// into a struct means the next field is a named literal at each of the +/// eleven construction sites rather than another positional parameter on +/// a public signature. +/// +/// **There is no `Default` impl, and that is the point.** `purpose` is +/// what makes the compiler — not a test — the thing that proves every +/// dispatcher supplied one (framing §6). A `Default` would let a new +/// dispatcher write `..Default::default()` and silently ship an empty +/// identity. +struct JobSpec<'a> { + /// Which builtin handler this job runs. + kind: JobKind, + /// Supersede key, if the dispatch opted into supersession. + supersede: Option<&'a str>, + /// `Some(max_batch)` marks this as a streaming dispatch. + stream: Option, + /// Filesystem mutation this job performs, for the settle-time + /// reconcile (dired Stage 2a). + resource: Option, + /// What the job is doing. See [`PendingJob::purpose`]. + purpose: String, } /// A settled filesystem mutation, with the paths the worker consumed @@ -490,6 +531,9 @@ pub struct ActiveJobInfo { /// True if this is a streaming dispatch (`emit_n`, `grep`, ...); /// false if it's request/reply (`sleep`, `compute_sum`). pub is_stream: bool, + /// What this job is doing (worker identity Stage 1). Rendered by + /// `*workers*` and by the statusline activity indicator. + pub purpose: String, } /// One row in the `*workers*` buffer's "completed" section: a job @@ -507,6 +551,8 @@ pub struct CompletedJobInfo { pub settled_age_ms: u64, /// Supersede key (if any) the job was dispatched under. pub supersede_key: Option, + /// What this job was doing (worker identity Stage 1). + pub purpose: String, /// Terminal outcome. `None` is unreachable here --- only /// settled jobs land in the completed ring. pub outcome: JobOutcome, @@ -543,9 +589,33 @@ struct CompletedSlot { dispatched_at: Instant, settled_at: Instant, supersede_key: Option, + purpose: String, outcome: JobOutcome, } +/// What the statusline activity indicator needs, and nothing more +/// (framing Q#W-3). +/// +/// A dedicated read surface rather than [`WorkersSnapshot`]: the +/// indicator is evaluated once per visible window per frame, and a +/// snapshot clones the whole completed ring (up to +/// [`COMPLETED_RING_CAP`] entries) that the indicator never looks at. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ActivitySummary { + /// How many jobs are in flight. Always ≥ 1 — an idle runtime + /// returns `None` rather than a zero count, because a segment that + /// is always present costs modeline width forever to say "nothing + /// is happening". + pub in_flight: usize, + /// The **oldest** in-flight job's purpose. + /// + /// Oldest, not newest and not "busiest": jobs carry no cost + /// estimate, so "busiest" is not a defined quantity, while oldest + /// is computable from `dispatched_at` and answers the question a + /// user actually asks of a stuck editor. + pub oldest_purpose: String, +} + /// One frame's worth of streamed items for a single stream id, /// returned by [`AsyncRuntime::take_stream_batches`]. T M3.5. #[derive(Clone, Debug)] @@ -610,6 +680,29 @@ pub struct AsyncRuntime { /// only contended at parse settle/take time --- never inside the /// editor's hot path. T M4.1. parse_handoff: Arc>>>, + /// Registered handler names of the `pmacs.workers.dispatch` calls + /// currently on the stack (worker identity Stage 1, Q#W-2). + /// + /// `pmacs.workers.dispatch(name, …)` looks `name` up, calls the + /// handler, and returns whatever it returns — **`name` is not a + /// parameter of any layer below that call**, and a handler that + /// reaches straight for `pmacs._async._dispatch_*` bypasses the Lua + /// wrapper layer entirely. So the name has to travel out of band, and + /// it is read here, at the one allocation funnel every job passes + /// through. + /// + /// A stack, not a slot: nesting is real (a handler may dispatch + /// through another registered handler) and innermost wins. + /// + /// **The extent is non-yieldable, and `async.lua` enforces it** — + /// both supported yield APIs refuse inside it, because parking a + /// coroutine with a name still pushed hands that name to whatever + /// allocates next. The one hole is a raw `coroutine.yield`, which + /// violates R46 and which no refusal sited in a yield helper can + /// intercept (the scheduler only sees the yielded value after the + /// coroutine has already suspended). That residual is recorded in + /// `docs/worker-identity-framing.md` §2, not claimed closed. + dispatch_names: RefCell>, } /// Default cap on stream items delivered in a single drain. 1024 @@ -650,6 +743,7 @@ impl AsyncRuntime { frame_target_ms: Cell::new(DEFAULT_FRAME_TARGET_MS), completed: RefCell::new(VecDeque::with_capacity(COMPLETED_RING_CAP)), parse_handoff: Arc::new(Mutex::new(HashMap::new())), + dispatch_names: RefCell::new(Vec::new()), } } @@ -733,34 +827,83 @@ impl AsyncRuntime { self.default_max_batch.set(n.clamp(1, 1_000_000)); } + /// Push a `pmacs.workers.dispatch` handler name for the dynamic + /// extent of that handler's call (worker identity Stage 1, Q#W-2). + /// + /// Paired with [`Self::pop_dispatch_name`] by + /// `pmacs.workers.dispatch`, which brackets the handler call under + /// `pcall` so a raising handler still pops. An unpaired push is the + /// failure mode that matters: it would poison every later dispatch + /// in the session with a stale name, and the feature would start + /// lying silently rather than loudly. + pub fn push_dispatch_name(&self, name: impl Into) { + self.dispatch_names.borrow_mut().push(name.into()); + } + + /// Pop the innermost dispatch-handler name. No-op when the stack is + /// already empty — an unbalanced pop is a Lua-side bug, and + /// panicking here would turn it into a torn editor rather than a + /// missing label. + pub fn pop_dispatch_name(&self) { + self.dispatch_names.borrow_mut().pop(); + } + + /// Whether a `pmacs.workers.dispatch` handler is on the stack. + /// + /// Read from Lua as `pmacs._async._in_dispatch_name_scope()`. Both + /// supported yield APIs refuse while it is set (Q#W-2 rule 1), for + /// the same reason `Handle:await` refuses inside + /// `pmacs.window.commit_to`: yielding would park the coroutine with + /// the name still pushed, and the next allocation — in any + /// coroutine, on any later tick — would inherit it. + #[must_use] + pub fn in_dispatch_name_scope(&self) -> bool { + !self.dispatch_names.borrow().is_empty() + } + + /// The innermost dispatch-handler name, if any. Nesting is a stack + /// and innermost wins (Q#W-2 rule 3). + #[must_use] + pub fn current_dispatch_name(&self) -> Option { + self.dispatch_names.borrow().last().cloned() + } + /// Register a fresh pending entry and return its id + cancel /// token. The token is what the worker closure polls; the entry /// is what `tick` updates on reply. /// - /// If `supersede_key` is `Some(key)`, any in-flight predecessor + /// **This is the single allocation funnel**: every job in the + /// system — the ten `dispatch_*` methods and + /// [`Self::register_external`] alike — is born here, which is what + /// makes the identity field reachable by construction rather than by + /// audit. + /// + /// If `spec.supersede` is `Some(key)`, any in-flight predecessor /// under the same key has its cancel token flipped *before* this /// allocation returns, and the `key → id` table is updated to /// point at the new id. The predecessor's pending entry is /// retained --- its worker will produce a `Cancelled` reply that /// `tick` then surfaces. - fn allocate( - &self, - kind: JobKind, - supersede_key: Option<&str>, - stream: Option, - ) -> (JobId, CancellationToken) { - self.allocate_with_resource(kind, supersede_key, stream, None) - } - - /// [`Self::allocate`], plus the filesystem mutation this job - /// performs. Only the two mutating fs dispatchers pass `resource`. - fn allocate_with_resource( - &self, - kind: JobKind, - supersede_key: Option<&str>, - stream: Option, - resource: Option, - ) -> (JobId, CancellationToken) { + /// + /// The recorded purpose **composes** with any dispatch-name ambient + /// rather than replacing it (Q#W-2 rule 6): `": "` + /// where the dispatcher described its own work, `""` where it + /// did not. Letting the dispatcher's purpose win would lose the + /// third-party caller all over again; letting the name win would + /// discard the only description of the actual work. + fn allocate(&self, spec: JobSpec<'_>) -> (JobId, CancellationToken) { + let JobSpec { + kind, + supersede: supersede_key, + stream, + resource, + purpose, + } = spec; + let purpose = match self.current_dispatch_name() { + Some(name) if purpose.is_empty() => name, + Some(name) => format!("{name}: {purpose}"), + None => purpose, + }; let id = self.next_job_id.fetch_add(1, Ordering::Relaxed); let cancel = CancellationToken::new(); if let Some(key) = supersede_key { @@ -788,6 +931,7 @@ impl AsyncRuntime { kind, dispatched_at: Instant::now(), resource, + purpose, }, ); (id, cancel) @@ -801,7 +945,13 @@ impl AsyncRuntime { /// dispatched under `key` is cancelled before this dispatch /// returns. T M3.4 / [spec §6.3]. pub fn dispatch_sleep(&self, ms: i64, supersede: Option<&str>) -> JobId { - let (id, cancel) = self.allocate(JobKind::Sleep, supersede, None); + let (id, cancel) = self.allocate(JobSpec { + kind: JobKind::Sleep, + supersede, + stream: None, + resource: None, + purpose: format!("sleep {}ms", ms.max(0)), + }); let bus = self.workers.clone(); let total = Duration::from_millis(ms.max(0).unsigned_abs()); self.pool.dispatch(move |_pool| { @@ -816,7 +966,13 @@ impl AsyncRuntime { /// the granular cancel boundary. `supersede` follows the same /// rule as [`Self::dispatch_sleep`]. pub fn dispatch_compute_sum(&self, n: u64, supersede: Option<&str>) -> JobId { - let (id, cancel) = self.allocate(JobKind::ComputeSum, supersede, None); + let (id, cancel) = self.allocate(JobSpec { + kind: JobKind::ComputeSum, + supersede, + stream: None, + resource: None, + purpose: format!("sum 1..{n}"), + }); let bus = self.workers.clone(); self.pool.dispatch(move |_pool| { let kind = run_compute_sum(&cancel, n); @@ -842,7 +998,13 @@ impl AsyncRuntime { max_batch: Option, ) -> JobId { let cap = max_batch.map_or_else(|| self.default_max_batch.get(), |n| n.clamp(1, 1_000_000)); - let (id, cancel) = self.allocate(JobKind::EmitN, supersede, Some(cap)); + let (id, cancel) = self.allocate(JobSpec { + kind: JobKind::EmitN, + supersede, + stream: Some(cap), + resource: None, + purpose: format!("emit {count} items"), + }); let bus = self.workers.clone(); self.pool.dispatch(move |_pool| { run_emit_n(&cancel, &bus, id, count); @@ -870,7 +1032,13 @@ impl AsyncRuntime { max_batch: Option, ) -> JobId { let cap = max_batch.map_or_else(|| self.default_max_batch.get(), |n| n.clamp(1, 1_000_000)); - let (id, cancel) = self.allocate(JobKind::Grep, supersede, Some(cap)); + let (id, cancel) = self.allocate(JobSpec { + kind: JobKind::Grep, + supersede, + stream: Some(cap), + resource: None, + purpose: format!("grep {:?} in {}", spec.pattern, spec.root.display()), + }); let bus = self.workers.clone(); self.pool.dispatch(move |_pool| { run_grep(&cancel, &bus, id, spec); @@ -897,7 +1065,13 @@ impl AsyncRuntime { /// in-flight predecessor under the same key has its cancel token /// flipped synchronously. T M4.1 / [spec §6.3]. pub fn dispatch_parse(&self, spec: ParseRequest, supersede: Option<&str>) -> JobId { - let (id, cancel) = self.allocate(JobKind::Parse, supersede, None); + let (id, cancel) = self.allocate(JobSpec { + kind: JobKind::Parse, + supersede, + stream: None, + resource: None, + purpose: format!("parse {}", spec.language_name), + }); let bus = self.workers.clone(); let handoff = self.parse_handoff.clone(); self.pool.dispatch(move |_pool| { @@ -922,7 +1096,13 @@ impl AsyncRuntime { tolerance: ReadDirTolerance, supersede: Option<&str>, ) -> JobId { - let (id, cancel) = self.allocate(JobKind::FsReadDir, supersede, None); + let (id, cancel) = self.allocate(JobSpec { + kind: JobKind::FsReadDir, + supersede, + stream: None, + resource: None, + purpose: format!("read_dir {}", path.display()), + }); let bus = self.workers.clone(); self.pool.dispatch(move |_pool| { let kind = run_fs_read_dir(&cancel, &path, tolerance); @@ -934,7 +1114,13 @@ impl AsyncRuntime { /// Dispatch a `stat(path)` job. Returns one [`FsDirEntry`] of /// metadata for `path`. T M8.1. pub fn dispatch_fs_stat(&self, path: PathBuf, supersede: Option<&str>) -> JobId { - let (id, cancel) = self.allocate(JobKind::FsStat, supersede, None); + let (id, cancel) = self.allocate(JobSpec { + kind: JobKind::FsStat, + supersede, + stream: None, + resource: None, + purpose: format!("stat {}", path.display()), + }); let bus = self.workers.clone(); self.pool.dispatch(move |_pool| { let kind = run_fs_stat(&cancel, &path); @@ -948,15 +1134,16 @@ impl AsyncRuntime { pub fn dispatch_fs_rename(&self, from: PathBuf, to: PathBuf, supersede: Option<&str>) -> JobId { // The closure below MOVES both paths; the pending entry is the // only thing that still knows them when the reply lands. - let (id, cancel) = self.allocate_with_resource( - JobKind::FsRename, + let (id, cancel) = self.allocate(JobSpec { + kind: JobKind::FsRename, supersede, - None, - Some(ResourceOp::Rename { + stream: None, + resource: Some(ResourceOp::Rename { from: from.clone(), to: to.clone(), }), - ); + purpose: format!("rename {} -> {}", from.display(), to.display()), + }); let bus = self.workers.clone(); self.pool.dispatch(move |_pool| { let kind = run_fs_rename(&cancel, &from, &to); @@ -967,7 +1154,13 @@ impl AsyncRuntime { /// Dispatch a `chmod(path, mode)` job. T M8.1. pub fn dispatch_fs_chmod(&self, path: PathBuf, mode: u32, supersede: Option<&str>) -> JobId { - let (id, cancel) = self.allocate(JobKind::FsChmod, supersede, None); + let (id, cancel) = self.allocate(JobSpec { + kind: JobKind::FsChmod, + supersede, + stream: None, + resource: None, + purpose: format!("chmod {mode:o} {}", path.display()), + }); let bus = self.workers.clone(); self.pool.dispatch(move |_pool| { let kind = run_fs_chmod(&cancel, &path, mode); @@ -978,12 +1171,13 @@ impl AsyncRuntime { /// Dispatch a `remove(path)` job. T M8.1. pub fn dispatch_fs_remove(&self, path: PathBuf, supersede: Option<&str>) -> JobId { - let (id, cancel) = self.allocate_with_resource( - JobKind::FsRemove, + let (id, cancel) = self.allocate(JobSpec { + kind: JobKind::FsRemove, supersede, - None, - Some(ResourceOp::Remove { path: path.clone() }), - ); + stream: None, + resource: Some(ResourceOp::Remove { path: path.clone() }), + purpose: format!("remove {}", path.display()), + }); let bus = self.workers.clone(); self.pool.dispatch(move |_pool| { let kind = run_fs_remove(&cancel, &path); @@ -1008,12 +1202,27 @@ impl AsyncRuntime { /// same supervisor (DAP, etc.) reuse this surface. /// /// `supersede` follows the same rule as the worker dispatchers. + /// + /// `purpose` is **required and has no derivable fallback** here, + /// which is why it is a parameter rather than something this method + /// composes for itself. The ten pool dispatchers each know what + /// their own job does; `register_external` knows only a `JobKind` + /// that is `McpRequest` or `LspRequest` — a category, not a + /// description. The caller is the only party that can say + /// `"lsp textDocument/definition"`. pub fn register_external( &self, kind: JobKind, supersede: Option<&str>, + purpose: impl Into, ) -> (JobId, CancellationToken) { - self.allocate(kind, supersede, None) + self.allocate(JobSpec { + kind, + supersede, + stream: None, + resource: None, + purpose: purpose.into(), + }) } /// Settle an externally-registered job with a JSON value. Wakes @@ -1206,6 +1415,7 @@ impl AsyncRuntime { dispatched_at: job.dispatched_at, settled_at: now, supersede_key: job.supersede_key.clone(), + purpose: job.purpose.clone(), outcome, }); } @@ -1243,6 +1453,7 @@ impl AsyncRuntime { supersede_key: j.supersede_key.clone(), cancel_requested: j.cancel.is_cancelled(), is_stream: j.stream_buffer.is_some(), + purpose: j.purpose.clone(), }) .collect(); // Stable order: oldest first. The buffer renderer renders in @@ -1262,12 +1473,51 @@ impl AsyncRuntime { .as_millis() as u64, settled_age_ms: now.saturating_duration_since(c.settled_at).as_millis() as u64, supersede_key: c.supersede_key.clone(), + purpose: c.purpose.clone(), outcome: c.outcome.clone(), }) .collect(); WorkersSnapshot { active, completed } } + /// What the statusline activity indicator shows, or `None` when + /// nothing is in flight (worker identity Stage 1, Q#W-3). + /// + /// `None` at zero is the contract, not an optimization: the + /// indicator renders **no segment at all** when idle, because a + /// statusline element that is always present costs modeline width + /// forever to say "nothing is happening". + /// + /// Scans the pending table rather than reusing + /// [`Self::workers_snapshot`]: this runs once per visible window per + /// frame, and a snapshot would clone the whole completed ring that + /// the indicator never reads. + #[must_use] + pub fn activity_summary(&self) -> Option { + let pending = self.pending.borrow(); + let mut in_flight = 0usize; + let mut oldest: Option<(&Instant, &str)> = None; + for job in pending.values() { + if !matches!(job.state, PendingState::Running) { + continue; + } + in_flight += 1; + // Strictly-earlier wins, so the first job seen holds the + // slot against later ties. `HashMap` iteration order is + // arbitrary, so two jobs dispatched in the same `Instant` + // resolve arbitrarily — a tie between simultaneous jobs has + // no right answer to lose. + if oldest.is_none_or(|(seen, _)| job.dispatched_at < *seen) { + oldest = Some((&job.dispatched_at, job.purpose.as_str())); + } + } + let (_, purpose) = oldest?; + Some(ActivitySummary { + in_flight, + oldest_purpose: purpose.to_owned(), + }) + } + /// Drain the per-stream accumulators into one batch each. Each /// returned batch is bounded by the stream's `max_batch`; items /// beyond the cap stay in the accumulator until the next call. @@ -1876,23 +2126,25 @@ mod tests { fn tick_reports_resources_in_bus_arrival_order_not_allocation_order() { fn run(reverse: bool) -> Vec { let rt = AsyncRuntime::with_pool_size(1); - let (a, _) = rt.allocate_with_resource( - JobKind::FsRename, - None, - None, - Some(ResourceOp::Rename { + let (a, _) = rt.allocate(JobSpec { + kind: JobKind::FsRename, + supersede: None, + stream: None, + resource: Some(ResourceOp::Rename { from: PathBuf::from("/tmp/a-from"), to: PathBuf::from("/tmp/a-to"), }), - ); - let (b, _) = rt.allocate_with_resource( - JobKind::FsRemove, - None, - None, - Some(ResourceOp::Remove { + purpose: "rename a".to_owned(), + }); + let (b, _) = rt.allocate(JobSpec { + kind: JobKind::FsRemove, + supersede: None, + stream: None, + resource: Some(ResourceOp::Remove { path: PathBuf::from("/tmp/b-gone"), }), - ); + purpose: "remove b".to_owned(), + }); let order = if reverse { [b, a] } else { [a, b] }; for id in order { rt.workers @@ -1936,23 +2188,25 @@ mod tests { #[test] fn a_failed_or_cancelled_resource_job_is_not_harvested() { let rt = AsyncRuntime::with_pool_size(1); - let (failed, _) = rt.allocate_with_resource( - JobKind::FsRename, - None, - None, - Some(ResourceOp::Rename { + let (failed, _) = rt.allocate(JobSpec { + kind: JobKind::FsRename, + supersede: None, + stream: None, + resource: Some(ResourceOp::Rename { from: PathBuf::from("/tmp/nope"), to: PathBuf::from("/tmp/also-nope"), }), - ); - let (cancelled, _) = rt.allocate_with_resource( - JobKind::FsRemove, - None, - None, - Some(ResourceOp::Remove { + purpose: "rename nope".to_owned(), + }); + let (cancelled, _) = rt.allocate(JobSpec { + kind: JobKind::FsRemove, + supersede: None, + stream: None, + resource: Some(ResourceOp::Remove { path: PathBuf::from("/tmp/never"), }), - ); + purpose: "remove never".to_owned(), + }); rt.workers .send( ASYNC_REPLY_TOPIC, diff --git a/src/lsp.rs b/src/lsp.rs index f5630b6..43632f9 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -167,7 +167,11 @@ impl LspServerSpec { } fn to_process_spec(&self) -> ProcessSpec { - let mut p = ProcessSpec::new(format!("lsp:{}", self.label), &self.command); + let mut p = ProcessSpec::new( + format!("lsp:{}", self.label), + &self.command, + format!("language server for {}", self.label), + ); p.args.clone_from(&self.args); p.cwd.clone_from(&self.cwd); p.env.clone_from(&self.env); @@ -1587,9 +1591,15 @@ impl LspManager { uri: &str, ) -> JobId { let supersede = format!("lsp:{method}:{}:{uri}", sid.raw()); - let (job_id, token) = self - .runtime - .register_external(JobKind::LspRequest, Some(&supersede)); + // Worker identity Stage 1: `register_external` bypasses the + // worker pool, so its `JobKind` is the undifferentiated + // `LspRequest` for every method. The method and the document are + // the only thing that makes one row distinguishable from another + // in `*workers*`. + let purpose = format!("lsp {method} {uri}"); + let (job_id, token) = + self.runtime + .register_external(JobKind::LspRequest, Some(&supersede), purpose); self.pending_external.insert( (sid, req_id), PendingExternal { @@ -4538,7 +4548,8 @@ mod resource_reconciliation_tests { let runtime = mgr.runtime.clone(); let mut register = |rid: u64, uri: &str| { - let (job_id, token) = runtime.register_external(JobKind::LspRequest, None); + let (job_id, token) = + runtime.register_external(JobKind::LspRequest, None, format!("lsp hover {uri}")); mgr.pending_routes.insert( (a, rid), ResponseRoute::Hover { diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index b2de320..9535f81 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -7566,6 +7566,73 @@ pub fn install_async( })?, )?; + // Worker identity Stage 1 (Q#W-2): the dispatch-name ambient. + // + // `pmacs.workers.dispatch(name, …)` is the one place a third-party + // job's own name exists, and nothing below it takes a name — the + // Rust dispatchers accept job arguments, a supersede key and stream + // data, and a handler reaching straight for `_dispatch_*` bypasses + // the Lua wrapper layer entirely. So the name travels out of band + // and is read at `allocate`, the single funnel every job passes + // through. + // + // Runtime-internal, underscore-prefixed: package code calls + // `pmacs.workers.dispatch`, which brackets these itself under + // `pcall`. A package pushing by hand and failing to pop would poison + // every later dispatch in the session with a stale name. + { + let rt = runtime.clone(); + async_mod.set( + "_push_dispatch_name", + lua.create_function(move |_, name: String| { + rt.push_dispatch_name(name); + Ok(()) + })?, + )?; + } + + { + let rt = runtime.clone(); + async_mod.set( + "_pop_dispatch_name", + lua.create_function(move |_, ()| { + rt.pop_dispatch_name(); + Ok(()) + })?, + )?; + } + + // The refusal predicate, the sibling of `_in_commit_scope` above and + // enforced for the same reason: a coroutine that parks inside the + // extent leaves the name pushed, and every job allocated in the + // meantime — in any coroutine, on any later tick — inherits it. + { + let rt = runtime.clone(); + async_mod.set( + "_in_dispatch_name_scope", + lua.create_function(move |_, ()| Ok(rt.in_dispatch_name_scope()))?, + )?; + } + + // The statusline activity indicator's read surface (Q#W-3). Returns + // `nil` when nothing is in flight — the indicator renders no segment + // at all when idle, so "absent" has to be representable. + { + let rt = runtime.clone(); + async_mod.set( + "_activity_summary", + lua.create_function(move |lua, ()| { + let Some(summary) = rt.activity_summary() else { + return Ok(mlua::Value::Nil); + }; + let t = lua.create_table_with_capacity(0, 2)?; + t.set("in_flight", summary.in_flight)?; + t.set("purpose", summary.oldest_purpose)?; + Ok(mlua::Value::Table(t)) + })?, + )?; + } + { let rt = runtime.clone(); async_mod.set( @@ -7728,7 +7795,7 @@ fn workers_snapshot_to_lua(lua: &Lua, runtime: &SharedAsyncRuntime) -> mlua::Res let out = lua.create_table()?; let active = lua.create_table_with_capacity(snap.active.len(), 0)?; for (i, job) in snap.active.iter().enumerate() { - let row = lua.create_table_with_capacity(0, 6)?; + let row = lua.create_table_with_capacity(0, 7)?; row.set("id", job.id)?; row.set("kind", job.kind.label())?; row.set("age_ms", job.age_ms)?; @@ -7737,12 +7804,13 @@ fn workers_snapshot_to_lua(lua: &Lua, runtime: &SharedAsyncRuntime) -> mlua::Res } row.set("cancel_requested", job.cancel_requested)?; row.set("is_stream", job.is_stream)?; + row.set("purpose", job.purpose.as_str())?; active.set(i + 1, row)?; } out.set("active", active)?; let completed = lua.create_table_with_capacity(snap.completed.len(), 0)?; for (i, job) in snap.completed.iter().enumerate() { - let row = lua.create_table_with_capacity(0, 7)?; + let row = lua.create_table_with_capacity(0, 8)?; row.set("id", job.id)?; row.set("kind", job.kind.label())?; row.set("duration_ms", job.duration_ms)?; @@ -7750,6 +7818,7 @@ fn workers_snapshot_to_lua(lua: &Lua, runtime: &SharedAsyncRuntime) -> mlua::Res if let Some(key) = &job.supersede_key { row.set("supersede", key.as_str())?; } + row.set("purpose", job.purpose.as_str())?; let (status, value): (&'static str, mlua::Value) = match &job.outcome { JobOutcome::Complete(JobResult::Unit) => ("ok", mlua::Value::Nil), JobOutcome::Complete(JobResult::Sum(v)) => ( @@ -8680,6 +8749,21 @@ fn parse_restart(name: &str) -> mlua::Result { fn lua_to_spec(table: &Table) -> mlua::Result { let label: String = table.get("label").unwrap_or_else(|_| "unnamed".to_owned()); let command: String = table.get("command")?; + // Worker identity Stage 1: required on the Rust struct, optional at + // this surface, falling back to the label. + // + // Requiring it here would break every existing `pmacs.process.spawn` + // caller, and the compiler obligation this lane is buying is on the + // *Rust* construction sites — the ones a future field would silently + // skip. A Lua caller that supplies nothing gets its own label back, + // which is what the caller already chose to call this work; it is + // less informative than a real description but it is not a + // fabrication, which is the bar `owner` failed (framing §3). + let purpose: String = table + .get::>("purpose") + .ok() + .flatten() + .unwrap_or_else(|| label.clone()); let args: Vec = table.get("args").unwrap_or_default(); let cwd: Option = table.get("cwd").ok().flatten(); let env_table: Option = table.get("env").ok().flatten(); @@ -8762,6 +8846,7 @@ fn lua_to_spec(table: &Table) -> mlua::Result { }; Ok(ProcessSpec { label, + purpose, command, args, cwd: cwd.map(std::path::PathBuf::from), @@ -8985,11 +9070,18 @@ pub fn install_process(lua: &Lua, supervisor: &SharedProcessSupervisor) -> mlua: .collect(); let out = lua.create_table_with_capacity(ids.len(), 0)?; for (i, id) in ids.iter().enumerate() { - let row = lua.create_table_with_capacity(0, 3)?; + let row = lua.create_table_with_capacity(0, 4)?; row.set("id", ProcessIdLua(*id))?; if let Some(spec) = sup.spec(*id) { row.set("label", spec.label.as_str())?; row.set("command", spec.command.as_str())?; + // Worker identity Stage 1: a new KEY on each + // existing row. The row COUNT is deliberately + // untouched — three acceptance suites assert on + // `#pmacs.process.list()` as a leak detector + // (framing Q#W-4), and widening what this + // enumerates would inflate all three baselines. + row.set("purpose", spec.purpose.as_str())?; } if let Some(state) = sup.state(*id) { row.set("state", state_to_lua(lua, state)?)?; diff --git a/src/mcp.rs b/src/mcp.rs index b1db5f4..f1085ca 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -175,7 +175,11 @@ impl McpServerSpec { } fn to_process_spec(&self) -> ProcessSpec { - let mut p = ProcessSpec::new(format!("mcp:{}", self.label), &self.command); + let mut p = ProcessSpec::new( + format!("mcp:{}", self.label), + &self.command, + format!("MCP server {}", self.label), + ); p.args.clone_from(&self.args); p.cwd.clone_from(&self.cwd); p.env.clone_from(&self.env); @@ -873,7 +877,9 @@ impl McpManager { } let req_id = next_request_id(client); let body = make_request(req_id, &method, params); - let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None); + let (job_id, token) = + self.runtime + .register_external(JobKind::McpRequest, None, format!("mcp {method}")); client.pending_external.insert( req_id, PendingExternal { @@ -948,7 +954,11 @@ impl McpManager { // (1) Cache hit. if let Some(ResourceCacheState::Cached { result }) = self.resource_cache.get(&key).cloned() { - let (job_id, _token) = self.runtime.register_external(JobKind::McpRequest, None); + let (job_id, _token) = self.runtime.register_external( + JobKind::McpRequest, + None, + format!("mcp resources/read {uri} (cached)"), + ); self.runtime.complete_external_ok(job_id, result); return Ok(job_id); } @@ -959,7 +969,11 @@ impl McpManager { // independently. if let Some(ResourceCacheState::InFlight { request_id }) = self.resource_cache.get(&key) { let in_flight_rid = *request_id; - let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None); + let (job_id, token) = self.runtime.register_external( + JobKind::McpRequest, + None, + format!("mcp resources/read {uri}"), + ); if let Some(p) = client.pending_external.get_mut(&in_flight_rid) { p.awaiters.push(Awaiter { job_id, token }); return Ok(job_id); @@ -974,7 +988,11 @@ impl McpManager { // (3) Cache miss: dispatch. let req_id = next_request_id(client); let body = make_request(req_id, "resources/read", json!({ "uri": uri })); - let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None); + let (job_id, token) = self.runtime.register_external( + JobKind::McpRequest, + None, + format!("mcp resources/read {uri}"), + ); client.pending_external.insert( req_id, PendingExternal { @@ -1063,10 +1081,13 @@ impl McpManager { // than referenced by `json!`); avoids a needless-pass-by- // value clippy complaint and matches `send_request`'s shape. let mut params_map = Map::new(); + let purpose = format!("mcp tools/call {name}"); params_map.insert("name".into(), Value::String(name)); params_map.insert("arguments".into(), arguments); let body = make_request(req_id, "tools/call", Value::Object(params_map)); - let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None); + let (job_id, token) = self + .runtime + .register_external(JobKind::McpRequest, None, purpose); client.pending_external.insert( req_id, PendingExternal { @@ -1125,10 +1146,13 @@ impl McpManager { } let req_id = next_request_id(client); let mut params_map = Map::new(); + let purpose = format!("mcp prompts/get {name}"); params_map.insert("name".into(), Value::String(name)); params_map.insert("arguments".into(), arguments); let body = make_request(req_id, "prompts/get", Value::Object(params_map)); - let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None); + let (job_id, token) = self + .runtime + .register_external(JobKind::McpRequest, None, purpose); client.pending_external.insert( req_id, PendingExternal { diff --git a/src/process.rs b/src/process.rs index c4a9277..17a5365 100644 --- a/src/process.rs +++ b/src/process.rs @@ -196,6 +196,23 @@ pub struct ProcessSpec { /// so multiple processes can run the same binary with /// distinguishable labels. pub label: String, + /// What this process is doing, in words a user can read (worker + /// identity Stage 1, `COHERENCE.md` §9). + /// + /// **Required, and not the same thing as [`Self::label`].** The + /// label is an *identity* — `lsp:rust-analyzer`, a terminal's buffer + /// name — spelled however the caller likes, so that two processes + /// running the same binary can be told apart. The purpose is a + /// *description*: it answers "what is happening", which is the + /// question §3's promise of visible asynchronous work is about and + /// which a label chosen for uniqueness routinely does not answer. + /// + /// **Not an owner**, in any spelling. It records what the process is + /// doing, not which package asked for it; `pmacs.process.spawn` is + /// callable by any package, so a value derived here would + /// misattribute third-party work to a builtin at exactly the point + /// §9 wants attribution (framing §3). + pub purpose: String, /// Program to execute. Looked up via the system PATH unless an /// absolute path is supplied. pub command: String, @@ -237,10 +254,21 @@ pub struct ProcessSpec { impl ProcessSpec { /// Construct a spec with the bare-minimum fields. Convenience /// for tests and one-off scripts. + /// + /// `purpose` is a parameter rather than something derived from the + /// label because it is a required field with no honest default + /// (worker identity Stage 1): deriving it from the label would make + /// every process claim its identity *is* its description, which is + /// exactly the conflation the field exists to undo. #[must_use] - pub fn new(label: impl Into, command: impl Into) -> Self { + pub fn new( + label: impl Into, + command: impl Into, + purpose: impl Into, + ) -> Self { Self { label: label.into(), + purpose: purpose.into(), command: command.into(), args: Vec::new(), cwd: None, @@ -2722,6 +2750,7 @@ mod tests { let spec = ProcessSpec::new( "unpublished-terminal", "/definitely/not/a/real/pmacs-terminal-program", + "test process", ); assert!(supervisor.spawn_terminal(spec).is_err()); supervisor.tick(); @@ -2732,7 +2761,7 @@ mod tests { #[test] fn spawn_pipes_lifecycle_started_then_exited() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("echo-test", "/bin/sh"); + let mut spec = ProcessSpec::new("echo-test", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "echo hello && exit 0".into()]; let id = sup.spawn(spec).expect("spawn"); let events = drain_until(&mut sup, id, Duration::from_secs(5), has_exited); @@ -2892,7 +2921,7 @@ mod tests { /// A plain PTY child, for tests that care about the PTY *branch* /// rather than about job control. fn spawn_live_pty(sup: &mut ProcessSupervisor, name: &str) -> (ProcessId, u32) { - let mut spec = ProcessSpec::new(name, "/bin/sleep"); + let mut spec = ProcessSpec::new(name, "/bin/sleep", "test process"); spec.args = vec!["30".into()]; spec.mode = ProcessMode::Pty { rows: 24, @@ -2943,7 +2972,7 @@ mod tests { sup: &mut ProcessSupervisor, name: &str, ) -> (ProcessId, u32, i32) { - let mut spec = ProcessSpec::new(name, BASH); + let mut spec = ProcessSpec::new(name, BASH, "test process"); spec.args = vec![ "--noprofile".into(), "--norc".into(), @@ -3194,7 +3223,7 @@ mod tests { #[test] fn a_pipe_child_still_renders_a_bare_leader_target() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("diag-pipe-leader", "/bin/sleep"); + let mut spec = ProcessSpec::new("diag-pipe-leader", "/bin/sleep", "test process"); spec.args = vec!["30".into()]; let id = sup.spawn(spec).expect("spawn"); let pid = spawn_started_pid(&mut sup, id); @@ -3227,7 +3256,7 @@ mod tests { let mut reports = Vec::new(); for signal in [Signal::SIGTERM, Signal::SIGUSR1] { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("diag-signal-name", "/bin/sh"); + let mut spec = ProcessSpec::new("diag-signal-name", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "sleep 30".into()]; spec.group = true; let id = sup.spawn(spec).expect("spawn"); @@ -3280,7 +3309,7 @@ mod tests { let mut sup = ProcessSupervisor::new(); let temp = tempfile::TempDir::new().expect("tempdir"); let ready = temp.path().join("usr1-trapped"); - let mut spec = ProcessSpec::new("diag-disposition-live", "/bin/sh"); + let mut spec = ProcessSpec::new("diag-disposition-live", "/bin/sh", "test process"); // Ignore USR1 so the successful non-fatal signal cannot end the // child and confuse the state assertion with a real exit — and // then WAIT for the child to say it has done so. `Started` is @@ -3344,7 +3373,7 @@ mod tests { let mut sup = ProcessSupervisor::new(); let temp = tempfile::TempDir::new().expect("tempdir"); let ready = temp.path().join("usr1-trapped"); - let mut spec = ProcessSpec::new("diag-trap-readiness", "/bin/sh"); + let mut spec = ProcessSpec::new("diag-trap-readiness", "/bin/sh", "test process"); spec.args = vec!["-c".into(), trapped_usr1_command(&ready, "sleep 1; ")]; spec.group = true; let id = sup.spawn(spec).expect("spawn"); @@ -3435,7 +3464,7 @@ mod tests { #[test] fn a_leader_directed_kill_failure_reports_the_fallback_branch() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("diag-leader", "/bin/sleep"); + let mut spec = ProcessSpec::new("diag-leader", "/bin/sleep", "test process"); spec.args = vec!["30".into()]; let id = sup.spawn(spec).expect("spawn"); let pid = spawn_started_pid(&mut sup, id); @@ -3484,7 +3513,7 @@ mod tests { #[test] fn a_failure_after_the_child_exits_reports_the_leader_as_exited() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("diag-exited", "/bin/sh"); + let mut spec = ProcessSpec::new("diag-exited", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "exit 3".into()]; let id = sup.spawn(spec).expect("spawn"); // NOT `spawn_started_pid`: draining ticks, and this child exits @@ -3512,7 +3541,7 @@ mod tests { #[test] fn an_injected_failure_changes_no_state_and_arms_no_ledger() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("diag-disposition", "/bin/sh"); + let mut spec = ProcessSpec::new("diag-disposition", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "sleep 30".into()]; spec.group = true; let id = sup.spawn(spec).expect("spawn"); @@ -3557,7 +3586,7 @@ mod tests { #[test] fn observing_the_leader_does_not_consume_the_exit_event() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("diag-one-event", "/bin/sh"); + let mut spec = ProcessSpec::new("diag-one-event", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "exit 7".into()]; spec.mode = ProcessMode::Pty { rows: 24, @@ -3599,7 +3628,7 @@ mod tests { let mut sup = ProcessSupervisor::new(); // `sleep 30` is long enough that the test definitely needs // to terminate it deliberately. - let mut spec = ProcessSpec::new("sleeper", "/bin/sh"); + let mut spec = ProcessSpec::new("sleeper", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "sleep 30".into()]; let id = sup.spawn(spec).expect("spawn"); // Wait for Started so we have a pid. @@ -3628,7 +3657,7 @@ mod tests { // implementation blocked the caller in `write_all` here — // which in the editor was the main thread, wedging the frame // loop whenever an LSP server fell behind on its stdin. - let mut spec = ProcessSpec::new("stdin-ignorer", "/bin/sh"); + let mut spec = ProcessSpec::new("stdin-ignorer", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "sleep 30".into()]; let id = sup.spawn(spec).expect("spawn"); let _ = drain_until(&mut sup, id, Duration::from_secs(2), |evs| { @@ -3654,7 +3683,7 @@ mod tests { // payload back followed by a clean exit proves the writer // thread drains its queue before dropping the pipe (the // flush-then-EOF contract `close_stdin` documents). - let mut spec = ProcessSpec::new("cat-echo", "/bin/sh"); + let mut spec = ProcessSpec::new("cat-echo", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "cat".into()]; let id = sup.spawn(spec).expect("spawn"); let _ = drain_until(&mut sup, id, Duration::from_secs(2), |evs| { @@ -3700,7 +3729,7 @@ mod tests { fn restart_on_crash_respawns_after_nonzero_exit() { let mut sup = ProcessSupervisor::new(); sup.set_restart_backoff(Duration::from_millis(10)); - let mut spec = ProcessSpec::new("crasher", "/bin/sh"); + let mut spec = ProcessSpec::new("crasher", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "exit 7".into()]; spec.restart = RestartPolicy::OnCrash; let id = sup.spawn(spec).expect("spawn"); @@ -3731,7 +3760,7 @@ mod tests { #[test] fn restart_never_does_not_respawn_after_clean_exit() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("oneshot", "/bin/sh"); + let mut spec = ProcessSpec::new("oneshot", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "exit 0".into()]; let id = sup.spawn(spec).expect("spawn"); let _ = drain_until(&mut sup, id, Duration::from_secs(2), has_exited); @@ -3760,7 +3789,7 @@ mod tests { let pid = { let mut sup = ProcessSupervisor::new(); sup.set_grace_period(Duration::from_millis(200)); - let mut spec = ProcessSpec::new("victim", "/bin/sh"); + let mut spec = ProcessSpec::new("victim", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "sleep 30".into()]; let id = sup.spawn(spec).expect("spawn"); // Drain until Started so we know the pid. @@ -3798,7 +3827,7 @@ mod tests { #[test] fn pty_mode_child_sees_a_tty() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("ttytest", "/bin/sh"); + let mut spec = ProcessSpec::new("ttytest", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "tty".into()]; spec.mode = ProcessMode::default_pty(); let id = sup.spawn(spec).expect("spawn"); @@ -3835,7 +3864,7 @@ mod tests { #[test] fn m6_1_pty_resize_delivers_sigwinch_to_child() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("winch-watch", "/bin/sh"); + let mut spec = ProcessSpec::new("winch-watch", "/bin/sh", "test process"); // Trap WINCH, print READY for synchronization, then loop on // a short sleep so SIGWINCH can interrupt and fire the trap. spec.args = vec![ @@ -3880,7 +3909,7 @@ mod tests { #[test] fn m6_1_pty_mode_lifecycle_started_then_exited() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("pty-exit", "/bin/sh"); + let mut spec = ProcessSpec::new("pty-exit", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "echo done && exit 0".into()]; spec.mode = ProcessMode::default_pty(); let id = sup.spawn(spec).expect("spawn"); @@ -3915,7 +3944,7 @@ mod tests { #[test] fn m6_1_pty_raw_mode_disables_kernel_echo() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("raw-stty", "/bin/sh"); + let mut spec = ProcessSpec::new("raw-stty", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "stty -a".into()]; spec.mode = ProcessMode::default_pty(); // Raw by default. let id = sup.spawn(spec).expect("spawn"); @@ -3937,7 +3966,7 @@ mod tests { #[test] fn m6_1_pty_canonical_mode_keeps_kernel_echo() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("canon-stty", "/bin/sh"); + let mut spec = ProcessSpec::new("canon-stty", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "stty -a".into()]; spec.mode = ProcessMode::Pty { rows: 24, @@ -3991,7 +4020,7 @@ mod tests { // buffers. const TOTAL: usize = 10 * 1024 * 1024; let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("byte-flood", "/bin/sh"); + let mut spec = ProcessSpec::new("byte-flood", "/bin/sh", "test process"); spec.args = vec!["-c".into(), format!("head -c {TOTAL} /dev/zero")]; let id = sup.spawn(spec).expect("spawn"); @@ -4067,7 +4096,7 @@ mod tests { #[test] fn m6_2_pty_streaming_coalesces_per_tick() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("chunky-stream", "/bin/sh"); + let mut spec = ProcessSpec::new("chunky-stream", "/bin/sh", "test process"); // 1 MiB of zeros from /dev/zero. The reader thread reads in // [`BYTE_CHUNK_SIZE`] (8 KiB) chunks --- ~128 reads --- all // queued onto the bounded channel within microseconds of @@ -4116,7 +4145,7 @@ mod tests { #[test] fn m6_2_ansi_enabled_pty_emits_structured_events() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("ansi-stream", "/bin/sh"); + let mut spec = ProcessSpec::new("ansi-stream", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "printf '\\033[31mhi\\033[0m\\n'".into()]; spec.mode = ProcessMode::Pty { rows: 24, @@ -4198,7 +4227,7 @@ mod tests { let handle = std::thread::spawn(move || { let mut sup = ProcessSupervisor::new(); sup.set_grace_period(Duration::from_millis(300)); - let mut spec = ProcessSpec::new("forever-flood", "/bin/sh"); + let mut spec = ProcessSpec::new("forever-flood", "/bin/sh", "test process"); // Continuous writer; SIGTERM kills it (no signal handler). spec.args = vec!["-c".into(), "while :; do printf 'X'; done".into()]; let id = sup.spawn(spec).expect("spawn"); @@ -4324,7 +4353,7 @@ mod tests { let handle = std::thread::spawn(move || { let mut sup = ProcessSupervisor::new(); sup.set_grace_period(Duration::from_millis(300)); - let mut spec = ProcessSpec::new("orphan-holds-pipe", "setsid"); + let mut spec = ProcessSpec::new("orphan-holds-pipe", "setsid", "test process"); // `setsid --fork` forks and the parent exits, so the // *recorded* pid terminates promptly (letting `poll_one` // reach the teardown path) while `cat` survives holding the @@ -4412,7 +4441,7 @@ mod tests { // ----------------------------------------------------------------- fn sh_group_spec(label: &str, script: &str) -> ProcessSpec { - let mut spec = ProcessSpec::new(label, "/bin/sh"); + let mut spec = ProcessSpec::new(label, "/bin/sh", "test process"); spec.args = vec!["-c".into(), script.to_owned()]; spec.stdin = StdinMode::Null; spec.group = true; @@ -4546,7 +4575,7 @@ mod tests { ); // Control: a non-group child inherits the test process's // group instead of leading its own. - let mut plain = ProcessSpec::new("plain", "/bin/sh"); + let mut plain = ProcessSpec::new("plain", "/bin/sh", "test process"); plain.args = vec!["-c".into(), "sleep 30".into()]; let plain_id = sup.spawn(plain).expect("spawn plain"); let plain_events = drain_until(&mut sup, plain_id, Duration::from_secs(2), |evs| { @@ -5017,7 +5046,7 @@ mod tests { fn maybe_restart_inert_once_shut_down() { let mut sup = ProcessSupervisor::new(); sup.set_restart_backoff(Duration::from_millis(30)); - let mut spec = ProcessSpec::new("restarter", "/bin/sh"); + let mut spec = ProcessSpec::new("restarter", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "echo x".into()]; spec.restart = RestartPolicy::Always; let id = sup.spawn(spec).expect("spawn"); @@ -5158,7 +5187,7 @@ mod tests { #[test] fn group_and_null_stdin_rejected_under_pty() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("pty-null", "/bin/sh"); + let mut spec = ProcessSpec::new("pty-null", "/bin/sh", "test process"); spec.mode = ProcessMode::default_pty(); spec.stdin = StdinMode::Null; let err = sup @@ -5169,7 +5198,7 @@ mod tests { "error points at pipe mode: {err}" ); - let mut spec = ProcessSpec::new("pty-group", "/bin/sh"); + let mut spec = ProcessSpec::new("pty-group", "/bin/sh", "test process"); spec.mode = ProcessMode::default_pty(); spec.group = true; let err = sup diff --git a/src/terminal/session.rs b/src/terminal/session.rs index c731fb0..47fae56 100644 --- a/src/terminal/session.rs +++ b/src/terminal/session.rs @@ -305,7 +305,8 @@ impl TerminalManager { buffer.set_read_only(true); core.registry.borrow_mut().insert(buffer); - let mut process_spec = ProcessSpec::new(buffer_name, spec.command); + let purpose = format!("terminal running {}", spec.command); + let mut process_spec = ProcessSpec::new(buffer_name, spec.command, purpose); process_spec.args = spec.args; process_spec.cwd = spec.cwd; process_spec.env = spec.env; diff --git a/src/workers_buffer.rs b/src/workers_buffer.rs index 6a6eeb4..02d6d03 100644 --- a/src/workers_buffer.rs +++ b/src/workers_buffer.rs @@ -14,19 +14,34 @@ //! ```text //! Workers (active: 2, completed: 5) //! -//! ID Kind Age Supersede Status -//! ------ ----------- -------- ---------- ---------- -//! #5 grep 412ms search running -//! #6 sleep 18ms running (cancel pending) +//! ID Kind Age Supersede Purpose Status +//! ------ ----------- -------- ---------- ------------------------ ---------- +//! #5 grep 412ms search search: grep "fn" in /x running +//! #6 sleep 18ms sleep 18ms running (cancel pending) //! //! Recent (newest first) //! -//! ID Kind Duration Supersede Outcome -//! ------ ----------- -------- ---------- ---------- -//! #4 grep 1242ms search cancelled (3s ago) -//! #3 compute_sum 2ms ok (3s ago) +//! ID Kind Duration Supersede Purpose Outcome +//! ------ ----------- -------- ---------- ------------------------ ---------- +//! #4 grep 1242ms search search: grep "fn" in /x cancelled (3s ago) +//! #3 compute_sum 2ms sum 1..100 ok (3s ago) //! ``` //! +//! # Purpose (worker identity Stage 1, `COHERENCE.md` §9) +//! +//! The `Purpose` column is what turns "twelve rows named `lsp_request`" +//! into a readable account of what the editor is doing. `Kind` names the +//! builtin dispatcher a job funnelled through, which for every +//! third-party job is a builtin's label rather than the caller's; the +//! purpose carries the work's own description and, under +//! `pmacs.workers.dispatch`, the registered handler name it ran under. +//! +//! It is placed **before** `Status` and padded, because `Status` is +//! variable-width (`running (cancel pending) [stream]`) and two +//! ragged trailing columns render as noise. An over-long purpose pushes +//! `Status` right rather than being truncated: losing the end of a path +//! is a worse failure than an uneven column. +//! //! Lua reads the snapshot via `pmacs.workers.snapshot()`; the //! `pmacs.workers.show()` builtin invokes [`render`] on it and //! returns the buffer id. Auto-refresh hooks into @@ -43,6 +58,11 @@ use crate::buffer_registry::BufferRegistry; /// Canonical name for the workers observability buffer. pub const WORKERS_BUFFER_NAME: &str = "*workers*"; +/// Minimum column width the `Purpose` column is padded to. Purposes +/// longer than this push the trailing column right rather than being +/// truncated (see the module docs). +const PURPOSE_WIDTH: usize = 24; + /// Render `snapshot` into the `*workers*` buffer (creating it if /// absent), replacing its full contents. Returns the buffer id /// and the Edits produced by the replacement (zero, one, or two — @@ -119,13 +139,17 @@ fn format_snapshot(snapshot: &WorkersSnapshot) -> String { let _ = writeln!(text); let _ = writeln!( text, - "{:<7} {:<11} {:>9} {:<11} Status", - "ID", "Kind", "Age", "Supersede" + "{:<7} {:<11} {:>9} {:<11} {:9} {:<11} ----------", - "------", "-----------", "---------", "-----------" + "{:<7} {:<11} {:>9} {:<11} {: String { let _ = writeln!(text); let _ = writeln!( text, - "{:<7} {:<11} {:>9} {:<11} Outcome", - "ID", "Kind", "Duration", "Supersede" + "{:<7} {:<11} {:>9} {:<11} {:9} {:<11} ----------", - "------", "-----------", "---------", "-----------" + "{:<7} {:<11} {:>9} {:<11} {:9} {key:<11} {status}"); + let purpose = &job.purpose; + let _ = writeln!( + text, + "{id:<7} {kind:<11} {age:>9} {key:<11} {purpose:9} {key:<11} {outcome} ({age} ago)" + "{id:<7} {kind:<11} {duration:>9} {key:<11} {purpose: bool { #[test] fn m4_4_lifecycle_spawn_and_exit() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("hello", "/bin/sh"); + let mut spec = ProcessSpec::new("hello", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "printf hi && exit 0".into()]; let id = sup.spawn(spec).expect("spawn"); let evs = drain_until(&mut sup, id, Duration::from_secs(5), has_exit_event); @@ -983,7 +983,7 @@ fn m4_4_lifecycle_spawn_and_exit() { #[test] fn m4_4_lifecycle_signal_terminates() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("victim", "/bin/sh"); + let mut spec = ProcessSpec::new("victim", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "sleep 30".into()]; let id = sup.spawn(spec).expect("spawn"); let _ = drain_until(&mut sup, id, Duration::from_secs(2), |evs| { @@ -1020,7 +1020,11 @@ fn m4_4_lifecycle_signal_terminates() { fn m4_4_lifecycle_crash_surfaces_as_event() { let mut sup = ProcessSupervisor::new(); // Path that will reliably not resolve. - let spec = ProcessSpec::new("ghost", "/this/binary/does/not/exist/pmacs-m4-4"); + let spec = ProcessSpec::new( + "ghost", + "/this/binary/does/not/exist/pmacs-m4-4", + "test process", + ); let _ = sup.spawn(spec); // spawn returns Err but the event is still emitted sup.tick(); let evs = sup.take_all_events(); @@ -1037,7 +1041,7 @@ fn m4_4_lifecycle_crash_surfaces_as_event() { fn m4_4_restart_policy_on_crash_respawns() { let mut sup = ProcessSupervisor::new(); sup.set_restart_backoff(Duration::from_millis(10)); - let mut spec = ProcessSpec::new("flap", "/bin/sh"); + let mut spec = ProcessSpec::new("flap", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "exit 9".into()]; spec.restart = RestartPolicy::OnCrash; let id = sup.spawn(spec).expect("spawn"); @@ -1070,7 +1074,7 @@ fn m4_4_restart_policy_on_crash_respawns() { #[test] fn m4_4_restart_policy_never_does_not_respawn() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("oneshot", "/bin/sh"); + let mut spec = ProcessSpec::new("oneshot", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "exit 0".into()]; let id = sup.spawn(spec).expect("spawn"); let _ = drain_until(&mut sup, id, Duration::from_secs(2), has_exit_event); @@ -1099,7 +1103,7 @@ fn m4_4_no_zombies_after_editor_drop() { let pid: u32 = { let mut sup = ProcessSupervisor::new(); sup.set_grace_period(Duration::from_millis(200)); - let mut spec = ProcessSpec::new("zombie-test", "/bin/sh"); + let mut spec = ProcessSpec::new("zombie-test", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "sleep 60".into()]; let id = sup.spawn(spec).expect("spawn"); let _ = drain_until(&mut sup, id, Duration::from_secs(2), |evs| { @@ -1136,7 +1140,7 @@ fn m4_4_no_zombies_after_editor_drop() { #[test] fn m4_4_pty_mode_child_observes_a_tty() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("ttytest", "/bin/sh"); + let mut spec = ProcessSpec::new("ttytest", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "tty".into()]; spec.mode = ProcessMode::default_pty(); let id = sup.spawn(spec).expect("spawn"); diff --git a/tests/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index a4dc5f1..c577af4 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -136,7 +136,12 @@ fn a01_04_registry_contract_limits_epochs_and_results() { .iter() .map(|provider| provider.name.as_str()) .collect::>(), - ["mode", "terminal", "lsp"], + // `activity` is worker identity Stage 1's fourth adopter, and it + // sorts first because `async.lua` is loaded before `syntax.lua`, + // `terminal.lua` and `lsp.lua`. This is an INVENTORY assertion: + // it grows when a builtin provider is added, which is exactly + // what it is for. + ["activity", "mode", "terminal", "lsp"], "built-in providers are discoverable in registration order" ); let before_epochs = { diff --git a/tests/vterm_stage1_acceptance.rs b/tests/vterm_stage1_acceptance.rs index 489ca9a..0c7a312 100644 --- a/tests/vterm_stage1_acceptance.rs +++ b/tests/vterm_stage1_acceptance.rs @@ -398,7 +398,7 @@ fn editor_shutdown_kills_term_ignoring_terminal_child() { #[test] fn terminal_tick_does_not_take_non_terminal_process_events() { let mut state = EditorState::new_with_roots(&crate::iso::roots()); - let mut process = pmacs::process::ProcessSpec::new("ordinary", "/bin/sh"); + let mut process = pmacs::process::ProcessSpec::new("ordinary", "/bin/sh", "test process"); process.args = vec!["-c".into(), "printf ordinary".into()]; let ordinary_id = state .process_supervisor diff --git a/tests/worker_identity_acceptance.rs b/tests/worker_identity_acceptance.rs new file mode 100644 index 0000000..d7bdbfb --- /dev/null +++ b/tests/worker_identity_acceptance.rs @@ -0,0 +1,849 @@ +// tests/worker_identity_acceptance.rs --- worker identity Stage 1. + +//! Worker identity Stage 1 (`docs/worker-identity-framing.md` §6, +//! `COHERENCE.md` §9). +//! +//! §9 grades the worker model **mechanism without identity**: a job +//! carries a `JobKind` naming the builtin dispatcher it funnelled +//! through, so every third-party job renders under a builtin's label, +//! and no progress indicator exists anywhere. This suite pins what +//! Stage 1 does about that — a required `purpose` on the job and the +//! process, the dispatch-name ambient that stops +//! `pmacs.workers.dispatch` discarding its handler name, and the first +//! indicator a user sees without running a command. +//! +//! # What is NOT here, deliberately +//! +//! **Presence is enforced by the COMPILER, not by anything below.** +//! `JobSpec::purpose` is non-optional and `JobSpec` has no `Default`, so +//! a dispatcher that supplies none does not build. A funnel test would +//! prove only that the funnel stores what it was handed, and would say +//! nothing about whether fourteen callers handed it anything meaningful. +//! Everything below is about *semantics*. +//! +//! **That a raw `coroutine.yield` inside either dynamic scope is +//! prevented — it is not.** R46 forbids package code from yielding +//! raw, but it is a convention, and the scheduler diagnoses a non-Handle +//! yield only *after* the coroutine has suspended (`async.lua` resumes, +//! then inspects what came back), so no refusal sited in a yield helper +//! is ever consulted. Rule 1 claims **the two supported yield APIs** and +//! nothing more. A test that "proved" coverage this design does not have +//! would be worse than the recorded gap, so the gap is recorded instead +//! (framing §2, §6, §7). +//! +//! **That background work is attributable from one place** (Stage 2's +//! unified view), **that a terminal PTY is visible anywhere** (Q#W-4), +//! or **that any job is attributed to the PACKAGE responsible for it**. +//! `purpose` records what work is being done and, under +//! `pmacs.workers.dispatch`, which registered handler it ran under. +//! Neither is package ownership, which waits for P3 — and there is no +//! `owner` field, in any spelling, for it to squat on (framing §3, §7). + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use pmacs::async_runtime::JobKind; +use pmacs::cell::{Cell, CellGrid, CellSize, Glyph}; +use pmacs::editor::EditorState; +use pmacs::protocol::FrontendId; +use pmacs::statusline::{ + StatuslineEvaluationOutcome, StatuslineEvaluationTarget, StatuslineProviderId, + evaluate_statusline, +}; + +#[path = "common/iso.rs"] +mod iso; + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +fn exec(state: &EditorState, source: &str) { + state.lua_host.lua().load(source.to_owned()).exec().unwrap(); +} + +fn eval(state: &EditorState, source: &str) -> T { + state.lua_host.lua().load(source.to_owned()).eval().unwrap() +} + +fn editor() -> EditorState { + let state = EditorState::new_with_roots(&iso::roots()); + exec(&state, "pmacs.lsp.config = {}"); + state +} + +/// Drive the async runtime until nothing is in flight and no coroutine +/// is parked. How many frames that takes is not knowable in advance, so +/// this never counts them. +/// +/// Quiescence is measured as **no `Running` job**, not as an empty +/// pending table. Most jobs here are dispatched and never awaited — +/// that is the shape the indicator exists to describe — and a settled +/// entry stays in the pending table until someone takes its result, so +/// `pending_count() == 0` would never come true. +fn pump(state: &mut EditorState) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let idle: bool = eval( + state, + "return pmacs._async.parked_count() == 0 + and #pmacs.workers.snapshot().active == 0", + ); + if idle { + return; + } + assert!(Instant::now() < deadline, "async pump deadline exceeded"); + state.tick_async(); + } +} + +/// The purposes of every job the runtime currently has in flight. +/// +/// Read through the **Lua** snapshot surface, which is what `*workers*` +/// and any package consume, rather than through the Rust struct. +fn active_purposes(state: &EditorState) -> Vec { + eval( + state, + "local out = {} + for _, job in ipairs(pmacs.workers.snapshot().active) do + out[#out + 1] = job.purpose + end + return out", + ) +} + +fn paint(state: &EditorState, rows: u32, cols: u32) -> Vec { + let mut cells = vec![Cell::default(); (rows * cols) as usize]; + let mut grid = CellGrid { + cells: &mut cells, + stride: cols, + size: CellSize::new(rows, cols), + }; + let _ = pmacs::editor::paint_frame( + state, + FrontendId::LOCAL, + &HashMap::new(), + &mut grid, + CellSize::new(rows, cols), + ); + cells +} + +fn row_text(cells: &[Cell], cols: u32, row: u32) -> String { + (0..cols) + .map( + |column| match &cells[(row * cols + column) as usize].glyph { + Glyph::Char(ch) => *ch, + Glyph::Cluster(bytes) => std::str::from_utf8(bytes) + .ok() + .and_then(|text| text.chars().next()) + .unwrap_or(' '), + Glyph::Continuation => ' ', + }, + ) + .collect() +} + +/// The registration handle of the builtin activity provider. +fn activity_provider(state: &EditorState) -> StatuslineProviderId { + state + .statusline_registry + .borrow() + .providers() + .into_iter() + .find(|provider| provider.name == "activity") + .expect("builtin activity provider") + .id +} + +/// The activity provider's segment for `LOCAL`'s only window, or `None` +/// when it produced **no segment at all**. +/// +/// `Option`, never `String`, is the whole point of this helper: +/// "absent" and "empty" must be distinguishable, because a zero-width +/// segment still consumes a separator in the composed modeline. +fn activity_segment(state: &EditorState) -> Option { + let id = activity_provider(state); + let evaluation = evaluate_statusline( + state.lua_host.lua(), + &state.core, + &state.statusline_registry, + StatuslineEvaluationTarget::Grid { + frontend_id: FrontendId::LOCAL, + }, + ); + let StatuslineEvaluationOutcome::Ready(windows) = evaluation.outcome else { + panic!("statusline evaluation must be ready in a single-window editor"); + }; + windows + .iter() + .flat_map(|window| window.left.iter().chain(window.right.iter())) + .find(|segment| segment.provider_id == id) + .map(|segment| segment.text.clone()) +} + +/// Dispatch one job that will still be **in flight** when the caller +/// looks, without sleeping. +/// +/// A pending entry leaves `Running` only inside `AsyncRuntime::tick`, so +/// a dispatch with no intervening tick is in flight by construction — +/// no wall-clock race, and no worker left sleeping past the test. +fn dispatch_one_in_flight(state: &EditorState) { + exec(state, "IN_FLIGHT = pmacs.workers.sleep(50)"); +} + +// --------------------------------------------------------------------------- +// 1 — purpose reaches the three structurally distinct entry paths +// --------------------------------------------------------------------------- + +/// One per distinct **shape**, not one per dispatcher: a pool +/// dispatcher, an `register_external` job, and a spawned process. +/// +/// `register_external` is here because MCP and LSP bypass the worker +/// pool entirely — they are the likeliest paths for a later field to be +/// added to `PendingJob` and quietly missed — and because its `JobKind` +/// is the undifferentiated `LspRequest`/`McpRequest` for every method, +/// so `purpose` is the only thing that tells two of its rows apart. +#[test] +fn every_entry_shape_records_what_its_work_is() { + let mut state = editor(); + + // (a) A pool dispatcher. + dispatch_one_in_flight(&state); + let purposes = active_purposes(&state); + assert_eq!(purposes.len(), 1, "one job in flight: {purposes:?}"); + assert_eq!( + purposes[0], "sleep 50ms", + "a pool job records the work, not just its handler's name" + ); + + // (b) An externally-settled job. The purpose is a PARAMETER here + // because `register_external` has nothing to derive one from: its + // kind is a category, not a description. + let (job_id, _token) = state.async_runtime.register_external( + JobKind::LspRequest, + None, + "lsp textDocument/definition file:///tmp/x.rs", + ); + let purposes = active_purposes(&state); + assert!( + purposes + .iter() + .any(|p| p == "lsp textDocument/definition file:///tmp/x.rs"), + "an externally-registered job carries its caller's description: {purposes:?}" + ); + state.async_runtime.complete_external_cancelled(job_id); + + // (c) A spawned process. `label` keeps its existing meaning and its + // existing callers; `purpose` is the new, separate answer to "what + // is this doing". + exec( + &state, + r#"P = pmacs.process.spawn { + label = "sh-1", + purpose = "probing the repository for a build system", + command = "/bin/sh", + args = { "-c", "sleep 5" }, + }"#, + ); + let rows: Vec = eval( + &state, + "local out = {} + for _, row in ipairs(pmacs.process.list()) do + out[#out + 1] = row.label .. ' | ' .. row.purpose + end + return out", + ); + assert!( + rows.iter() + .any(|row| row == "sh-1 | probing the repository for a build system"), + "a spawned process carries a purpose ALONGSIDE its label: {rows:?}" + ); + exec(&state, "pmacs.process.terminate(P)"); + + pump(&mut state); +} + +/// A `pmacs.process.spawn` caller that supplies no purpose keeps +/// working, and gets its own label back rather than an empty field. +/// +/// The Rust struct's field is required — the compiler enforces that at +/// every construction site. This surface is deliberately lenient, +/// because requiring it here would break every existing caller for no +/// coverage the compiler is not already providing. +#[test] +fn a_process_spawned_without_a_purpose_falls_back_to_its_label() { + let mut state = editor(); + exec( + &state, + r#"P = pmacs.process.spawn { + label = "legacy-caller", + command = "/bin/sh", + args = { "-c", "sleep 5" }, + }"#, + ); + let purpose: String = eval( + &state, + "for _, row in ipairs(pmacs.process.list()) do + if row.label == 'legacy-caller' then return row.purpose end + end + return ''", + ); + assert_eq!(purpose, "legacy-caller"); + exec(&state, "pmacs.process.terminate(P)"); + pump(&mut state); +} + +/// Q#W-4's preservation half, pinned here as well as by the three +/// leak-detector suites: `purpose` is a new KEY on each existing row and +/// changes nothing about **which** processes `list()` enumerates. +/// +/// `m6_8_multi_repl_acceptance`, `compile_mode_acceptance` and +/// `lean4_stage1_acceptance` all assert on `#pmacs.process.list()` as a +/// leak baseline. If any of them needs editing, the design is wrong. +#[test] +fn process_list_still_hides_terminal_ptys() { + let mut state = editor(); + let before: usize = eval(&state, "return #pmacs.process.list()"); + exec( + &state, + "T = pmacs.terminal.open { command = '/bin/sh', args = { '-c', 'sleep 5' } }", + ); + let after: usize = eval(&state, "return #pmacs.process.list()"); + assert_eq!( + before, after, + "a terminal PTY must stay invisible to pmacs.process.list (Q#W-4)" + ); + assert!( + eval::(&state, "return pmacs.terminal.is_terminal(T)"), + "precondition: the PTY really was opened" + ); + // No explicit close: terminals have no Lua teardown surface, and + // `EditorState::drop` shuts the supervisor down with SIGTERM then + // SIGKILL, so the child cannot outlive the test. + pump(&mut state); +} + +// --------------------------------------------------------------------------- +// 2 — the dispatch-name ambient (Q#W-2) +// --------------------------------------------------------------------------- + +/// **Rule 7 + the defect itself.** A job dispatched through +/// `pmacs.workers.dispatch("name", …)` reports `"name"`. +/// +/// The witness is a handler **registered from Lua that calls a real +/// dispatcher**, not a synthetic push of the ambient. A test that +/// pushed the name by hand would prove the stack works and leave the +/// actual defect — `name` dying inside an arbitrary handler, three +/// layers above anything that takes a name — completely unwitnessed. +#[test] +fn a_dispatched_job_reports_the_registered_handler_name() { + let mut state = editor(); + exec( + &state, + "pmacs.workers.register('indexer', function() + return pmacs.workers.sleep(50) + end) + H = pmacs.workers.dispatch('indexer')", + ); + let purposes = active_purposes(&state); + assert_eq!(purposes.len(), 1, "one job in flight: {purposes:?}"); + assert!( + purposes[0].starts_with("indexer"), + "the third party's own name must survive the call chain: {purposes:?}" + ); + + // Rule 7: outside any extent, nothing changes. + exec(&state, "DIRECT = pmacs.workers.sleep(50)"); + let purposes = active_purposes(&state); + assert!( + purposes.iter().any(|p| p == "sleep 50ms"), + "a builtin invoked directly records its own purpose: {purposes:?}" + ); + pump(&mut state); +} + +/// **Rule 6 — COMPOSE, do not replace.** Both halves asserted, because +/// a test on the prefix alone passes when the description is dropped, +/// and a test on the description alone passes when the third party is +/// lost again. +#[test] +fn a_dispatched_job_composes_the_handler_name_with_the_work() { + let mut state = editor(); + exec( + &state, + "pmacs.workers.register('indexer', function() + return pmacs.workers.sleep(50) + end) + H = pmacs.workers.dispatch('indexer')", + ); + let purposes = active_purposes(&state); + assert_eq!( + purposes, + vec!["indexer: sleep 50ms".to_owned()], + "letting the name win discards the work; letting the work win \ + loses the third party" + ); + pump(&mut state); +} + +/// **Rules 3 and 4 — nesting is a stack (innermost wins) and fan-out +/// shares the name.** +#[test] +fn nesting_takes_the_innermost_name_and_fan_out_shares_it() { + let mut state = editor(); + exec( + &state, + "pmacs.workers.register('inner', function() + -- Fan-out: two jobs under one handler. + A = pmacs.workers.sleep(50) + B = pmacs.workers.sleep(51) + return A + end) + pmacs.workers.register('outer', function() + pmacs.workers.dispatch('inner') + -- Back in `outer`'s extent: the stack restored on return. + C = pmacs.workers.sleep(52) + return C + end) + pmacs.workers.dispatch('outer')", + ); + let mut purposes = active_purposes(&state); + purposes.sort(); + assert_eq!( + purposes, + vec![ + "inner: sleep 50ms".to_owned(), + "inner: sleep 51ms".to_owned(), + "outer: sleep 52ms".to_owned(), + ], + "innermost wins inside, and the outer name is restored after" + ); + pump(&mut state); +} + +/// **Rule 5 — unwind-safe, and this is the one that makes a naive +/// version worse than none.** +/// +/// A handler that raises must still pop. Otherwise one failure poisons +/// every subsequent dispatch in the session with a stale name, and the +/// feature stops failing loudly and starts lying silently — a +/// regression that would surface as intermittent misattribution long +/// after the lane landed. +#[test] +fn a_raising_handler_still_pops_its_name() { + let mut state = editor(); + exec( + &state, + "pmacs.workers.register('boom', function() error('handler failed') end) + OK, ERR = pcall(pmacs.workers.dispatch, 'boom')", + ); + assert!( + !eval::(&state, "return OK"), + "the handler's error must still reach the caller" + ); + assert!( + eval::(&state, "return tostring(ERR)").contains("handler failed"), + "and must reach it unchanged" + ); + + exec(&state, "LATER = pmacs.workers.sleep(50)"); + let purposes = active_purposes(&state); + assert_eq!( + purposes, + vec!["sleep 50ms".to_owned()], + "an unrelated later dispatch must not inherit the failed \ + handler's name: {purposes:?}" + ); + pump(&mut state); +} + +/// **Preservation.** `pmacs.workers.dispatch` was `return +/// handler(args, opts)` — a tail call that propagates **every** return +/// value. Bracketing it must not quietly truncate that. +/// +/// A `local ok, result = pcall(...)` bracketing would pass every other +/// test in this file and lose a two-value handler's second value with no +/// error anywhere, which is the shape of regression that surfaces months +/// later in somebody else's package. +#[test] +fn dispatch_still_propagates_every_value_the_handler_returns() { + let mut state = editor(); + let values: Vec = eval( + &state, + "pmacs.workers.register('multi', function() + return pmacs.workers.sleep(50), 'second', 'third' + end) + local a, b, c = pmacs.workers.dispatch('multi') + return { type(a), tostring(b), tostring(c) }", + ); + assert_eq!( + values, + vec!["table".to_owned(), "second".to_owned(), "third".to_owned()], + "a multi-value handler must survive the bracketing" + ); + pump(&mut state); +} + +/// **Rule 2 — work dispatched LATER is not covered, deliberately.** +/// +/// A job dispatched from an `on_complete` callback runs ticks later, +/// outside the extent, and carries only its own purpose. Asserted so +/// that the boundary reads as designed rather than as broken; covering +/// it would need the asynchronous lifetime mechanism Stage 3 owns +/// (Q#W-5). +#[test] +fn work_dispatched_from_a_completion_callback_carries_no_handler_name() { + let mut state = editor(); + exec( + &state, + "LATE = nil + pmacs.workers.register('deferred', function() + local h = pmacs.workers.sleep(1) + h:on_complete(function() + LATE = pmacs.workers.sleep(50) + end) + return h + end) + pmacs.workers.dispatch('deferred')", + ); + // One tick settles the first job and fires the callback; the job the + // callback dispatches is what this test is about, so do not pump to + // quiescence before reading it. + let deadline = Instant::now() + Duration::from_secs(10); + while !eval::(&state, "return LATE ~= nil") { + assert!(Instant::now() < deadline, "callback never fired"); + state.tick_async(); + } + let purposes = active_purposes(&state); + assert_eq!( + purposes, + vec!["sleep 50ms".to_owned()], + "the extent is the handler CALL, not the job's lifetime: {purposes:?}" + ); + pump(&mut state); +} + +// --------------------------------------------------------------------------- +// 3 — rule 1: the extent is non-yieldable, and that is ENFORCED +// --------------------------------------------------------------------------- + +/// **Rule 1, first supported yield API.** Two assertions, and the +/// second is the load-bearing one. +/// +/// A guard that raises but leaves the name pushed has converted a silent +/// misattribution into a silent misattribution *plus* an error. So the +/// witness dispatches again after the rejection and asserts the new job +/// carries no stale name. +#[test] +fn awaiting_inside_a_handler_is_refused_and_the_scope_restores() { + let mut state = editor(); + // The awaited handle is created OUTSIDE the extent on purpose: the + // second assertion below is about what a job allocated *after* the + // refusal carries, and a job the handler allocated for itself would + // legitimately wear the handler's name and blur that. + exec( + &state, + "OUTSIDE = pmacs.workers.sleep(1) + REFUSAL = nil + pmacs.workers.register('awaits', function() + local ok, err = pcall(function() return OUTSIDE:await() end) + REFUSAL = (not ok) and tostring(err) or '' + return OUTSIDE + end) + pmacs.async(function() pmacs.workers.dispatch('awaits') end)", + ); + let refusal: String = eval(&state, "return REFUSAL"); + assert!( + refusal.contains("cannot await inside") && refusal.contains("pmacs.workers.dispatch"), + "the refusal must name the rule it enforces; got {refusal:?}" + ); + assert!( + !eval::(&state, "return pmacs._async._in_dispatch_name_scope()"), + "a refused await must still leave the scope popped" + ); + + exec(&state, "AFTER = pmacs.workers.sleep(50)"); + let purposes = active_purposes(&state); + assert!( + purposes.iter().any(|p| p == "sleep 50ms"), + "and a later dispatch must carry no stale name: {purposes:?}" + ); + assert!( + !purposes.iter().any(|p| p.starts_with("awaits:")), + "no job allocated after the refusal may inherit the handler's \ + name: {purposes:?}" + ); + pump(&mut state); +} + +/// **Rule 1, unconditionally.** The refusal fires even when the awaited +/// handle has already settled. +/// +/// This is the case that separates an unconditional guard from one whose +/// behaviour depends on a race: a guard placed after the `_is_complete` +/// check would fire only when a yield would really occur, passing under +/// test and failing intermittently in production depending on whether +/// the job happened to finish first. +#[test] +fn the_await_refusal_fires_even_for_an_already_complete_handle() { + let mut state = editor(); + exec(&state, "SETTLED = pmacs.workers.sleep(0)"); + let deadline = Instant::now() + Duration::from_secs(10); + while !eval::(&state, "return SETTLED:is_complete()") { + assert!(Instant::now() < deadline, "the canary never settled"); + state.tick_async(); + } + + exec( + &state, + "REFUSAL = nil + pmacs.workers.register('awaits-settled', function() + local ok, err = pcall(function() return SETTLED:await() end) + REFUSAL = (not ok) and tostring(err) or '' + return pmacs.workers.sleep(50) + end) + pmacs.workers.dispatch('awaits-settled')", + ); + let refusal: String = eval(&state, "return REFUSAL"); + assert!( + refusal.contains("cannot await inside") && refusal.contains("pmacs.workers.dispatch"), + "a settled handle must be refused too, or the guard's behaviour \ + depends on a race; got {refusal:?}" + ); + assert!( + !eval::(&state, "return pmacs._async._in_dispatch_name_scope()"), + "and the scope must still be popped" + ); + pump(&mut state); +} + +/// **Rule 1, second supported yield API.** Guarding `:await()` and not +/// `yield_to_next_tick` would leave the extent open through a second +/// door — and Q#W-7 below is the proof that exactly that happens when +/// only one door is guarded. +#[test] +fn yield_to_next_tick_inside_a_handler_is_refused_and_the_scope_restores() { + let mut state = editor(); + exec( + &state, + "REFUSAL = nil + pmacs.workers.register('yields', function() + local ok, err = pcall(pmacs.async.yield_to_next_tick) + REFUSAL = (not ok) and tostring(err) or '' + return pmacs.workers.sleep(50) + end) + pmacs.async(function() pmacs.workers.dispatch('yields') end)", + ); + let refusal: String = eval(&state, "return REFUSAL"); + assert!( + refusal.contains("cannot yield inside") && refusal.contains("pmacs.workers.dispatch"), + "the second yield API must refuse too; got {refusal:?}" + ); + assert!( + !eval::(&state, "return pmacs._async._in_dispatch_name_scope()"), + "and must leave the scope popped" + ); + + exec(&state, "AFTER = pmacs.workers.sleep(51)"); + let purposes = active_purposes(&state); + assert!( + purposes.iter().any(|p| p == "sleep 51ms"), + "a later dispatch must carry no stale name: {purposes:?}" + ); + pump(&mut state); +} + +// --------------------------------------------------------------------------- +// 4 — Q#W-7: the same hole in `commit_to`, closed here +// --------------------------------------------------------------------------- + +/// **Q#W-7 — a pre-existing defect, found by reading and repaired in +/// this lane.** +/// +/// `Handle:await()` refuses inside `pmacs.window.commit_to` precisely so +/// a coroutine cannot park with the frontend scope pushed (Journey Stage +/// 1a, Q#JR14b). But `pmacs.async.yield_to_next_tick()` also yields, is +/// public, and carried **no** such refusal — so that invariant had a +/// second entrance. +/// +/// **Reachability by a real caller is UNPROVEN.** No production caller +/// is known to yield through this door inside a commit; this pins the +/// guard rather than reproducing a user-visible bug. +/// +/// Both halves asserted, for the same reason as rule 1's: a refusal that +/// leaves the scope pushed swaps a silent misrouting for a loud one and +/// fixes neither. +#[test] +fn yield_to_next_tick_inside_commit_to_is_refused_and_the_commit_scope_restores() { + let mut state = editor(); + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("alpha.txt"), b"alpha\n").expect("write"); + + // A GENUINE destination, produced by the production capture: the + // listener claims (returns false), so nothing commits and what lands + // in `dest` is exactly the userdata dired would have received. + // Nothing in a test can construct one. + exec( + &state, + "dest = nil + pmacs.hook.add('path.open-directory', function(_, d) dest = d return false end)", + ); + state.open_directory_target(dir.path()); + pump(&mut state); + assert!( + eval::(&state, "return dest ~= nil"), + "the chain must hand listeners a destination" + ); + + exec( + &state, + "REFUSAL = nil + pmacs.async(function() + local ok, err = pcall(pmacs.window.commit_to, dest, function() + pmacs.async.yield_to_next_tick() + end) + REFUSAL = (not ok) and tostring(err) or '' + end)", + ); + let refusal: String = eval(&state, "return REFUSAL"); + assert!( + refusal.contains("cannot yield inside") && refusal.contains("commit_to"), + "the second door into the commit scope must be shut; got {refusal:?}" + ); + assert!( + !eval::(&state, "return pmacs._async._in_commit_scope()"), + "and the commit scope must still be restored afterwards" + ); + pump(&mut state); +} + +// --------------------------------------------------------------------------- +// 5 — the statusline activity indicator (Q#W-3, Q#W-6) +// --------------------------------------------------------------------------- + +/// **Absent at zero, asserted as an absent SEGMENT rather than as an +/// empty string.** A zero-width segment still consumes a separator in +/// the composed modeline, so "returns nothing" and "returns nothing +/// visible" are different claims and only one of them is the design. +#[test] +fn the_indicator_produces_no_segment_at_all_when_nothing_is_running() { + let state = editor(); + assert_eq!( + activity_segment(&state), + None, + "an idle editor must produce NO activity segment" + ); +} + +/// **A count plus the oldest in-flight job's purpose, witnessed through +/// the real per-frame evaluation path.** +/// +/// Driven through `paint_frame`, not by calling the provider function +/// directly: a provider that works in isolation and never gets evaluated +/// is exactly the failure this must exclude. +#[test] +fn the_indicator_shows_a_count_and_the_oldest_purpose_in_a_painted_frame() { + let mut state = editor(); + exec( + &state, + "FIRST = pmacs.workers.sleep(50) + SECOND = pmacs.workers.grep({ root = '/tmp', pattern = 'zzz-no-match' })", + ); + + let cells = paint(&state, 24, 160); + let modeline = row_text(&cells, 160, 22); + assert!( + modeline.contains("⋯2 sleep 50ms"), + "the painted modeline must carry the count and the OLDEST job's \ + purpose (not the newest); got {modeline:?}" + ); + + // And the same value reaches the evaluator's segment vector, which is + // what the semantic frontend ships. + assert_eq!( + activity_segment(&state).as_deref(), + Some("⋯2 sleep 50ms"), + "the segment and the painted row must agree" + ); + + exec(&state, "SECOND:cancel()"); + pump(&mut state); +} + +/// **Q#W-6 — the setting, witnessed with work genuinely in flight.** +/// +/// The discriminating case: an assertion taken on an idle editor cannot +/// tell "disabled" from "nothing is happening", which is the only thing +/// this setting changes. +#[test] +fn the_indicator_honours_its_setting_while_work_is_in_flight() { + let mut state = editor(); + dispatch_one_in_flight(&state); + assert!( + activity_segment(&state).is_some(), + "precondition: work is in flight and the indicator is on" + ); + + exec(&state, "pmacs.config.set('ui.activity-indicator', false)"); + assert_eq!( + activity_segment(&state), + None, + "disabled means NO segment, with work still running" + ); + + exec(&state, "pmacs.config.set('ui.activity-indicator', true)"); + assert!( + activity_segment(&state).is_some(), + "and re-enabling brings it back without a restart" + ); + pump(&mut state); +} + +/// The setting is a real registry entry, not an ad-hoc global: it is +/// discoverable through `pmacs.config.describe` like every other +/// setting, which is what `COHERENCE.md` §11 grades. +#[test] +fn the_setting_is_registered_with_a_true_default() { + let state = editor(); + let (kind, default): (String, bool) = eval( + &state, + "local d = pmacs.config.describe('ui.activity-indicator') + return d.type, d.default", + ); + assert_eq!(kind, "boolean"); + assert!(default, "visible by default — no configuration, no command"); +} + +// --------------------------------------------------------------------------- +// 6 — `*workers*` renders the purpose +// --------------------------------------------------------------------------- + +/// The view §9 already has, now answering §9's question. +/// +/// `Kind` names the builtin dispatcher a job funnelled through, which +/// for a third-party job is a builtin's label rather than the caller's; +/// the `Purpose` column is what carries the caller's own account. +#[test] +fn the_workers_buffer_renders_the_purpose_column() { + let mut state = editor(); + exec( + &state, + "pmacs.workers.register('indexer', function() + return pmacs.workers.sleep(50) + end) + pmacs.workers.dispatch('indexer') + BUF = pmacs.workers.show()", + ); + let text: String = eval(&state, "return BUF:slice(0, BUF:len())"); + assert!( + text.contains("Purpose"), + "the active table must have a Purpose column:\n{text}" + ); + assert!( + text.contains("indexer: sleep 50ms"), + "and the row must render it:\n{text}" + ); + exec(&state, "pmacs.workers.hide()"); + pump(&mut state); +}