feat(workers): a required purpose on every job and process — worker identity Stage 1
`COHERENCE.md` §9 grades the worker model "mechanism without identity",
and §0 names step 11 (background-work ownership) as one of the two
remaining thin ends of the golden journey. The mechanism half is solid —
cancellation, supersession, streaming, frame-aware draining, `*workers*`.
The identity half was absent: `PendingJob` carried no description of what
it was doing, `pmacs.workers.dispatch` discarded the registered handler
name three layers above anything that takes one, and §9's "no progress
indicator exists anywhere" was checkable and true.
Framing: `docs/worker-identity-framing.md` (revision 4, approved).
What lands:
**A required `purpose`, on the job and on the process.** Non-optional,
with no `Default`, so the compiler — not a test — is what proves every
dispatcher supplied one. `allocate` / `allocate_with_resource` collapse
into ONE private `JobSpec`-taking funnel (Q#W-1): the two-function split
existed only because one prior lane needed one extra parameter, and a
second lane doing the same produces `allocate_with_resource_and_identity`.
`register_external` gains a `purpose` parameter rather than deriving one,
because its `JobKind` is `McpRequest`/`LspRequest` for every method — a
category, not a description.
**A dispatch-name ambient (Q#W-2), read at that same single funnel.** The
capture point is Rust, not the Lua wrapper layer, because a handler
reaching straight for `pmacs._async._dispatch_*` bypasses the wrappers
entirely — and those are precisely the callers attribution exists for.
Seven rules; the ones that decide whether it is honest:
- **Rule 1 — the extent is NON-YIELDABLE, and that is ENFORCED.** Both
supported yield APIs refuse inside it, modelled on the `commit_to`
refusal already in `async.lua`. The guards reject BEFORE parking and
reject UNCONDITIONALLY: one placed after `_is_complete` would fire only
when a yield really occurred, passing under test and failing
intermittently in production.
- **A raw `coroutine.yield` is NOT covered, and nothing here claims it
is.** R46 is a convention, and the scheduler inspects the yielded value
only after `coroutine.resume` returns — by which point the coroutine has
already suspended — so no refusal sited in a yield helper is ever
consulted. The residual is recorded in the framing §2 and in the
suite's module docs rather than papered over with a test that would
imply coverage this design lacks.
- **Rule 5 — unwind-safe.** A raising handler still pops. A version that
did not would let one failure poison every later dispatch in the session
with a stale name: the feature would stop failing loudly and start lying
silently. The bracketing also has to preserve the tail call it replaced:
`dispatch` was `return handler(args, opts)` and propagated EVERY return
value, so the pop/rethrow runs behind a varargs boundary rather than a
`local ok, result = pcall(...)` that would silently truncate a
multi-value handler. Varargs rather than `table.pack`, because that is
Lua 5.2 surface and LuaJIT is this project's default backend.
- **Rule 6 — compose, do not replace.** `"<name>: <purpose>"`, because
letting the dispatcher's purpose win loses the third party again and
letting the name win discards the only description of the actual work.
**A statusline activity indicator** — the fourth `pmacs.statusline.register`
adopter, after `mode`, `terminal` and `lsp`. A count plus the OLDEST
in-flight job's purpose ("busiest" is not a defined quantity; jobs carry
no cost estimate), and **absent entirely** when idle rather than a
zero-width segment that costs modeline width forever to say nothing is
happening. Gated by one setting, `ui.activity-indicator` (boolean, default
true, Q#W-6) — a permanently-visible modeline element is a preference
someone genuinely holds on day one. No setting for purpose capture
itself: that is substrate.
**NO WIRE CHANGE.** The indicator rides the existing `StatuslineSegments`
vector, so a fourth provider adds an element, not a variant.
`PROTOCOL_VERSION` and `ADVERTISED_PROTOCOL_VERSION` are untouched — which
is the property that lets this run beside the two lanes holding the bump
slot.
**Q#W-7 — a pre-existing defect, repaired here, and NOT one anybody has
observed.** `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, and a coroutine could produce exactly the misrouting the
`await` guard exists to prevent. It gains both refusals here: the same
supported yield helper, the same invariant, the same edit family, so
splitting it would have preserved a known hole without reducing
integration risk.
**Reachability by a real caller is UNPROVEN.** This was found by reading
the guard family while scouting rule 1, not by reproducing a fault. No
production caller is known to yield through that door inside a commit,
and the test pins the guard rather than reproducing a user-visible bug.
Nobody should later cite this commit as evidence the bug was observed in
the wild. Its witness is a PAIR, like rule 1's: the refusal fires **and**
the commit scope is restored afterwards — a guard that raises while
leaving the scope pushed converts a silent fault into a loud one and
fixes neither.
`journey_acceptance` carries the established `commit_to` pins —
forged-destination refusal, scope-and-restore on normal return and on
raise, the await refusal, delivery to the requesting frontend. It passes
**untouched**, which is what says this closed a gap in Journey Stage 1a's
semantics rather than altering them.
What is deliberately NOT here, and why it is worth saying:
- **No `owner`, in any spelling** — not `origin`, not `subsystem` (§3).
Populated from static per-subsystem constants it would be an origin,
not an owner, and would confidently misattribute third-party work to a
builtin at exactly the point §9 wants attribution. A field that asserts
a falsehood is worse than an absent one. The slot stays empty until P3
can fill it with a real package signal.
- **No `parent`** (Q#W-5). An unpopulated field renders as `None`
everywhere and reads as "this job has no parent" rather than "this
system does not track parents". Stage 3 builds the lifetime model and
the field together.
Consequences worth recording:
- `ProcessSpec::new` takes a third argument. The 40-odd call sites are
almost all tests; the three production ones (LSP, MCP, terminal) supply
real descriptions. `pmacs.process.spawn`'s Lua surface keeps `purpose`
OPTIONAL, falling back to the label — requiring it there would break
every existing caller for no coverage the compiler is not already
providing, and a caller's own label is not a fabrication.
- `pmacs.process.list` gains a `purpose` KEY on each row and enumerates
exactly the same processes (Q#W-4). Terminal PTYs stay hidden: three
acceptance suites use `#pmacs.process.list()` as a leak baseline, and
widening the accessor would inflate all three. Stage 2's unified view
owns that decision.
- `statusline_segments_acceptance`'s builtin-provider inventory grows to
`["activity", "mode", "terminal", "lsp"]`. That assertion exists to
grow when a builtin provider is added.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
parent
26a26006bb
commit
689fb8333d
|
|
@ -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, `"<name>: <purpose>"`) 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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -408,6 +408,47 @@ struct PendingJob {
|
|||
/// job→buffer link already lives in a side map and §9 names that as
|
||||
/// the defect.
|
||||
resource: Option<ResourceOp>,
|
||||
/// 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<usize>,
|
||||
/// Filesystem mutation this job performs, for the settle-time
|
||||
/// reconcile (dired Stage 2a).
|
||||
resource: Option<ResourceOp>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
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<Mutex<HashMap<JobId, Arc<ParseTreeBundle>>>>,
|
||||
/// 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<Vec<String>>,
|
||||
}
|
||||
|
||||
/// 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<String>) {
|
||||
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<String> {
|
||||
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<usize>,
|
||||
) -> (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<usize>,
|
||||
resource: Option<ResourceOp>,
|
||||
) -> (JobId, CancellationToken) {
|
||||
///
|
||||
/// The recorded purpose **composes** with any dispatch-name ambient
|
||||
/// rather than replacing it (Q#W-2 rule 6): `"<name>: <purpose>"`
|
||||
/// where the dispatcher described its own work, `"<name>"` 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<usize>,
|
||||
) -> 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<usize>,
|
||||
) -> 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<String>,
|
||||
) -> (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<ActivitySummary> {
|
||||
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<ResourceOp> {
|
||||
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,
|
||||
|
|
|
|||
21
src/lsp.rs
21
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 {
|
||||
|
|
|
|||
|
|
@ -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<RestartPolicy> {
|
|||
fn lua_to_spec(table: &Table) -> mlua::Result<ProcessSpec> {
|
||||
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::<Option<String>>("purpose")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| label.clone());
|
||||
let args: Vec<String> = table.get("args").unwrap_or_default();
|
||||
let cwd: Option<String> = table.get("cwd").ok().flatten();
|
||||
let env_table: Option<Table> = table.get("env").ok().flatten();
|
||||
|
|
@ -8762,6 +8846,7 @@ fn lua_to_spec(table: &Table) -> mlua::Result<ProcessSpec> {
|
|||
};
|
||||
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)?)?;
|
||||
|
|
|
|||
38
src/mcp.rs
38
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 {
|
||||
|
|
|
|||
|
|
@ -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<String>, command: impl Into<String>) -> Self {
|
||||
pub fn new(
|
||||
label: impl Into<String>,
|
||||
command: impl Into<String>,
|
||||
purpose: impl Into<String>,
|
||||
) -> 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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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} {:<PURPOSE_WIDTH$} Status",
|
||||
"ID", "Kind", "Age", "Supersede", "Purpose"
|
||||
);
|
||||
let _ = writeln!(
|
||||
text,
|
||||
"{:<7} {:<11} {:>9} {:<11} ----------",
|
||||
"------", "-----------", "---------", "-----------"
|
||||
"{:<7} {:<11} {:>9} {:<11} {:<PURPOSE_WIDTH$} ----------",
|
||||
"------",
|
||||
"-----------",
|
||||
"---------",
|
||||
"-----------",
|
||||
"-".repeat(PURPOSE_WIDTH)
|
||||
);
|
||||
if snapshot.active.is_empty() {
|
||||
let _ = writeln!(text, "(no active jobs)");
|
||||
|
|
@ -139,13 +163,17 @@ fn format_snapshot(snapshot: &WorkersSnapshot) -> String {
|
|||
let _ = writeln!(text);
|
||||
let _ = writeln!(
|
||||
text,
|
||||
"{:<7} {:<11} {:>9} {:<11} Outcome",
|
||||
"ID", "Kind", "Duration", "Supersede"
|
||||
"{:<7} {:<11} {:>9} {:<11} {:<PURPOSE_WIDTH$} Outcome",
|
||||
"ID", "Kind", "Duration", "Supersede", "Purpose"
|
||||
);
|
||||
let _ = writeln!(
|
||||
text,
|
||||
"{:<7} {:<11} {:>9} {:<11} ----------",
|
||||
"------", "-----------", "---------", "-----------"
|
||||
"{:<7} {:<11} {:>9} {:<11} {:<PURPOSE_WIDTH$} ----------",
|
||||
"------",
|
||||
"-----------",
|
||||
"---------",
|
||||
"-----------",
|
||||
"-".repeat(PURPOSE_WIDTH)
|
||||
);
|
||||
if snapshot.completed.is_empty() {
|
||||
let _ = writeln!(text, "(no recent completions)");
|
||||
|
|
@ -169,7 +197,11 @@ fn write_active_row(text: &mut String, job: &ActiveJobInfo) {
|
|||
if job.is_stream {
|
||||
status.push_str(" [stream]");
|
||||
}
|
||||
let _ = writeln!(text, "{id:<7} {kind:<11} {age:>9} {key:<11} {status}");
|
||||
let purpose = &job.purpose;
|
||||
let _ = writeln!(
|
||||
text,
|
||||
"{id:<7} {kind:<11} {age:>9} {key:<11} {purpose:<PURPOSE_WIDTH$} {status}"
|
||||
);
|
||||
}
|
||||
|
||||
fn write_completed_row(text: &mut String, job: &CompletedJobInfo) {
|
||||
|
|
@ -179,9 +211,10 @@ fn write_completed_row(text: &mut String, job: &CompletedJobInfo) {
|
|||
let key = job.supersede_key.as_deref().unwrap_or("");
|
||||
let outcome = format_outcome(&job.outcome);
|
||||
let age = format_duration_ms(job.settled_age_ms);
|
||||
let purpose = &job.purpose;
|
||||
let _ = writeln!(
|
||||
text,
|
||||
"{id:<7} {kind:<11} {duration:>9} {key:<11} {outcome} ({age} ago)"
|
||||
"{id:<7} {kind:<11} {duration:>9} {key:<11} {purpose:<PURPOSE_WIDTH$} {outcome} ({age} ago)"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -287,6 +320,7 @@ mod tests {
|
|||
supersede_key: Some("search".to_string()),
|
||||
cancel_requested: false,
|
||||
is_stream: true,
|
||||
purpose: "grep pattern".to_string(),
|
||||
}],
|
||||
vec![],
|
||||
);
|
||||
|
|
@ -309,6 +343,7 @@ mod tests {
|
|||
supersede_key: None,
|
||||
cancel_requested: true,
|
||||
is_stream: false,
|
||||
purpose: "grep pattern".to_string(),
|
||||
}],
|
||||
vec![],
|
||||
);
|
||||
|
|
@ -326,6 +361,7 @@ mod tests {
|
|||
duration_ms: 25,
|
||||
settled_age_ms: 200,
|
||||
supersede_key: None,
|
||||
purpose: "sum 1..10".to_string(),
|
||||
outcome: JobOutcome::Complete(JobResult::Sum(55)),
|
||||
}],
|
||||
);
|
||||
|
|
@ -368,6 +404,7 @@ mod tests {
|
|||
supersede_key: None,
|
||||
cancel_requested: false,
|
||||
is_stream: true,
|
||||
purpose: "grep pattern".to_string(),
|
||||
}],
|
||||
vec![],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -952,7 +952,7 @@ fn has_exit_event(events: &[ProcessEvent]) -> 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");
|
||||
|
|
|
|||
|
|
@ -136,7 +136,12 @@ fn a01_04_registry_contract_limits_epochs_and_results() {
|
|||
.iter()
|
||||
.map(|provider| provider.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<T: mlua::FromLuaMulti>(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<String> {
|
||||
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<Cell> {
|
||||
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<String>`, 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<String> {
|
||||
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<String> = 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 '<absent>'",
|
||||
);
|
||||
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::<bool>(&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::<bool>(&state, "return OK"),
|
||||
"the handler's error must still reach the caller"
|
||||
);
|
||||
assert!(
|
||||
eval::<String>(&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<String> = 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::<bool>(&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 '<no raise>'
|
||||
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::<bool>(&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::<bool>(&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 '<no raise>'
|
||||
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::<bool>(&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 '<no raise>'
|
||||
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::<bool>(&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::<bool>(&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 '<no raise>'
|
||||
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::<bool>(&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);
|
||||
}
|
||||
Loading…
Reference in New Issue