pmacs/src/async_runtime.rs

2990 lines
119 KiB
Rust

// async_runtime.rs --- T M3.3 main-thread async runtime: dispatcher,
// pending-job table, and tick that drains the worker reply bus.
//! Async runtime (T M3.3).
//!
//! This module is the bridge between the Rust worker pool ([T M3.1]) and
//! the message bus ([T M3.2]) on the producer side, and the Lua
//! coroutine API ([R44], [R45], [R46]) on the consumer side. It runs
//! exclusively on the main editor thread, so it uses [`Rc`] +
//! [`RefCell`] for shared state instead of [`Arc`] + [`Mutex`].
//!
//! # Topology
//!
//! ```text
//! ┌─────────────────────────────────┐
//! │ AsyncRuntime (main thread, !Send)│
//! │ │
//! Lua: pmacs.workers.* │ pool ──► [worker threads] │
//! -------------------► │ │ │ │
//! │ ▼ ▼ bus.send │
//! │ pending bus_main ◄──────┐ │
//! │ ▲ tick() │ │
//! │ └─────────────────────┘ │
//! └──────────────────────────────────┘
//! ```
//!
//! `dispatch_*` allocates a fresh [`JobId`], inserts a [`PendingJob`]
//! into [`AsyncRuntime::pending`], and submits a closure to the
//! [`WorkerPool`] that always responds via the bus --- including a
//! `Cancelled` reply when the closure observes the runtime's own
//! [`CancellationToken`]. [`AsyncRuntime::tick`] drains every
//! envelope queued on `bus_main`, decodes it, and updates the
//! corresponding pending entry.
//!
//! # Cancellation token discipline
//!
//! The runtime carries its **own** cancellation token per job, separate
//! from the [`WorkerPool`]'s built-in token. The pool's token is used
//! for skip-before-pickup semantics; we never set it. Our own token is
//! the one [`AsyncRuntime::cancel`] flips, and the worker closure
//! always runs (so a cancelled job still produces a `Cancelled` reply,
//! making the pending table reachable from `tick`).
//!
//! # Builtin handlers
//!
//! T M3.3 ships two built-in handlers, intended primarily as
//! exercise material for the coroutine API:
//!
//! * [`AsyncRuntime::dispatch_sleep`] --- sleeps `ms` milliseconds
//! while polling the cancel token at 1ms granularity. Returns
//! `JobResult::Unit`.
//! * [`AsyncRuntime::dispatch_compute_sum`] --- computes
//! `1 + 2 + ... + n` while polling the cancel token. Returns
//! `JobResult::Sum(u64)`.
//!
//! T M3.6 adds the parallel grep handler ([`AsyncRuntime::dispatch_grep`])
//! that doubles as M3's milestone-justifying load: parallel directory
//! search with cooperative cancellation and frame-boundary coalescing.
//! Tree-sitter and LSP land in M4 on the same dispatch shape.
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use crossbeam::channel as cb_channel;
use serde::{Deserialize, Serialize};
use crate::fs::{
FsDirEntry, FsDirListing, FsError, ReadDirTolerance, chmod_blocking, read_dir_blocking,
remove_blocking, rename_blocking, stat_blocking,
};
use crate::message_bus::{BusEnd, MessageBus, SchemaRegistry};
use crate::syntax::{self as syntax_mod, ParseRequest, ParseTreeBundle};
use crate::worker::{CancellationToken, WorkerPool};
/// Identifier the runtime hands to Lua so a [`Handle`] knows which
/// pending entry it is bound to. Distinct from the bus's
/// `MessageId` and from the pool's `JobId` --- this is the ID Lua
/// userdata holds.
pub type JobId = u64;
/// Bus topic carrying every worker reply. A single topic suffices
/// because [`WorkerReply::kind`] discriminates between the variants
/// our handlers produce.
const ASYNC_REPLY_TOPIC: &str = "async.reply";
// ---------------------------------------------------------------------------
// Wire types
// ---------------------------------------------------------------------------
/// One item produced by a stream handler. Each variant corresponds
/// to a stream "shape" (numeric ticker, grep match, ...); the Lua
/// binding layer translates the variant into the appropriate
/// representation when delivering a batch to user callbacks.
///
/// New variants land here as new streaming handlers are added. The
/// enum is `#[non_exhaustive]` from the Lua side's perspective (the
/// match in `lua_bindings::install_async` exhaustively translates
/// every variant), but staying exhaustive in Rust keeps the compiler
/// honest when handlers are added.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum StreamPayload {
/// 1-based index emitted by the synthetic `emit_n` handler. T M3.5.
U64(u64),
/// One grep match: the file the match was found in, the 1-based
/// line number, byte offsets of the match within the line text,
/// and the line text itself (truncated past
/// [`GrepSpec::max_match_text`]). T M3.6.
Match(GrepMatch),
}
/// One match emitted by [`run_grep`]. Owned strings only --- a worker
/// holds nothing but its own copy of these bytes ([R31]). The Lua
/// binding turns each into a `{ file, line, text, match_start,
/// match_end }` table.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct GrepMatch {
/// Path the match was found in, relative to the search root.
pub file: String,
/// 1-based line number within the file.
pub line: u32,
/// Byte offset of the match start within `text`.
pub match_start: u32,
/// Byte offset of the match end within `text` (exclusive).
pub match_end: u32,
/// The full line containing the match, truncated to
/// [`GrepSpec::max_match_text`] bytes if longer. Always valid
/// UTF-8 (non-UTF-8 lines are skipped at search time).
pub text: String,
}
/// Description of a grep job: where to search, what to look for, and
/// the limits that bound resource use. Kept owned-only ([R31]) so the
/// closure dispatched to a worker holds nothing borrowed from the main
/// thread.
#[derive(Clone, Debug)]
pub struct GrepSpec {
/// Directory to walk. Symlinks and hidden directories
/// (`.git`, `.svn`, `.hg`, `node_modules`, `target`) are skipped
/// to keep the workload bounded; a future opt-in would expose
/// these as filter knobs.
pub root: PathBuf,
/// Literal byte pattern to search for. Empty pattern matches no
/// lines (rather than all lines, which would be a footgun).
/// Regex support lands in M4 with the LSP pipeline.
pub pattern: String,
/// If `false`, ASCII-fold both pattern and line bytes before
/// comparing (`Aa` and `aA` match `aa`). Defaults to `true`.
pub case_sensitive: bool,
/// Skip files larger than this (bytes). Default: 16 MiB ---
/// matches ripgrep's behaviour and bounds the per-file
/// allocation a worker has to make.
pub max_file_bytes: u64,
/// Truncate any line longer than this (bytes) to this length
/// before emitting it. Defaults to 4 KiB. Pathological binary
/// or minified files can have multi-megabyte "lines" and would
/// otherwise blow the wire format.
pub max_match_text: u32,
/// Stop emitting after this many matches; `0` means unlimited.
/// Default: 0. The user-facing cancel + supersede paths are the
/// canonical way to cut a search short --- this knob is for
/// pathological "every line matches" loads.
pub max_results: u32,
/// Number of internal threads the grep job spawns to fan out
/// file searches. Defaults to `available_parallelism`. The grep
/// dispatch itself occupies one [`WorkerPool`] slot; these are
/// scoped threads spawned *inside* that closure.
pub fanout: usize,
}
impl GrepSpec {
/// A spec searching `root` for the literal `pattern`, with
/// reasonable defaults for the rest. Callers tune individual
/// fields with struct-update syntax.
#[must_use]
pub fn new(root: PathBuf, pattern: String) -> Self {
let fanout = thread::available_parallelism().map_or(2, std::num::NonZeroUsize::get);
Self {
root,
pattern,
case_sensitive: true,
max_file_bytes: 16 * 1024 * 1024,
max_match_text: 4096,
max_results: 0,
fanout: fanout.max(1),
}
}
}
/// Discriminant carried alongside each reply.
#[derive(Clone, Debug, Serialize, Deserialize)]
enum ReplyKind {
/// `dispatch_sleep` finished without observing cancellation.
Sleep,
/// `dispatch_compute_sum` completed; payload is the sum.
Sum(u64),
/// The handler observed cancellation and exited early.
Cancelled,
/// The handler raised. Carries a stringified message --- richer
/// error structure can land in M4 once real handlers exist.
Error(String),
/// One streaming item from a stream handler. Multiple items per
/// stream id are accumulated in [`PendingJob::stream_buffer`] and
/// delivered as a single batch on the next
/// [`AsyncRuntime::take_stream_batches`] call. T M3.5.
StreamItem(StreamPayload),
/// Stream completed cleanly (no further items will arrive).
StreamClosed,
/// `dispatch_parse` settled. The fresh
/// [`crate::syntax::ParseTreeBundle`] is *not* on the wire ---
/// trees aren't `Serialize` --- so the worker has already stashed
/// it in [`AsyncRuntime::parse_handoff`] under `job_id`.
/// `duration_ms` is the parse-only duration (excludes dispatch
/// queueing) and is what the M4.1 acceptance criteria measure.
/// T M4.1.
Parse { duration_ms: u64 },
/// `dispatch_fs_read_dir` completed; payload is the directory
/// listing. The listing is `Serialize` so it crosses the bus
/// directly --- no side handoff like parse trees need. T M8.1; its
/// per-entry error channel is dired Q#DR6.
ReadDir(FsDirListing),
/// `dispatch_fs_stat` completed; payload is the per-path
/// metadata. T M8.1.
Stat(FsDirEntry),
/// Generic completion-with-no-payload reply for the unit-result
/// fs primitives (`rename`, `chmod`, `remove`). Distinct from
/// [`Self::Sleep`] so the worker observability layer can label
/// fs jobs separately. T M8.1.
FsUnit,
/// Externally-settled job produced a JSON value. Sent by
/// [`AsyncRuntime::complete_external_ok`] from the main thread;
/// the runtime's `tick` translates it to
/// [`JobResult::Json`]. T M9.1.
Json(serde_json::Value),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct WorkerReply {
job_id: JobId,
kind: ReplyKind,
}
// ---------------------------------------------------------------------------
// Runtime-side types
// ---------------------------------------------------------------------------
/// Result a completed job hands back to Lua. Per-handler payload
/// shape lives here so [`crate::lua_bindings`] can convert it without
/// reaching into the wire enum.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum JobResult {
/// Sleep handler completed (no payload).
Unit,
/// Compute-sum handler completed; carries the sum.
Sum(u64),
/// Parse handler completed. The tree itself is held in the
/// runtime's parse-handoff side map under the same `JobId`;
/// callers fetch it via [`AsyncRuntime::take_parse_tree`]. The
/// `duration_ms` here is the parse-only duration. T M4.1.
Parse {
/// Parse-only wall-clock duration in milliseconds.
duration_ms: u64,
},
/// `dispatch_fs_read_dir` produced a directory listing. The
/// Lua boundary in [`crate::lua_bindings`] turns the entries into
/// per-entry tables when `_take_result` consumes the result, and
/// keys the result *shape* on whether the listing carries a
/// per-entry error channel. T M8.1 / dired Q#DR6.
ReadDir(FsDirListing),
/// `dispatch_fs_stat` produced metadata for a single path. The
/// Lua boundary turns the [`FsDirEntry`] into the same table
/// shape `read_dir` entries use. T M8.1.
Stat(FsDirEntry),
/// External request/reply produced a JSON-shaped result. Used by
/// the M9.1 MCP integration; the Lua boundary in
/// [`crate::lua_bindings`] translates the inner
/// [`serde_json::Value`] to a Lua table when `_take_result` is
/// called. T M9.1.
Json(serde_json::Value),
}
/// Terminal state a [`PendingJob`] settles into.
#[derive(Clone, Debug)]
enum PendingState {
/// Worker has not replied yet.
Running,
/// Worker replied with a successful result.
Complete(JobResult),
/// Worker observed cancellation (or never ran).
Cancelled,
/// Worker raised. Carries the error message for surfacing to Lua.
Failed(String),
}
/// Discriminator for which builtin handler a job was dispatched
/// against. Used by the observability buffer ([T M3.7]) to label
/// rows. New handlers added in M4 (tree-sitter, LSP, project
/// indexing) extend this enum.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum JobKind {
/// `dispatch_sleep` --- the canary handler.
Sleep,
/// `dispatch_compute_sum` --- the synthetic CPU load.
ComputeSum,
/// `dispatch_emit_n` --- streaming numeric ticker.
EmitN,
/// `dispatch_grep` --- parallel directory grep ([T M3.6]).
Grep,
/// `dispatch_parse` --- tree-sitter parse on a worker ([T M4.1]).
Parse,
/// `dispatch_fs_read_dir` --- directory enumeration ([T M8.1]).
FsReadDir,
/// `dispatch_fs_stat` --- single-path metadata ([T M8.1]).
FsStat,
/// `dispatch_fs_rename` --- atomic rename ([T M8.1]).
FsRename,
/// `dispatch_fs_chmod` --- permission-bit replacement ([T M8.1]).
FsChmod,
/// `dispatch_fs_remove` --- delete a single object ([T M8.1]).
FsRemove,
/// External request/reply settled by code outside the worker
/// pool. Used by the M9.1 MCP integration: the manager registers
/// a pending entry via [`AsyncRuntime::register_external`] and
/// settles it via [`AsyncRuntime::complete_external_ok`] /
/// `complete_external_failed` / `complete_external_cancelled`
/// when the corresponding JSON-RPC response arrives on the
/// supervisor pipe. No worker thread is occupied for the
/// round-trip.
McpRequest,
/// External request/reply for an LSP server, settled the same way
/// as [`JobKind::McpRequest`]: the [`crate::lsp::LspManager`]
/// registers a pending entry via [`AsyncRuntime::register_external`]
/// when a `textDocument/*` request goes out and settles it via
/// `complete_external_ok` / `_failed` / `_cancelled` when the
/// JSON-RPC response is routed in `LspManager::tick`. No worker
/// thread is occupied for the round-trip ([T M4.5] async bridge).
LspRequest,
}
impl JobKind {
/// Human-readable label used by the `*workers*` buffer.
#[must_use]
pub fn label(self) -> &'static str {
match self {
JobKind::Sleep => "sleep",
JobKind::ComputeSum => "compute_sum",
JobKind::EmitN => "emit_n",
JobKind::Grep => "grep",
JobKind::Parse => "parse",
JobKind::FsReadDir => "fs_read_dir",
JobKind::FsStat => "fs_stat",
JobKind::FsRename => "fs_rename",
JobKind::FsChmod => "fs_chmod",
JobKind::FsRemove => "fs_remove",
JobKind::McpRequest => "mcp_request",
JobKind::LspRequest => "lsp_request",
}
}
}
#[derive(Clone, Debug)]
struct PendingJob {
cancel: CancellationToken,
state: PendingState,
/// If this job was dispatched with a supersede key, the key is
/// preserved so [`AsyncRuntime::tick`] can prune the
/// `key → id` mapping when the job settles --- but only if the
/// table still maps the key to *this* id (a later dispatch may
/// have superseded us, in which case the table belongs to that
/// successor). T M3.4 / [spec §6.3].
supersede_key: Option<String>,
/// Per-job streaming accumulator. `Some(buf)` marks this entry as
/// a stream; `tick` pushes [`ReplyKind::StreamItem`] payloads
/// into `buf` and [`AsyncRuntime::take_stream_batches`] drains
/// them. `None` means this is a request/reply job and stream
/// items targeting it are dropped (with no panic). T M3.5.
stream_buffer: Option<Vec<StreamPayload>>,
/// Per-stream cap on items delivered in a single batch. Items
/// beyond the cap stay in the accumulator until the next drain.
/// Tunable per dispatch (and falls back to the runtime's
/// `default_max_batch`). T M3.5.
max_batch: usize,
/// Which built-in handler this job runs. Surfaced by the
/// `*workers*` buffer ([T M3.7]).
kind: JobKind,
/// When the job was registered. Used to compute "age" in the
/// `*workers*` buffer.
dispatched_at: Instant,
/// The filesystem mutation this job performs, retained so the
/// main-thread drain can reconcile the editor's path owners once
/// the syscall lands (dired Stage 2a, §5).
///
/// The paths have to live here because the dispatchers **move**
/// them into the worker closure and nothing else retains them, and
/// because the reply is undifferentiated — rename and remove both
/// settle as `ReplyKind::FsUnit`, so a drain cannot key on the
/// reply and must key on the pending job.
///
/// One enum field rather than a pair of `Option`s: two would admit
/// a both-`Some` state that cannot occur, which every consumer
/// would then have to rule out by hand. `COHERENCE.md` §9 is why
/// this is a field on the job and not a side map — the parse
/// job→buffer link already lives in a side map and §9 names that as
/// the defect.
resource: Option<ResourceOp>,
}
/// A settled filesystem mutation, with the paths the worker consumed
/// (dired Stage 2a, §5).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ResourceOp {
/// A successful `rename(from, to)`.
Rename {
/// Source path, as the caller spelled it.
from: PathBuf,
/// Destination path, as the caller spelled it.
to: PathBuf,
},
/// A successful `remove(path)`.
Remove {
/// The path that was removed.
path: PathBuf,
},
}
/// What one [`AsyncRuntime::tick`] observed.
///
/// Settle identity and resource metadata come out of **one**
/// transaction — the post-drain loop already borrows `pending` to
/// record completions — so a consumer cannot see a settle without its
/// resource, or the reverse.
#[derive(Clone, Debug, Default)]
pub struct TickOutcome {
/// Ids that transitioned from `Running` to a terminal state during
/// this tick. The Lua runtime resumes coroutines parked on these.
pub settled: Vec<JobId>,
/// Successful resource mutations, **in bus-arrival order. This is
/// not filesystem execution order.**
///
/// [`AsyncRuntime::tick`] drains the reply bus with `try_recv` and
/// the runtime establishes no execution token, so a worker can
/// complete, be descheduled before sending, and have a later
/// mutation's reply arrive first. A consumer that reads "in settle
/// order" and infers causality is wrong; reconciliation is
/// deliberately order-independent (Q#DR29), and the primitive's
/// contract is that a caller with overlapping source/target paths
/// serializes by awaiting each op before dispatching the next.
///
/// Carries **only** jobs that settled
/// [`PendingState::Complete`] — a failed or cancelled mutation
/// reconciles nothing and fires no hook.
pub resources: Vec<ResourceOp>,
}
/// Snapshot of a job's terminal state, returned by
/// [`AsyncRuntime::take_result`] when a coroutine awaits it. Mirrors
/// [`PendingState`] but without the `Running` variant --- only
/// settled states are takeable.
#[derive(Clone, Debug)]
pub enum JobOutcome {
/// The worker completed successfully.
Complete(JobResult),
/// The worker observed cancellation.
Cancelled,
/// The worker raised.
Failed(String),
}
/// One row in the `*workers*` buffer's "active" section: a job that
/// the runtime has not yet observed settling. T M3.7.
#[derive(Clone, Debug)]
pub struct ActiveJobInfo {
/// Lua-visible job id.
pub id: JobId,
/// Which built-in handler this job runs.
pub kind: JobKind,
/// Wall-clock age in milliseconds since dispatch.
pub age_ms: u64,
/// Supersede key (if any) the job was dispatched under.
pub supersede_key: Option<String>,
/// Whether the job's cancel token has been flipped --- a
/// "cancellation pending" signal that the worker may not yet
/// have observed.
pub cancel_requested: bool,
/// True if this is a streaming dispatch (`emit_n`, `grep`, ...);
/// false if it's request/reply (`sleep`, `compute_sum`).
pub is_stream: bool,
}
/// One row in the `*workers*` buffer's "completed" section: a job
/// the runtime saw settle in the recent past. Bounded by the
/// runtime's completion ring (default capacity 32). T M3.7.
#[derive(Clone, Debug)]
pub struct CompletedJobInfo {
/// Lua-visible job id.
pub id: JobId,
/// Which built-in handler this job ran.
pub kind: JobKind,
/// How long the job ran from dispatch to settle.
pub duration_ms: u64,
/// How long ago the job settled (vs the snapshot moment).
pub settled_age_ms: u64,
/// Supersede key (if any) the job was dispatched under.
pub supersede_key: Option<String>,
/// Terminal outcome. `None` is unreachable here --- only
/// settled jobs land in the completed ring.
pub outcome: JobOutcome,
}
/// Snapshot returned by [`AsyncRuntime::workers_snapshot`]: the
/// active job table plus the recent-completions ring. The lists
/// are independent at any moment in time --- a job is in exactly
/// one of them.
///
/// Snapshots are point-in-time copies; the runtime's internal
/// state may change immediately after the snapshot is read. T M3.7
/// acceptance: "buffer updates within 100 ms of pool state
/// changes" is satisfied by re-snapshotting in the editor's tick
/// loop, which fires at frame cadence (16 ms by default).
#[derive(Clone, Debug)]
pub struct WorkersSnapshot {
/// Currently in-flight or settled-but-not-yet-taken jobs.
pub active: Vec<ActiveJobInfo>,
/// Recent settles, newest-first. Bounded by
/// [`COMPLETED_RING_CAP`].
pub completed: Vec<CompletedJobInfo>,
}
/// Capacity of the completed-jobs ring. Older entries fall off
/// the back. 64 is enough headroom for a busy editor's recent
/// history without bloating the snapshot.
pub const COMPLETED_RING_CAP: usize = 64;
#[derive(Clone, Debug)]
struct CompletedSlot {
id: JobId,
kind: JobKind,
dispatched_at: Instant,
settled_at: Instant,
supersede_key: Option<String>,
outcome: JobOutcome,
}
/// One frame's worth of streamed items for a single stream id,
/// returned by [`AsyncRuntime::take_stream_batches`]. T M3.5.
#[derive(Clone, Debug)]
pub struct StreamBatch {
/// The stream's job id.
pub id: JobId,
/// Items accumulated since the previous drain. Bounded by the
/// stream's `max_batch`. Each variant carries a payload whose
/// shape depends on the handler kind (numeric for `emit_n`,
/// [`GrepMatch`] for `grep`, ...).
pub items: Vec<StreamPayload>,
/// True iff the stream has settled (either closed cleanly or
/// cancelled / errored). When `closed` is true, the runtime has
/// already evicted the pending entry; this is the last batch
/// the consumer will see for `id`.
pub closed: bool,
/// Outcome carried alongside `closed`. `None` while the stream
/// is still running.
pub outcome: Option<JobOutcome>,
}
/// Main-thread async runtime. Use [`SharedAsyncRuntime`] for the
/// reference-counted handle that the Lua bindings and the editor's
/// run loop both hold.
///
/// Not [`Send`] --- the [`RefCell`] inside is the wrong primitive for
/// cross-thread sharing, and the runtime is always co-located with
/// the Lua VM on the main thread anyway.
pub struct AsyncRuntime {
pool: WorkerPool,
main: BusEnd,
workers: BusEnd,
next_job_id: AtomicU64,
pending: RefCell<HashMap<JobId, PendingJob>>,
/// Supersede key → currently-active job id. A new dispatch under
/// the same key cancels the prior id's token *synchronously* and
/// overwrites the entry. The map is pruned by `tick` when a
/// settled job's key still points back at it --- a later
/// dispatch overwrites the entry first, so the still-pending
/// successor is never accidentally removed. T M3.4.
supersede: RefCell<HashMap<String, JobId>>,
/// Default cap on items delivered per
/// [`Self::take_stream_batches`] call. Streams may override per
/// dispatch. T M3.5 acceptance: "tunable batch size".
default_max_batch: Cell<usize>,
/// Frame target the editor's run loop reads to size its
/// `poll_event` timeout. Workers emitting many items per second
/// are coalesced into one main-thread wake-and-drain per frame.
/// T M3.5 acceptance: "tunable frame target".
frame_target_ms: Cell<u64>,
/// Recent completions, newest-first. Settled jobs are pushed to
/// the front by `tick` and the back is trimmed to
/// [`COMPLETED_RING_CAP`]. The `*workers*` buffer reads this
/// for its history pane. T M3.7.
completed: RefCell<VecDeque<CompletedSlot>>,
/// Side handoff for parse jobs: a fresh
/// [`ParseTreeBundle`] cannot ride the [`MessagePack`] bus
/// (`tree_sitter::Tree` is not [`Serialize`]), so the worker
/// thread parks the bundle here under the job id and sends a
/// payload-less [`ReplyKind::Parse`] over the bus. Main thread
/// drains the bundle via [`Self::take_parse_tree`]. The mutex is
/// 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>>>>,
}
/// Default cap on stream items delivered in a single drain. 1024
/// items is a conservative default --- big enough that 60 Hz
/// delivery handles ~60K items/sec without piling up, small enough
/// that one batch fits comfortably in cache.
pub const DEFAULT_MAX_BATCH: usize = 1024;
/// Default frame target. 16 ms ≈ 60 Hz, the canonical "snappy
/// editor" cadence; can be raised to 33 ms (30 Hz) if the editor
/// is bottlenecked elsewhere.
pub const DEFAULT_FRAME_TARGET_MS: u64 = 16;
/// Shared, single-threaded handle to the runtime. Same pattern as
/// the other `Rc<...>` aliases in [`crate::lua_bindings`]: cheaply
/// cloneable, captured by closures that bridge into Lua.
pub type SharedAsyncRuntime = Rc<AsyncRuntime>;
impl AsyncRuntime {
/// Build a runtime with `pool_size` worker threads. Sized to
/// `available_parallelism - 1` (floor `1`) by default --- callers
/// that want a specific size pass it directly.
#[must_use]
pub fn with_pool(pool: WorkerPool) -> Self {
let schema = Arc::new(SchemaRegistry::new());
schema
.register::<WorkerReply>(ASYNC_REPLY_TOPIC)
.expect("register async.reply");
let (main, workers) = MessageBus::pair(schema);
Self {
pool,
main,
workers,
next_job_id: AtomicU64::new(0),
pending: RefCell::new(HashMap::new()),
supersede: RefCell::new(HashMap::new()),
default_max_batch: Cell::new(DEFAULT_MAX_BATCH),
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())),
}
}
/// Build a runtime sized at `available_parallelism - 1` worker
/// threads, matching the spec's "main thread is the event loop"
/// pattern ([spec §6.1]).
#[must_use]
pub fn with_default_pool() -> Self {
Self::with_pool(WorkerPool::with_default_size())
}
/// Build a runtime with exactly `size` worker threads, used
/// from tests that need predictable concurrency.
#[must_use]
pub fn with_pool_size(size: usize) -> Self {
Self::with_pool(WorkerPool::new(size))
}
/// Signal the worker-pool threads to exit (see
/// [`crate::worker::WorkerPool::signal_shutdown`]). Idempotent,
/// non-blocking. Called from [`crate::editor::EditorState`]'s
/// `Drop`: the runtime's `Rc` is captured into Lua-VM reference
/// cycles and never reaches a zero refcount, so without this
/// explicit teardown every editor instance leaks its whole pool
/// --- one leaked pool per test in a suite that builds real
/// editors. Signal-only (no join): a worker blocked handing its
/// reply to the main thread must not deadlock the main thread's
/// drop.
pub fn shutdown_workers(&self) {
self.pool.signal_shutdown();
}
/// Returns the number of in-flight or settled-but-not-yet-taken
/// pending entries.
#[must_use]
pub fn pending_len(&self) -> usize {
self.pending.borrow().len()
}
/// Number of supersede keys currently bound to a live job. Test
/// helper for verifying the table prunes itself on settle.
#[must_use]
pub fn supersede_len(&self) -> usize {
self.supersede.borrow().len()
}
/// The job currently active under `key`, if any. Test helper
/// (and a future read surface for `describe-async`).
#[must_use]
pub fn active_for_key(&self, key: &str) -> Option<JobId> {
self.supersede.borrow().get(key).copied()
}
/// Editor frame target in milliseconds. The run loop reads this
/// to size its `poll_event` timeout, which is what bounds
/// streaming-worker wakeups to one per frame.
#[must_use]
pub fn frame_target_ms(&self) -> u64 {
self.frame_target_ms.get()
}
/// Set the editor frame target. Bounded to `[1, 1000]` --- a
/// frame target above one second would render the editor
/// unresponsive; a target below 1 ms would burn CPU on no-op
/// poll cycles.
pub fn set_frame_target_ms(&self, ms: u64) {
self.frame_target_ms.set(ms.clamp(1, 1000));
}
/// Default per-stream batch size used when a dispatch does not
/// override it.
#[must_use]
pub fn default_max_batch(&self) -> usize {
self.default_max_batch.get()
}
/// Override the runtime-wide default batch size. Bounded to
/// `[1, 1_000_000]` so a typo doesn't accidentally disable
/// coalescing or blow memory.
pub fn set_default_max_batch(&self, n: usize) {
self.default_max_batch.set(n.clamp(1, 1_000_000));
}
/// 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
/// 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) {
let id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
let cancel = CancellationToken::new();
if let Some(key) = supersede_key {
// Flip the prior token *before* we publish the new id, so
// a worker observing key→old still sees an in-flight
// predecessor's cancel signal. Holding only one borrow at
// a time keeps RefCell happy across re-entrant pending
// borrows.
let prior_id = self.supersede.borrow().get(key).copied();
if let Some(prior) = prior_id
&& let Some(job) = self.pending.borrow().get(&prior)
{
job.cancel.cancel();
}
self.supersede.borrow_mut().insert(key.to_owned(), id);
}
self.pending.borrow_mut().insert(
id,
PendingJob {
cancel: cancel.clone(),
state: PendingState::Running,
supersede_key: supersede_key.map(str::to_owned),
stream_buffer: stream.map(|_| Vec::new()),
max_batch: stream.unwrap_or(0),
kind,
dispatched_at: Instant::now(),
resource,
},
);
(id, cancel)
}
/// Dispatch a `sleep(ms)` job. The job sleeps in 1ms slices,
/// polling its cancel token between each slice. Returns the
/// runtime-assigned [`JobId`].
///
/// If `supersede` is `Some(key)`, any in-flight job previously
/// 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 bus = self.workers.clone();
let total = Duration::from_millis(ms.max(0).unsigned_abs());
self.pool.dispatch(move |_pool| {
let kind = run_sleep(&cancel, total);
let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind });
});
id
}
/// Dispatch a `sum(n)` job. The job sums `1..=n` while polling
/// cancel; small `n` settle in microseconds, large `n` exercises
/// 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 bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_compute_sum(&cancel, n);
let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind });
});
id
}
/// Dispatch a streaming handler that emits `count` items as fast
/// as the worker can push them onto the bus. Each item is the
/// 1-based index of the emission, terminated by a
/// [`ReplyKind::StreamClosed`] reply when the loop completes
/// (or by [`ReplyKind::Cancelled`] on cancel). T M3.5
/// acceptance: "10000 messages/sec produces at most one wakeup
/// per frame" --- this is the synthetic load that proves it.
///
/// `max_batch` overrides the runtime's default cap on items per
/// drain; pass `None` to use [`Self::default_max_batch`].
pub fn dispatch_emit_n(
&self,
count: u64,
supersede: Option<&str>,
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 bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
run_emit_n(&cancel, &bus, id, count);
});
id
}
/// Dispatch a parallel directory grep. The handler walks
/// `spec.root`, fans file searches out across `spec.fanout`
/// internal scoped threads, and emits one
/// [`StreamPayload::Match`] per match. Terminates with
/// [`ReplyKind::StreamClosed`] (clean) or
/// [`ReplyKind::Cancelled`] (token observed).
///
/// `max_batch` overrides the runtime default for this stream's
/// per-frame coalescing cap.
///
/// T M3.6: this is the milestone-justifying load --- "Lua code
/// can do expensive things without freezing the editor". All
/// arguments cross into the worker by value ([R31]).
pub fn dispatch_grep(
&self,
spec: GrepSpec,
supersede: Option<&str>,
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 bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
run_grep(&cancel, &bus, id, spec);
});
id
}
/// Dispatch a tree-sitter parse on a worker. Returns the
/// runtime-assigned [`JobId`].
///
/// On success, the worker stashes the produced
/// [`ParseTreeBundle`] in [`Self::parse_handoff`] under the same
/// id and emits [`ReplyKind::Parse`] over the bus. The main
/// thread's `tick` transitions the pending entry to
/// [`JobResult::Parse`]; callers fetch the bundle by calling
/// [`Self::take_parse_tree`] (typically from Lua's parse-handle
/// glue right after `take_result`).
///
/// On cancellation, no bundle is stashed --- the worker observes
/// the flipped token before parsing and returns
/// [`ReplyKind::Cancelled`].
///
/// `supersede` follows the same rule as the other dispatchers:
/// 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 bus = self.workers.clone();
let handoff = self.parse_handoff.clone();
self.pool.dispatch(move |_pool| {
run_parse(&cancel, &bus, &handoff, id, spec);
});
id
}
/// Dispatch a `read_dir(path)` job. The worker enumerates
/// `path`, returning one [`FsDirEntry`] per child with
/// `lstat`-style metadata. Polls cancel every batch of
/// entries; supersede follows the same rule as the other
/// dispatchers. T M8.1.
///
/// `tolerance` selects the per-entry contract (dired Q#DR6):
/// [`ReadDirTolerance::Fatal`] is the original all-or-nothing
/// listing, [`ReadDirTolerance::PerEntry`] carries per-entry
/// failures alongside the entries that survived.
pub fn dispatch_fs_read_dir(
&self,
path: PathBuf,
tolerance: ReadDirTolerance,
supersede: Option<&str>,
) -> JobId {
let (id, cancel) = self.allocate(JobKind::FsReadDir, supersede, None);
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_fs_read_dir(&cancel, &path, tolerance);
let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind });
});
id
}
/// 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 bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_fs_stat(&cancel, &path);
let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind });
});
id
}
/// Dispatch a `rename(from, to)` job. Settles to
/// [`JobResult::Unit`] on success. T M8.1.
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,
supersede,
None,
Some(ResourceOp::Rename {
from: from.clone(),
to: to.clone(),
}),
);
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_fs_rename(&cancel, &from, &to);
let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind });
});
id
}
/// 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 bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_fs_chmod(&cancel, &path, mode);
let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind });
});
id
}
/// 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,
supersede,
None,
Some(ResourceOp::Remove { path: path.clone() }),
);
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_fs_remove(&cancel, &path);
let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind });
});
id
}
/// Register a pending entry that will be settled from outside the
/// worker pool. Returns `(JobId, CancellationToken)`. The caller
/// is responsible for eventually calling
/// [`Self::complete_external_ok`], [`Self::complete_external_failed`],
/// or [`Self::complete_external_cancelled`] on the returned id;
/// the cancellation token is what `pmacs.workers._cancel(id)`
/// flips, and the caller should poll it (e.g. inside its tick)
/// to give up on outstanding requests when the user cancels.
///
/// T M9.1: this is the entry point the MCP layer uses to bind
/// JSON-RPC request ids to async-runtime job ids without
/// occupying a worker thread for the synchronous-write +
/// pipe-response round-trip. Future protocols that ride on the
/// same supervisor (DAP, etc.) reuse this surface.
///
/// `supersede` follows the same rule as the worker dispatchers.
pub fn register_external(
&self,
kind: JobKind,
supersede: Option<&str>,
) -> (JobId, CancellationToken) {
self.allocate(kind, supersede, None)
}
/// Settle an externally-registered job with a JSON value. Wakes
/// any coroutine parked on the corresponding [`Handle:await()`]
/// on the next [`Self::tick`].
///
/// Idempotent against double-completion: the second call is a
/// no-op (the entry has already settled). T M9.1.
pub fn complete_external_ok(&self, id: JobId, value: serde_json::Value) {
let _ = self.workers.send(
ASYNC_REPLY_TOPIC,
&WorkerReply {
job_id: id,
kind: ReplyKind::Json(value),
},
);
}
/// Settle an externally-registered job with a failure message.
/// T M9.1.
pub fn complete_external_failed(&self, id: JobId, message: impl Into<String>) {
let _ = self.workers.send(
ASYNC_REPLY_TOPIC,
&WorkerReply {
job_id: id,
kind: ReplyKind::Error(message.into()),
},
);
}
/// Settle an externally-registered job as cancelled. Used when
/// the underlying request was abandoned without a response (e.g.
/// the MCP server crashed mid-flight). T M9.1.
pub fn complete_external_cancelled(&self, id: JobId) {
let _ = self.workers.send(
ASYNC_REPLY_TOPIC,
&WorkerReply {
job_id: id,
kind: ReplyKind::Cancelled,
},
);
}
/// Drain the parse-tree bundle for `id` from the side handoff.
/// Returns `None` if the job is unknown, still running, didn't
/// produce a tree (cancelled or failed), or has already been
/// taken. T M4.1.
pub fn take_parse_tree(&self, id: JobId) -> Option<Arc<ParseTreeBundle>> {
self.parse_handoff
.lock()
.expect("parse_handoff mutex poisoned")
.remove(&id)
}
/// Number of parked parse-tree bundles waiting to be drained
/// from the handoff. Test helper for verifying the handoff
/// doesn't leak entries across long sessions.
#[must_use]
pub fn parse_handoff_len(&self) -> usize {
self.parse_handoff
.lock()
.expect("parse_handoff mutex poisoned")
.len()
}
/// Mark `id` cancelled. The worker closure observes this on its
/// next granular check and produces a `Cancelled` reply, which
/// `tick` then surfaces to Lua. No-op if `id` is unknown.
pub fn cancel(&self, id: JobId) {
if let Some(job) = self.pending.borrow().get(&id) {
job.cancel.cancel();
}
}
/// Drain every queued reply on the main-thread bus, update pending
/// entries, and report what settled.
///
/// [`TickOutcome::settled`] is the ids that transitioned from
/// `Running` to a terminal state during this tick — the Lua runtime
/// resumes coroutines parked on these.
/// [`TickOutcome::resources`] is the filesystem mutations among them
/// that **succeeded**, in **bus-arrival order** (see the field's
/// own documentation: that is not execution order).
pub fn tick(&self) -> TickOutcome {
let mut newly_settled = Vec::new();
while let Ok(env) = self.main.try_recv() {
let Ok(reply): Result<WorkerReply, _> = self.main.decode(&env) else {
// A malformed reply can only come from a broken
// handler; surface it via *errors* in the future,
// but for now skip.
continue;
};
let mut pending = self.pending.borrow_mut();
let Some(job) = pending.get_mut(&reply.job_id) else {
continue;
};
match reply.kind {
// Streaming items accumulate in the per-job buffer;
// they are *not* a settle event. The buffer is
// drained by `take_stream_batches`. T M3.5.
ReplyKind::StreamItem(v) => {
if let Some(buf) = job.stream_buffer.as_mut() {
buf.push(v);
}
// Else: stream item targeting a non-stream job
// is a handler bug; drop it silently rather
// than poisoning the request/reply channel.
}
ReplyKind::StreamClosed => {
if matches!(job.state, PendingState::Running) {
job.state = PendingState::Complete(JobResult::Unit);
newly_settled.push(reply.job_id);
}
}
ReplyKind::Sleep
| ReplyKind::Sum(_)
| ReplyKind::Parse { .. }
| ReplyKind::ReadDir(_)
| ReplyKind::Stat(_)
| ReplyKind::FsUnit
| ReplyKind::Json(_)
| ReplyKind::Cancelled
| ReplyKind::Error(_)
if matches!(job.state, PendingState::Running) =>
{
job.state = match reply.kind {
ReplyKind::Sleep | ReplyKind::FsUnit => {
PendingState::Complete(JobResult::Unit)
}
ReplyKind::Sum(v) => PendingState::Complete(JobResult::Sum(v)),
ReplyKind::Parse { duration_ms } => {
PendingState::Complete(JobResult::Parse { duration_ms })
}
ReplyKind::ReadDir(listing) => {
PendingState::Complete(JobResult::ReadDir(listing))
}
ReplyKind::Stat(entry) => PendingState::Complete(JobResult::Stat(entry)),
ReplyKind::Json(v) => PendingState::Complete(JobResult::Json(v)),
ReplyKind::Cancelled => PendingState::Cancelled,
ReplyKind::Error(msg) => PendingState::Failed(msg),
_ => unreachable!("matched above"),
};
newly_settled.push(reply.job_id);
}
// Already-settled job receiving a duplicate reply is a
// no-op; ignore.
_ => {}
}
}
// Prune supersede entries whose owning job just settled. We
// only remove entries that *still* point at the settled id;
// a successor that came in mid-flight will have overwritten
// the entry already, and that successor's pending lifetime
// is what owns the slot now.
let mut resources = Vec::new();
if !newly_settled.is_empty() {
let pending = self.pending.borrow();
let mut sup = self.supersede.borrow_mut();
let mut completed = self.completed.borrow_mut();
let now = Instant::now();
for id in &newly_settled {
if let Some(job) = pending.get(id) {
// The harvest (§5): one more read in a loop that
// already borrows `pending` and reads `job.kind`,
// so settle identity and resource metadata come out
// of one transaction. Gated on `Complete` — a
// failed or cancelled mutation reconciles nothing.
if let Some(resource) = &job.resource
&& matches!(job.state, PendingState::Complete(_))
{
resources.push(resource.clone());
}
if let Some(key) = &job.supersede_key
&& sup.get(key) == Some(id)
{
sup.remove(key);
}
// T M3.7: record the settle in the completion
// ring. We push the front and trim the back so
// the newest completions are always at index 0.
let outcome = match &job.state {
PendingState::Complete(r) => JobOutcome::Complete(r.clone()),
PendingState::Cancelled => JobOutcome::Cancelled,
PendingState::Failed(msg) => JobOutcome::Failed(msg.clone()),
PendingState::Running => continue,
};
completed.push_front(CompletedSlot {
id: *id,
kind: job.kind,
dispatched_at: job.dispatched_at,
settled_at: now,
supersede_key: job.supersede_key.clone(),
outcome,
});
}
}
while completed.len() > COMPLETED_RING_CAP {
completed.pop_back();
}
}
TickOutcome {
settled: newly_settled,
resources,
}
}
/// Snapshot the runtime's job tables for the `*workers*`
/// observability buffer ([T M3.7]). Returns active jobs and
/// recent completions in two parallel lists. Newest completions
/// come first.
///
/// This is the read surface the buffer renderer uses; it does
/// *not* mutate any state. Calling it from a hot path is fine
/// --- the cost is one `HashMap::iter()` and one `VecDeque`
/// clone, both O(N) in entries.
#[must_use]
pub fn workers_snapshot(&self) -> WorkersSnapshot {
let now = Instant::now();
let pending = self.pending.borrow();
let mut active: Vec<ActiveJobInfo> = pending
.iter()
.filter(|(_, j)| matches!(j.state, PendingState::Running))
.map(|(id, j)| ActiveJobInfo {
id: *id,
kind: j.kind,
age_ms: now.saturating_duration_since(j.dispatched_at).as_millis() as u64,
supersede_key: j.supersede_key.clone(),
cancel_requested: j.cancel.is_cancelled(),
is_stream: j.stream_buffer.is_some(),
})
.collect();
// Stable order: oldest first. The buffer renderer renders in
// the order returned, and "oldest job at top" is what users
// expect from a process-list view.
active.sort_by_key(|a| (a.age_ms, a.id));
active.reverse(); // age_ms descending = oldest first
let completed = self.completed.borrow();
let completed: Vec<CompletedJobInfo> = completed
.iter()
.map(|c| CompletedJobInfo {
id: c.id,
kind: c.kind,
duration_ms: c
.settled_at
.saturating_duration_since(c.dispatched_at)
.as_millis() as u64,
settled_age_ms: now.saturating_duration_since(c.settled_at).as_millis() as u64,
supersede_key: c.supersede_key.clone(),
outcome: c.outcome.clone(),
})
.collect();
WorkersSnapshot { active, completed }
}
/// 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.
///
/// A batch with `closed = true` carries the stream's terminal
/// outcome; the runtime evicts the corresponding pending entry
/// after delivering it, so subsequent `take_stream_batches`
/// calls will not see this id again.
///
/// T M3.5 acceptance: this is the coalescing primitive --- a
/// run loop that calls `take_stream_batches` once per frame
/// receives at most one batch per stream per frame, regardless
/// of how many items the worker emitted in between.
pub fn take_stream_batches(&self) -> Vec<StreamBatch> {
let mut out = Vec::new();
let mut pending = self.pending.borrow_mut();
let mut to_evict = Vec::new();
// Iterate ids first (immutable view) to avoid mutable+
// immutable borrow overlap. HashMap iteration order is
// arbitrary; consumers must not rely on it.
let stream_ids: Vec<JobId> = pending
.iter()
.filter(|(_, j)| j.stream_buffer.is_some())
.map(|(id, _)| *id)
.collect();
for id in stream_ids {
let Some(job) = pending.get_mut(&id) else {
continue;
};
let Some(buf) = job.stream_buffer.as_mut() else {
continue;
};
let cap = job.max_batch.max(1);
let take = buf.len().min(cap);
// A stream with no pending items and still running
// contributes nothing; skip.
let settled = !matches!(job.state, PendingState::Running);
if take == 0 && !settled {
continue;
}
let drained: Vec<StreamPayload> = buf.drain(..take).collect();
// Closed iff the stream is settled *and* the buffer is
// now empty (no more frames to deliver).
let closed = settled && buf.is_empty();
let outcome = if closed {
Some(match &job.state {
PendingState::Complete(r) => JobOutcome::Complete(r.clone()),
PendingState::Cancelled => JobOutcome::Cancelled,
PendingState::Failed(msg) => JobOutcome::Failed(msg.clone()),
PendingState::Running => unreachable!("settled checked above"),
})
} else {
None
};
out.push(StreamBatch {
id,
items: drained,
closed,
outcome,
});
if closed {
to_evict.push(id);
}
}
for id in to_evict {
pending.remove(&id);
}
out
}
/// Has `id` settled into a terminal state?
#[must_use]
pub fn is_complete(&self, id: JobId) -> bool {
self.pending
.borrow()
.get(&id)
.is_some_and(|j| !matches!(j.state, PendingState::Running))
}
/// Was `id` cancelled? Returns true only after `tick` has
/// observed the worker's `Cancelled` reply --- not at the moment
/// `cancel` was called.
#[must_use]
pub fn is_cancelled(&self, id: JobId) -> bool {
matches!(
self.pending.borrow().get(&id).map(|j| &j.state),
Some(PendingState::Cancelled)
)
}
/// Take the terminal outcome for `id`, removing the entry from
/// the pending table. Returns `None` if `id` is unknown or still
/// running. Lua's `Handle:await()` calls this once per handle.
///
/// Side effect: any leftover parse-tree handoff entry under `id`
/// is also dropped here. Callers that want the bundle must call
/// [`Self::take_parse_tree`] *before* `take_result` --- otherwise
/// the bundle is GC'd alongside the pending entry. This bounds
/// the handoff's worst-case footprint to "settled-but-not-yet-
/// taken parse jobs" rather than allowing forgotten bundles to
/// pile up indefinitely. T M4.1. (`take_result` here is the
/// method itself.)
pub fn take_result(&self, id: JobId) -> Option<JobOutcome> {
let mut pending = self.pending.borrow_mut();
let outcome = match pending.get(&id)?.state {
PendingState::Running => return None,
_ => match pending.remove(&id)?.state {
PendingState::Complete(r) => JobOutcome::Complete(r),
PendingState::Cancelled => JobOutcome::Cancelled,
PendingState::Failed(msg) => JobOutcome::Failed(msg),
PendingState::Running => unreachable!("checked above"),
},
};
// Drop stale handoff slot if the caller didn't explicitly
// take it. No-op when the job wasn't a parse, and idempotent
// if `take_parse_tree` was called first.
let _ = self
.parse_handoff
.lock()
.expect("parse_handoff mutex poisoned")
.remove(&id);
Some(outcome)
}
}
// ---------------------------------------------------------------------------
// Built-in worker bodies
// ---------------------------------------------------------------------------
fn run_sleep(cancel: &CancellationToken, total: Duration) -> ReplyKind {
let step = Duration::from_millis(1);
let start = Instant::now();
while start.elapsed() < total {
if cancel.is_cancelled() {
return ReplyKind::Cancelled;
}
thread::sleep(step);
}
if cancel.is_cancelled() {
return ReplyKind::Cancelled;
}
ReplyKind::Sleep
}
/// Worker body for [`AsyncRuntime::dispatch_fs_read_dir`].
/// Translates [`crate::fs::read_dir_blocking`]'s
/// [`FsError`] taxonomy into the bus reply enum:
/// [`FsError::Cancelled`] becomes [`ReplyKind::Cancelled`];
/// [`FsError::Io`] becomes [`ReplyKind::Error`] with the
/// human-readable message attached.
fn run_fs_read_dir(
cancel: &CancellationToken,
path: &Path,
tolerance: ReadDirTolerance,
) -> ReplyKind {
match read_dir_blocking(path, cancel, tolerance) {
Ok(listing) => ReplyKind::ReadDir(listing),
Err(FsError::Cancelled) => ReplyKind::Cancelled,
Err(e @ (FsError::Io { .. } | FsError::NonUtf8Path { .. })) => {
ReplyKind::Error(e.to_string())
}
}
}
fn run_fs_stat(cancel: &CancellationToken, path: &Path) -> ReplyKind {
match stat_blocking(path, cancel) {
Ok(entry) => ReplyKind::Stat(entry),
Err(FsError::Cancelled) => ReplyKind::Cancelled,
Err(e @ (FsError::Io { .. } | FsError::NonUtf8Path { .. })) => {
ReplyKind::Error(e.to_string())
}
}
}
fn run_fs_rename(cancel: &CancellationToken, from: &Path, to: &Path) -> ReplyKind {
fs_unit_to_reply(rename_blocking(from, to, cancel))
}
fn run_fs_chmod(cancel: &CancellationToken, path: &Path, mode: u32) -> ReplyKind {
fs_unit_to_reply(chmod_blocking(path, mode, cancel))
}
fn run_fs_remove(cancel: &CancellationToken, path: &Path) -> ReplyKind {
fs_unit_to_reply(remove_blocking(path, cancel))
}
/// Shared error-mapping for the unit-result fs primitives. Keeps
/// the rename/chmod/remove worker bodies one-liners so the table
/// of dispatchers reads at a glance.
fn fs_unit_to_reply(result: Result<(), FsError>) -> ReplyKind {
match result {
Ok(()) => ReplyKind::FsUnit,
Err(FsError::Cancelled) => ReplyKind::Cancelled,
Err(e @ (FsError::Io { .. } | FsError::NonUtf8Path { .. })) => {
ReplyKind::Error(e.to_string())
}
}
}
fn run_compute_sum(cancel: &CancellationToken, n: u64) -> ReplyKind {
let mut acc: u64 = 0;
// Granular: poll cancel every 1024 iterations to balance
// responsiveness against polling overhead.
let mut counter: u64 = 0;
let mut i: u64 = 1;
while i <= n {
counter = counter.wrapping_add(1);
if counter.trailing_zeros() >= 10 && cancel.is_cancelled() {
return ReplyKind::Cancelled;
}
acc = acc.wrapping_add(i);
i += 1;
}
if cancel.is_cancelled() {
return ReplyKind::Cancelled;
}
ReplyKind::Sum(acc)
}
/// Streaming handler used by [`AsyncRuntime::dispatch_emit_n`].
/// Pushes `count` `StreamItem` envelopes onto the bus as fast as
/// the worker can send them, terminated by either `StreamClosed`
/// (clean completion) or `Cancelled` (token observed flipped).
/// Polls cancel every iteration --- this is the load that proves
/// frame-boundary coalescing on the consumer side.
fn run_emit_n(cancel: &CancellationToken, bus: &BusEnd, id: JobId, count: u64) {
for i in 1..=count {
if cancel.is_cancelled() {
let _ = bus.send(
ASYNC_REPLY_TOPIC,
&WorkerReply {
job_id: id,
kind: ReplyKind::Cancelled,
},
);
return;
}
let _ = bus.send(
ASYNC_REPLY_TOPIC,
&WorkerReply {
job_id: id,
kind: ReplyKind::StreamItem(StreamPayload::U64(i)),
},
);
}
let _ = bus.send(
ASYNC_REPLY_TOPIC,
&WorkerReply {
job_id: id,
kind: ReplyKind::StreamClosed,
},
);
}
/// Streaming handler used by [`AsyncRuntime::dispatch_grep`]. Walks
/// `spec.root` and fans file searches out across `spec.fanout`
/// scoped threads. Each thread pulls file paths from a shared
/// channel, reads the file, scans it for `spec.pattern`, and
/// emits one [`StreamPayload::Match`] envelope per match. The walk
/// runs on the dispatching thread; the workers consume in
/// parallel.
///
/// Cancellation: every worker checks `cancel` between files, and
/// the walker checks it between directory entries. A flipped token
/// produces a single [`ReplyKind::Cancelled`] reply --- regardless
/// of how many workers were in flight.
///
/// R31: every value crossed into a worker (the file path, the
/// pattern, the bus end) is owned. No buffer references are held.
pub fn run_grep(cancel: &CancellationToken, bus: &BusEnd, id: JobId, spec: GrepSpec) {
let GrepSpec {
root,
pattern,
case_sensitive,
max_file_bytes,
max_match_text,
max_results,
fanout,
} = spec;
if pattern.is_empty() || cancel.is_cancelled() {
let kind = if cancel.is_cancelled() {
ReplyKind::Cancelled
} else {
ReplyKind::StreamClosed
};
let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind });
return;
}
// Bounded channel for backpressure: walker stalls if workers
// fall behind, so a fast walk over many small files cannot
// outpace the search and balloon memory.
let fanout = fanout.max(1);
let (tx, rx) = cb_channel::bounded::<PathBuf>(fanout * 16);
let results_emitted = Arc::new(AtomicU64::new(0));
// Ascii-fold the pattern once if case-insensitive; workers
// fold their reads on the fly.
let pattern_norm: Arc<Vec<u8>> = if case_sensitive {
Arc::new(pattern.into_bytes())
} else {
Arc::new(ascii_fold(pattern.as_bytes()))
};
thread::scope(|scope| {
let mut handles = Vec::with_capacity(fanout);
for _ in 0..fanout {
let rx = rx.clone();
let bus = bus.clone();
let cancel = cancel.clone();
let pattern_norm = Arc::clone(&pattern_norm);
let results_emitted = Arc::clone(&results_emitted);
let root = root.clone();
let h = scope.spawn(move || {
while let Ok(path) = rx.recv() {
if cancel.is_cancelled() {
break;
}
if max_results > 0
&& results_emitted.load(Ordering::Relaxed) >= u64::from(max_results)
{
break;
}
search_file(
&path,
&root,
&pattern_norm,
case_sensitive,
max_file_bytes,
max_match_text,
max_results,
&results_emitted,
&cancel,
&bus,
id,
);
}
});
handles.push(h);
}
// Drop our local sender so workers see disconnect once the
// walker finishes feeding paths.
drop(rx);
walk_dir(&root, &tx, cancel);
drop(tx);
for h in handles {
let _ = h.join();
}
});
let kind = if cancel.is_cancelled() {
ReplyKind::Cancelled
} else {
ReplyKind::StreamClosed
};
let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind });
}
/// Worker body for [`AsyncRuntime::dispatch_parse`]. Runs the
/// synchronous parse, parks the [`ParseTreeBundle`] in `handoff`
/// under `id`, and reports settle (or cancel/error) over the bus.
///
/// Cancellation is coarse: the token is checked once before the
/// parse runs. Mid-parse cancellation requires wiring tree-sitter's
/// `AtomicUsize` cancellation flag through the worker's
/// `CancellationToken` (an `AtomicBool`), which is M4.x territory ---
/// M4.1 parses are bounded (5000-line cold parse < 100 ms; edits
/// even faster), so coarse cancellation suffices for v0.1.
fn run_parse(
cancel: &CancellationToken,
bus: &BusEnd,
handoff: &Mutex<HashMap<JobId, Arc<ParseTreeBundle>>>,
id: JobId,
spec: ParseRequest,
) {
if cancel.is_cancelled() {
let _ = bus.send(
ASYNC_REPLY_TOPIC,
&WorkerReply {
job_id: id,
kind: ReplyKind::Cancelled,
},
);
return;
}
let kind = match syntax_mod::run_parse(spec) {
Ok(bundle) => {
let duration_ms = u64::try_from(bundle.parse_duration.as_millis()).unwrap_or(u64::MAX);
handoff
.lock()
.expect("parse_handoff mutex poisoned")
.insert(id, Arc::new(bundle));
ReplyKind::Parse { duration_ms }
}
Err(msg) => ReplyKind::Error(msg),
};
let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind });
}
/// ASCII-fold every byte to lowercase. Non-ASCII bytes pass
/// through unchanged --- a deliberate v0.1 limitation. Unicode
/// case-folding lands when we add the regex layer in M4.
fn ascii_fold(bytes: &[u8]) -> Vec<u8> {
bytes.iter().map(u8::to_ascii_lowercase).collect()
}
/// Recursive directory walker. Pushes paths of regular files into
/// `tx`. Skips hidden directories, common build / VCS roots, and
/// symlinks (cycle prevention). Aborts on cancel between entries.
fn walk_dir(root: &Path, tx: &cb_channel::Sender<PathBuf>, cancel: &CancellationToken) {
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
if cancel.is_cancelled() {
return;
}
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
if cancel.is_cancelled() {
return;
}
let path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
if let Some(name) = path.file_name().and_then(|n| n.to_str())
&& (name.starts_with('.')
|| matches!(
name,
"node_modules" | "target" | "build" | "dist" | "__pycache__"
))
{
continue;
}
stack.push(path);
} else if file_type.is_file() && tx.send(path).is_err() {
return;
}
}
}
}
/// Read one file and emit a `StreamItem(Match)` per matching line.
/// Skips files that are too large, that contain a NUL byte (binary
/// heuristic), or that aren't valid UTF-8.
#[allow(
clippy::too_many_arguments,
reason = "intentionally explicit per-call state to keep the closure flat; bundling into a struct adds ceremony without clarity"
)]
fn search_file(
path: &Path,
root: &Path,
pattern: &[u8],
case_sensitive: bool,
max_file_bytes: u64,
max_match_text: u32,
max_results: u32,
results_emitted: &AtomicU64,
cancel: &CancellationToken,
bus: &BusEnd,
id: JobId,
) {
let Ok(metadata) = path.metadata() else {
return;
};
if metadata.len() > max_file_bytes {
return;
}
let Ok(bytes) = std::fs::read(path) else {
return;
};
// Binary heuristic: a NUL byte in the first 8 KiB classes the
// file as binary and skips it.
let head = bytes.len().min(8 * 1024);
if bytes[..head].contains(&0u8) {
return;
}
let rel = path
.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.into_owned();
let mut line_no: u32 = 0;
for line_bytes in bytes.split(|&b| b == b'\n') {
line_no = line_no.saturating_add(1);
if cancel.is_cancelled() {
return;
}
if max_results > 0 && results_emitted.load(Ordering::Relaxed) >= u64::from(max_results) {
return;
}
// Strip a trailing CR for CRLF files. The match offsets
// reported are within the trimmed line text.
let trimmed = if let Some((&b'\r', rest)) = line_bytes.split_last() {
rest
} else {
line_bytes
};
let haystack = if case_sensitive {
trimmed.to_vec()
} else {
ascii_fold(trimmed)
};
let Some(pos) = find_subslice(&haystack, pattern) else {
continue;
};
// The user-visible text is the *original* (case-preserved)
// line bytes, not the folded haystack.
let Ok(text_str) = std::str::from_utf8(trimmed) else {
return; // non-UTF-8 file: skip remainder.
};
let mut text_owned = text_str.to_owned();
if u32::try_from(text_owned.len()).unwrap_or(u32::MAX) > max_match_text {
// Truncate to a UTF-8 boundary.
let cap = max_match_text as usize;
let mut end = cap;
while end > 0 && !text_owned.is_char_boundary(end) {
end -= 1;
}
text_owned.truncate(end);
}
let match_start = u32::try_from(pos).unwrap_or(u32::MAX);
let match_end = u32::try_from(pos + pattern.len()).unwrap_or(u32::MAX);
// Only emit if the match offsets are still valid in the
// (possibly truncated) text. Otherwise drop this match.
if (match_end as usize) > text_owned.len() {
continue;
}
let m = GrepMatch {
file: rel.clone(),
line: line_no,
match_start,
match_end,
text: text_owned,
};
let _ = bus.send(
ASYNC_REPLY_TOPIC,
&WorkerReply {
job_id: id,
kind: ReplyKind::StreamItem(StreamPayload::Match(m)),
},
);
results_emitted.fetch_add(1, Ordering::Relaxed);
}
}
/// Naive byte-substring search. v0.1 deliberately stays
/// dependency-free; if benchmarks show this is the bottleneck we
/// promote `memchr::memmem`.
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() {
return None;
}
if needle.len() > haystack.len() {
return None;
}
let last = haystack.len() - needle.len();
let first = needle[0];
let mut i = 0;
while i <= last {
if haystack[i] == first && &haystack[i..i + needle.len()] == needle {
return Some(i);
}
i += 1;
}
None
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn pump_until<F: Fn() -> bool>(rt: &AsyncRuntime, f: F) {
let deadline = Instant::now() + Duration::from_secs(2);
while !f() {
assert!(Instant::now() < deadline, "runtime tick deadline exceeded");
let _ = rt.tick();
thread::sleep(Duration::from_millis(1));
}
}
/// dired Stage 2a, acceptance 54 (controlled-bus layer). Allocate
/// two resource jobs **without dispatching workers**, inject their
/// successful replies in a chosen order, and assert
/// `TickOutcome.resources` reports exactly that order.
///
/// This is the honest statement of what the runtime guarantees:
/// `tick` drains the reply bus with `try_recv` and establishes no
/// execution token, so what a consumer sees is bus-arrival order.
/// The test fails against sorting by job id or kind, and against any
/// claim that the order recovers dispatch or filesystem-execution
/// order — because the injection order here is *deliberately* the
/// reverse of the allocation order in the first case.
#[test]
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 {
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 {
path: PathBuf::from("/tmp/b-gone"),
}),
);
let order = if reverse { [b, a] } else { [a, b] };
for id in order {
rt.workers
.send(
ASYNC_REPLY_TOPIC,
&WorkerReply {
job_id: id,
kind: ReplyKind::FsUnit,
},
)
.expect("inject reply");
}
let outcome = rt.tick();
assert_eq!(outcome.settled.len(), 2, "both jobs settled");
outcome.resources
}
let a_first = ResourceOp::Rename {
from: PathBuf::from("/tmp/a-from"),
to: PathBuf::from("/tmp/a-to"),
};
let b_first = ResourceOp::Remove {
path: PathBuf::from("/tmp/b-gone"),
};
assert_eq!(
run(true),
vec![b_first.clone(), a_first.clone()],
"B injected first must be reported first, even though A was \
allocated first"
);
assert_eq!(
run(false),
vec![a_first, b_first],
"and the reverse arrival order reverses the report"
);
}
/// A failed or cancelled mutation reconciles nothing, so it must not
/// appear in `resources` at all (acceptance 37's runtime half).
#[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 {
from: PathBuf::from("/tmp/nope"),
to: PathBuf::from("/tmp/also-nope"),
}),
);
let (cancelled, _) = rt.allocate_with_resource(
JobKind::FsRemove,
None,
None,
Some(ResourceOp::Remove {
path: PathBuf::from("/tmp/never"),
}),
);
rt.workers
.send(
ASYNC_REPLY_TOPIC,
&WorkerReply {
job_id: failed,
kind: ReplyKind::Error("ENOENT".to_owned()),
},
)
.expect("inject");
rt.workers
.send(
ASYNC_REPLY_TOPIC,
&WorkerReply {
job_id: cancelled,
kind: ReplyKind::Cancelled,
},
)
.expect("inject");
let outcome = rt.tick();
assert_eq!(outcome.settled.len(), 2, "both settled");
assert!(
outcome.resources.is_empty(),
"only Complete mutations are harvested; got {:?}",
outcome.resources
);
}
#[test]
fn dispatch_sum_completes_with_correct_value() {
let rt = AsyncRuntime::with_pool_size(2);
let id = rt.dispatch_compute_sum(10, None);
pump_until(&rt, || rt.is_complete(id));
match rt.take_result(id) {
Some(JobOutcome::Complete(JobResult::Sum(v))) => assert_eq!(v, 55),
other => panic!("unexpected outcome: {other:?}"),
}
}
#[test]
fn dispatch_sleep_completes_with_unit() {
let rt = AsyncRuntime::with_pool_size(1);
let id = rt.dispatch_sleep(5, None);
pump_until(&rt, || rt.is_complete(id));
match rt.take_result(id) {
Some(JobOutcome::Complete(JobResult::Unit)) => {}
other => panic!("unexpected outcome: {other:?}"),
}
}
#[test]
fn cancel_in_flight_sleep_yields_cancelled_outcome() {
let rt = AsyncRuntime::with_pool_size(1);
let id = rt.dispatch_sleep(2_000, None);
// Yield to ensure the worker has noticed and started sleeping.
thread::sleep(Duration::from_millis(20));
rt.cancel(id);
pump_until(&rt, || rt.is_complete(id));
assert!(rt.is_cancelled(id));
assert!(matches!(rt.take_result(id), Some(JobOutcome::Cancelled)));
}
#[test]
fn many_independent_jobs_complete_concurrently() {
let rt = AsyncRuntime::with_pool_size(4);
let mut ids = Vec::new();
for n in 1..=20u64 {
ids.push((n, rt.dispatch_compute_sum(n, None)));
}
pump_until(&rt, || ids.iter().all(|(_, id)| rt.is_complete(*id)));
for (n, id) in ids {
let expected = n * (n + 1) / 2;
match rt.take_result(id) {
Some(JobOutcome::Complete(JobResult::Sum(v))) => assert_eq!(v, expected),
other => panic!("job {id} (n={n}): {other:?}"),
}
}
assert_eq!(rt.pending_len(), 0);
}
#[test]
fn take_result_on_unknown_id_returns_none() {
let rt = AsyncRuntime::with_pool_size(1);
assert!(rt.take_result(99_999).is_none());
}
#[test]
fn take_result_while_running_returns_none() {
let rt = AsyncRuntime::with_pool_size(1);
let id = rt.dispatch_sleep(500, None);
// Don't tick --- the worker has not yet sent its reply
// (or if it has, it is queued unread). take_result requires
// the runtime to have settled the entry first.
assert!(rt.take_result(id).is_none() || rt.is_complete(id));
}
#[test]
fn cancel_unknown_id_is_noop() {
let rt = AsyncRuntime::with_pool_size(1);
rt.cancel(12_345); // no panic, no observable effect
}
// ---- T M3.4 supersede acceptance ----------------------------------------
/// Acceptance bullet 1: supersession cancels an in-flight job
/// within 50 ms. We dispatch a 2-second sleep, dispatch the same
/// key again, and assert the first settles into Cancelled within
/// 50 ms wall-clock.
#[test]
fn supersede_cancels_in_flight_job_within_50ms() {
let rt = AsyncRuntime::with_pool_size(1);
let first = rt.dispatch_sleep(2_000, Some("search"));
// Let the worker pick the job up so cancel hits a running job.
thread::sleep(Duration::from_millis(15));
let started = Instant::now();
let _second = rt.dispatch_sleep(2_000, Some("search"));
// Pump until the first settles. With 1ms cancel polling, the
// worker observes the flag in ~1-2 ms; the bus carries the
// reply on the next try_recv. 50 ms gives generous slack.
while !rt.is_complete(first) {
assert!(
started.elapsed() < Duration::from_millis(50),
"supersede did not cancel within 50ms"
);
let _ = rt.tick();
thread::sleep(Duration::from_millis(1));
}
assert!(rt.is_cancelled(first), "first job should be Cancelled");
}
/// Acceptance bullet 2: queued jobs with the same key are cancelled
/// before they run. Single-thread pool. We dispatch a "gate" job
/// first --- a 100ms sleep --- to occupy the worker. While the
/// worker is busy on that gate, we spam 100 dispatches under the
/// same key. They all pile up in the deque, and the supersede
/// rule means each new dispatch flips its predecessor's cancel
/// token. By the time the worker pops them off the queue, every
/// one of them sees `is_cancelled()` at entry and returns
/// `Cancelled` without running. The single surviving uncancelled
/// token is the *latest* dispatch.
///
/// Acceptance: of the 100 dispatches, at most one settles
/// Complete; the remaining 99 settle Cancelled.
#[test]
fn supersede_drops_queued_jobs_before_they_run() {
let rt = AsyncRuntime::with_pool_size(1);
// Gate the worker with a job that will sit in flight while we
// dispatch the rest. The gate is keyless so it does not
// interact with the supersede table.
let gate = rt.dispatch_sleep(100, None);
let mut ids = Vec::with_capacity(100);
for _ in 0..100 {
ids.push(rt.dispatch_sleep(0, Some("k")));
}
// Pump until every dispatched id (gate + 100 keyed) settles.
let deadline = Instant::now() + Duration::from_secs(3);
loop {
assert!(Instant::now() < deadline, "settle deadline exceeded");
let _ = rt.tick();
if rt.is_complete(gate) && ids.iter().all(|id| rt.is_complete(*id)) {
break;
}
thread::sleep(Duration::from_millis(1));
}
let mut completed = 0usize;
let mut cancelled = 0usize;
for id in &ids {
match rt.take_result(*id) {
Some(JobOutcome::Complete(_)) => completed += 1,
Some(JobOutcome::Cancelled) => cancelled += 1,
other => panic!("job {id}: unexpected outcome {other:?}"),
}
}
assert!(
completed <= 1,
"expected ≤1 surviving job, got {completed} (cancelled={cancelled})"
);
assert_eq!(
completed + cancelled,
ids.len(),
"every dispatched job must settle"
);
// Drain the gate's outcome too so the assertions below see a
// clean pending table.
let _ = rt.take_result(gate);
// The supersede table should have pruned itself once the
// last job (the one that owns the key) settled.
assert_eq!(rt.supersede_len(), 0);
}
/// Acceptance bullet 3: rapid dispatch with the same key produces
/// exactly one running job at a time. On a single-thread pool we
/// can prove this structurally: at any moment, only the worker
/// thread runs, so "exactly one running" is automatic. The
/// supersede invariant we *can* check is that the `key → id`
/// table only ever holds the most-recently-dispatched id.
#[test]
fn supersede_table_holds_only_the_most_recent_id() {
let rt = AsyncRuntime::with_pool_size(1);
let mut ids = Vec::new();
for _ in 0..50 {
ids.push(rt.dispatch_sleep(50, Some("only-one")));
}
// Immediately, before any tick, the table should map the
// key to the *last* dispatched id.
assert_eq!(rt.active_for_key("only-one"), Some(*ids.last().unwrap()));
// And every prior id's cancel token must already be set.
for id in &ids[..ids.len() - 1] {
// Try to take_result eventually; even if the worker
// hasn't replied yet, the cancel token is flipped.
// Pump until it settles into Cancelled.
let deadline = Instant::now() + Duration::from_millis(500);
while !rt.is_complete(*id) {
assert!(
Instant::now() < deadline,
"prior id {id} did not get cancelled"
);
let _ = rt.tick();
thread::sleep(Duration::from_millis(1));
}
assert!(
rt.is_cancelled(*id),
"prior id {id} should have settled Cancelled"
);
}
}
/// Two distinct keys do not interfere: cancelling under "alpha"
/// must not affect a job in flight under "beta".
#[test]
fn supersede_keys_are_independent() {
let rt = AsyncRuntime::with_pool_size(2);
let alpha1 = rt.dispatch_sleep(2_000, Some("alpha"));
let beta = rt.dispatch_sleep(0, Some("beta"));
let _alpha2 = rt.dispatch_sleep(0, Some("alpha"));
// beta should complete cleanly; alpha1 should be cancelled.
let deadline = Instant::now() + Duration::from_secs(2);
while !(rt.is_complete(beta) && rt.is_complete(alpha1)) {
assert!(Instant::now() < deadline, "settle deadline exceeded");
let _ = rt.tick();
thread::sleep(Duration::from_millis(1));
}
assert!(rt.is_cancelled(alpha1));
assert!(matches!(
rt.take_result(beta),
Some(JobOutcome::Complete(JobResult::Unit))
));
}
/// A job dispatched without a key never enters the supersede
/// table and is unaffected by dispatches that do specify keys.
#[test]
fn keyless_dispatch_is_unaffected_by_supersede() {
let rt = AsyncRuntime::with_pool_size(2);
let keyless = rt.dispatch_compute_sum(1_000_000, None);
// Spam the same supersede key around the keyless job.
for _ in 0..5 {
let _ = rt.dispatch_sleep(0, Some("noisy"));
}
let deadline = Instant::now() + Duration::from_secs(2);
while !rt.is_complete(keyless) {
assert!(Instant::now() < deadline, "keyless job stalled");
let _ = rt.tick();
thread::sleep(Duration::from_millis(1));
}
let expected = (1_000_000u64 * 1_000_001) / 2;
assert!(matches!(
rt.take_result(keyless),
Some(JobOutcome::Complete(JobResult::Sum(v))) if v == expected
));
}
// ---- T M3.5 streaming + frame-boundary coalescing -----------------------
/// Acceptance bullet 1 + 2: a 10000-item stream produces no
/// message loss, and the consumer is woken at most one batch
/// per drain regardless of producer rate. We bound the test by
/// asserting `batch_count` is far smaller than `item_count` ---
/// this is what coalescing buys.
#[test]
fn streaming_handler_emits_all_items_with_few_batches() {
const N: u64 = 10_000;
let rt = AsyncRuntime::with_pool_size(2);
let id = rt.dispatch_emit_n(N, None, None);
// Drive ticks at frame cadence (16 ms) until the stream is
// closed. A run loop would do exactly this; we simulate it.
let mut total_items = 0usize;
let mut batch_count = 0usize;
let mut closed = false;
let deadline = Instant::now() + Duration::from_secs(5);
while !closed {
assert!(Instant::now() < deadline, "stream did not complete in time");
let _ = rt.tick();
for batch in rt.take_stream_batches() {
if batch.id == id {
total_items += batch.items.len();
batch_count += 1;
if batch.closed {
assert!(matches!(
batch.outcome,
Some(JobOutcome::Complete(JobResult::Unit))
));
closed = true;
}
}
}
thread::sleep(Duration::from_millis(16));
}
assert_eq!(
total_items as u64, N,
"expected exactly {N} items, received {total_items}"
);
// Coalescing bound: at default 1024 cap and 60 Hz cadence,
// 10K items should be delivered in a tiny number of batches
// --- structurally bounded to ⌈N / cap⌉ + 1 (final closing
// batch). Loose enough to absorb scheduler jitter.
assert!(
batch_count <= 64,
"expected ≤64 batches for {N} items, got {batch_count}"
);
}
/// Acceptance bullet 3 (batch size): per-stream `max_batch`
/// caps individual batches. With cap = 32, no single drain
/// returns more than 32 items.
#[test]
fn stream_max_batch_caps_batch_size() {
const N: u64 = 1_000;
const CAP: usize = 32;
let rt = AsyncRuntime::with_pool_size(1);
let id = rt.dispatch_emit_n(N, None, Some(CAP));
let mut total_items = 0usize;
let mut closed = false;
let deadline = Instant::now() + Duration::from_secs(5);
while !closed {
assert!(Instant::now() < deadline, "stream did not complete in time");
let _ = rt.tick();
for batch in rt.take_stream_batches() {
if batch.id == id {
assert!(
batch.items.len() <= CAP,
"batch had {} items, exceeds cap {CAP}",
batch.items.len()
);
total_items += batch.items.len();
if batch.closed {
closed = true;
}
}
}
thread::sleep(Duration::from_millis(8));
}
assert_eq!(total_items as u64, N, "items lost under per-batch cap");
}
/// Acceptance bullet 3 (frame target): the runtime exposes
/// `frame_target_ms` as a tunable knob, and the editor's run
/// loop reads it each iteration.
#[test]
fn frame_target_is_tunable_and_clamped() {
let rt = AsyncRuntime::with_pool_size(1);
assert_eq!(rt.frame_target_ms(), DEFAULT_FRAME_TARGET_MS);
rt.set_frame_target_ms(33);
assert_eq!(rt.frame_target_ms(), 33);
// Out-of-range values clamp into [1, 1000] rather than
// panicking or silently disabling the loop.
rt.set_frame_target_ms(0);
assert_eq!(rt.frame_target_ms(), 1);
rt.set_frame_target_ms(10_000);
assert_eq!(rt.frame_target_ms(), 1000);
}
/// Default-batch knob behaves the same way: tunable, clamped.
#[test]
fn default_max_batch_is_tunable_and_clamped() {
let rt = AsyncRuntime::with_pool_size(1);
assert_eq!(rt.default_max_batch(), DEFAULT_MAX_BATCH);
rt.set_default_max_batch(64);
assert_eq!(rt.default_max_batch(), 64);
rt.set_default_max_batch(0);
assert_eq!(rt.default_max_batch(), 1);
rt.set_default_max_batch(10_000_000);
assert_eq!(rt.default_max_batch(), 1_000_000);
}
/// Streams compose with supersede: a second stream under the
/// same key cancels the predecessor; the predecessor's final
/// batch is `closed = true` with `Cancelled` outcome.
#[test]
fn stream_supersede_cancels_predecessor_with_cancelled_outcome() {
let rt = AsyncRuntime::with_pool_size(2);
let first = rt.dispatch_emit_n(1_000_000, Some("emit"), Some(1_000_000));
let _second = rt.dispatch_emit_n(10, Some("emit"), Some(64));
// Drain until the first stream's closed batch arrives.
let deadline = Instant::now() + Duration::from_secs(5);
let mut first_outcome: Option<JobOutcome> = None;
while first_outcome.is_none() {
assert!(
Instant::now() < deadline,
"first stream did not close in time"
);
let _ = rt.tick();
for batch in rt.take_stream_batches() {
if batch.id == first && batch.closed {
first_outcome = batch.outcome;
}
}
thread::sleep(Duration::from_millis(2));
}
assert!(matches!(first_outcome, Some(JobOutcome::Cancelled)));
}
/// A stream prior settling under supersession must not clobber
/// a *still-alive* successor's slot. We pair a stream prior with
/// a long-running sleep successor so the assert sees the
/// successor mid-flight.
#[test]
fn stream_settle_does_not_clobber_successor_supersede_slot() {
let rt = AsyncRuntime::with_pool_size(2);
let prior = rt.dispatch_emit_n(1, Some("k"), Some(8));
// Successor is a 5-second sleep so it stays Running while
// we observe the supersede slot.
let successor = rt.dispatch_sleep(5_000, Some("k"));
// Drain until the prior stream emits its closed batch.
let deadline = Instant::now() + Duration::from_millis(500);
let mut prior_done = false;
while !prior_done {
assert!(Instant::now() < deadline, "prior never settled");
let _ = rt.tick();
for batch in rt.take_stream_batches() {
if batch.id == prior && batch.closed {
prior_done = true;
}
}
thread::sleep(Duration::from_millis(2));
}
// The supersede slot must still point at the successor.
assert_eq!(rt.active_for_key("k"), Some(successor));
rt.cancel(successor);
}
/// Settling a superseded predecessor must not prune the table
/// entry that the *successor* now owns. Regression test for the
/// "`get(key) == Some(id)` only" guard inside `tick`.
#[test]
fn settling_superseded_does_not_clobber_successor_table_slot() {
let rt = AsyncRuntime::with_pool_size(1);
let prior = rt.dispatch_sleep(2_000, Some("k"));
let successor = rt.dispatch_sleep(2_000, Some("k"));
// Pump until prior settles (Cancelled). Successor still
// running; key→successor must persist.
let deadline = Instant::now() + Duration::from_millis(500);
while !rt.is_complete(prior) {
assert!(Instant::now() < deadline, "prior never settled");
let _ = rt.tick();
thread::sleep(Duration::from_millis(1));
}
assert_eq!(rt.active_for_key("k"), Some(successor));
// Cleanup so the runtime drops cleanly.
rt.cancel(successor);
}
// ---- T M3.6 parallel grep acceptance ------------------------------------
/// R31 (compile-time): every value carried into a grep worker
/// closure is owned. The grep types are `Send` --- the worker
/// pool's `dispatch` requires it, so failing this trait bound
/// is a missing-`Send` regression.
#[test]
fn grep_types_satisfy_send_per_r31() {
fn assert_send<T: Send>() {}
assert_send::<GrepSpec>();
assert_send::<GrepMatch>();
assert_send::<StreamPayload>();
}
/// Build a temp tree with `(path, contents)` files and return
/// the temp dir.
fn make_grep_tree(files: &[(&str, &str)]) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
for (rel, contents) in files {
let path = dir.path().join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create parent");
}
std::fs::write(&path, contents).expect("write file");
}
dir
}
fn collect_matches(rt: &AsyncRuntime, id: JobId, deadline: Duration) -> Vec<GrepMatch> {
let mut out = Vec::new();
let mut closed = false;
let start = Instant::now();
while !closed {
assert!(start.elapsed() < deadline, "grep did not close in time");
let _ = rt.tick();
for batch in rt.take_stream_batches() {
if batch.id != id {
continue;
}
for payload in batch.items {
if let StreamPayload::Match(m) = payload {
out.push(m);
}
}
if batch.closed {
closed = true;
}
}
thread::sleep(Duration::from_millis(2));
}
out
}
/// Correctness: matches found in known files, with correct
/// line numbers and offsets.
#[test]
fn grep_finds_known_matches_in_synthetic_tree() {
let tree = make_grep_tree(&[
("src/a.txt", "first\nneedle here\nthird\n"),
("src/b.txt", "no match here\n"),
("src/sub/c.txt", "alpha needle\nbeta\nneedle\n"),
("README.md", "needle\n"),
]);
let rt = AsyncRuntime::with_pool_size(2);
let id = rt.dispatch_grep(
GrepSpec::new(tree.path().to_path_buf(), "needle".to_owned()),
None,
None,
);
let matches = collect_matches(&rt, id, Duration::from_secs(5));
// Three files, four matches total.
assert_eq!(matches.len(), 4, "matches: {matches:#?}");
let mut by_file: HashMap<String, Vec<&GrepMatch>> = HashMap::new();
for m in &matches {
by_file.entry(m.file.clone()).or_default().push(m);
}
let a = by_file
.iter()
.find(|(k, _)| k.ends_with("a.txt"))
.expect("a.txt match");
assert_eq!(a.1[0].line, 2);
assert_eq!(a.1[0].text, "needle here");
assert_eq!(a.1[0].match_start, 0);
assert_eq!(a.1[0].match_end, 6);
let c = by_file
.iter()
.find(|(k, _)| k.ends_with("c.txt"))
.expect("c.txt match");
assert_eq!(c.1.len(), 2, "two matches expected in c.txt");
}
/// Case-insensitive mode folds both haystack and needle.
#[test]
fn grep_case_insensitive_matches_mixed_case() {
let tree = make_grep_tree(&[("a.txt", "FOO\nfoo\nFoO\n")]);
let rt = AsyncRuntime::with_pool_size(1);
let mut spec = GrepSpec::new(tree.path().to_path_buf(), "foo".to_owned());
spec.case_sensitive = false;
let id = rt.dispatch_grep(spec, None, None);
let matches = collect_matches(&rt, id, Duration::from_secs(5));
assert_eq!(matches.len(), 3);
}
/// Skips binary files (NUL byte in head) and files exceeding the
/// size cap.
#[test]
fn grep_skips_binary_and_oversize_files() {
let tree = tempfile::tempdir().expect("tempdir");
// Binary: contains a NUL byte.
std::fs::write(tree.path().join("bin.dat"), b"prefix\0needle suffix\n").expect("write bin");
// Oversize: 2 KiB body, cap will be 1 KiB.
std::fs::write(tree.path().join("big.txt"), "needle\n".repeat(400)).expect("write big");
// Normal: contains the match.
std::fs::write(tree.path().join("small.txt"), "needle\n").expect("write small");
let rt = AsyncRuntime::with_pool_size(1);
let mut spec = GrepSpec::new(tree.path().to_path_buf(), "needle".to_owned());
spec.max_file_bytes = 1024;
let id = rt.dispatch_grep(spec, None, None);
let matches = collect_matches(&rt, id, Duration::from_secs(5));
assert_eq!(matches.len(), 1);
assert!(matches[0].file.ends_with("small.txt"));
}
/// Acceptance bullet 2: typing a new query while grep is in
/// flight cancels the predecessor within 50 ms. We dispatch a
/// single-fanout grep against a synthetic tree large enough to
/// keep the worker busy past the supersede deadline, then
/// dispatch a second grep under the same key and assert the
/// prior settles `Cancelled` within 50 ms of the supersede call.
#[test]
fn grep_supersede_cancels_predecessor_within_50ms() {
// Synthetic load: 5000 small files of non-matching content.
// Single-fanout sequential scan keeps the worker busy past
// the 50 ms deadline without depending on disk speed.
let dir = tempfile::tempdir().expect("tempdir");
let body: String = "noise noise noise noise noise\n".repeat(50);
for i in 0..5_000 {
std::fs::write(dir.path().join(format!("f{i:05}.txt")), &body).expect("write");
}
let rt = AsyncRuntime::with_pool_size(2);
let mut prior_spec = GrepSpec::new(dir.path().to_path_buf(), "needle".to_owned());
prior_spec.fanout = 1;
let prior = rt.dispatch_grep(prior_spec, Some("search"), None);
// Let the worker actually start scanning.
thread::sleep(Duration::from_millis(15));
// Sanity: the prior should not have already finished the
// whole tree --- if it has, the load is too small for the
// host. Skip rather than emit a false negative.
if rt.is_complete(prior) {
// Drain so the runtime drops cleanly; treat as a
// capacity-tested no-op.
let _ = rt.tick();
let _ = rt.take_stream_batches();
return;
}
let started = Instant::now();
let mut succ_spec = GrepSpec::new(dir.path().to_path_buf(), "alpha".to_owned());
succ_spec.fanout = 1;
let _successor = rt.dispatch_grep(succ_spec, Some("search"), None);
let mut prior_done = false;
let mut prior_outcome: Option<JobOutcome> = None;
while !prior_done {
assert!(
started.elapsed() < Duration::from_millis(50),
"grep supersede did not cancel within 50ms (elapsed: {:?})",
started.elapsed()
);
let _ = rt.tick();
for batch in rt.take_stream_batches() {
if batch.id == prior && batch.closed {
prior_done = true;
prior_outcome = batch.outcome;
}
}
}
assert!(
matches!(prior_outcome, Some(JobOutcome::Cancelled)),
"prior outcome should be Cancelled, got {prior_outcome:?}"
);
}
/// Acceptance bullet 3 (coalescing): a saturating grep load ---
/// every line matches --- delivers all matches in a small
/// number of batches, not one wakeup per match. With cap=64 and
/// 500 matching lines, structural bound is ⌈500/64⌉ + 1 ≈ 9
/// batches; we allow generous slack for scheduler jitter.
#[test]
fn grep_coalesces_saturating_match_rate() {
let dir = tempfile::tempdir().expect("tempdir");
let mut body = String::new();
for _ in 0..500 {
body.push_str("needle\n");
}
std::fs::write(dir.path().join("dense.txt"), body).expect("write");
let rt = AsyncRuntime::with_pool_size(2);
let id = rt.dispatch_grep(
GrepSpec::new(dir.path().to_path_buf(), "needle".to_owned()),
None,
Some(64),
);
let mut total = 0usize;
let mut batch_count = 0usize;
let mut closed = false;
let deadline = Instant::now() + Duration::from_secs(5);
while !closed {
assert!(Instant::now() < deadline, "grep did not close in time");
let _ = rt.tick();
for batch in rt.take_stream_batches() {
if batch.id == id {
assert!(
batch.items.len() <= 64,
"batch had {} items, exceeds cap 64",
batch.items.len()
);
total += batch.items.len();
batch_count += 1;
if batch.closed {
closed = true;
}
}
}
// 16 ms cadence simulates a 60 Hz run loop.
thread::sleep(Duration::from_millis(16));
}
assert_eq!(total, 500, "expected 500 matches, got {total}");
// Coalescing bound: 500 items at cap 64 across 16 ms frames
// yields a small batch count. Loose enough for jitter.
assert!(
batch_count <= 64,
"expected ≤64 batches for 500 items, got {batch_count}"
);
}
/// Acceptance bullet 1 scaled-down: parallel grep over many
/// small files completes quickly. We don't try to reproduce the
/// "kernel source under 2s" benchmark in CI (no kernel source
/// on disk); instead we prove the parallel structure: searching
/// 1000 small files for a literal pattern with `fanout = N`
/// completes in a small wall-clock budget. Generous bound (5 s)
/// because slow CI hosts vary wildly --- the meaningful test is
/// that this runs at all without freezing.
#[test]
fn grep_parallel_search_completes_at_synthetic_scale() {
let dir = tempfile::tempdir().expect("tempdir");
for i in 0..1_000 {
let body = format!("header line\nnoise noise noise\nneedle line {i}\nfooter\n");
std::fs::write(dir.path().join(format!("f{i:04}.txt")), body).expect("write");
}
let rt = AsyncRuntime::with_pool_size(4);
let started = Instant::now();
let id = rt.dispatch_grep(
GrepSpec::new(dir.path().to_path_buf(), "needle".to_owned()),
None,
None,
);
let matches = collect_matches(&rt, id, Duration::from_secs(5));
let elapsed = started.elapsed();
assert_eq!(matches.len(), 1_000);
assert!(
elapsed < Duration::from_secs(5),
"1000-file grep took {elapsed:?}, expected under 5s"
);
}
/// Long lines truncate to `max_match_text` rather than emitting
/// megabytes per match. The match offsets must remain valid in
/// the truncated text (or the match is dropped).
#[test]
fn grep_truncates_long_lines_to_max_match_text() {
let dir = tempfile::tempdir().expect("tempdir");
// 16 KiB line with `needle` near the start.
let mut line = String::from("needle ");
line.push_str(&"x".repeat(16 * 1024));
line.push('\n');
std::fs::write(dir.path().join("long.txt"), line).expect("write");
let rt = AsyncRuntime::with_pool_size(1);
let mut spec = GrepSpec::new(dir.path().to_path_buf(), "needle".to_owned());
spec.max_match_text = 128;
let id = rt.dispatch_grep(spec, None, None);
let matches = collect_matches(&rt, id, Duration::from_secs(5));
assert_eq!(matches.len(), 1);
assert!(
matches[0].text.len() <= 128,
"text length {} exceeds cap",
matches[0].text.len()
);
assert!(matches[0].text.starts_with("needle"));
}
/// Empty pattern emits zero matches and closes cleanly.
#[test]
fn grep_empty_pattern_emits_no_matches() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("a.txt"), "any line\n").expect("write");
let rt = AsyncRuntime::with_pool_size(1);
let id = rt.dispatch_grep(
GrepSpec::new(dir.path().to_path_buf(), String::new()),
None,
None,
);
let matches = collect_matches(&rt, id, Duration::from_secs(5));
assert_eq!(matches.len(), 0);
}
// ---- T M3.7 workers observability ---------------------------------------
/// Active jobs show up in the snapshot with the expected kind,
/// supersede key, and a non-zero age.
#[test]
fn workers_snapshot_lists_active_jobs() {
let rt = AsyncRuntime::with_pool_size(1);
let _sleep_id = rt.dispatch_sleep(2_000, Some("sleeper"));
let _sum_id = rt.dispatch_compute_sum(50_000_000, None);
// Yield so age_ms is observably non-zero.
thread::sleep(Duration::from_millis(5));
let snap = rt.workers_snapshot();
assert_eq!(snap.active.len(), 2, "two active jobs expected");
assert!(snap.completed.is_empty());
let kinds: Vec<JobKind> = snap.active.iter().map(|a| a.kind).collect();
assert!(kinds.contains(&JobKind::Sleep));
assert!(kinds.contains(&JobKind::ComputeSum));
let sleeper = snap
.active
.iter()
.find(|a| a.kind == JobKind::Sleep)
.expect("sleeper");
assert_eq!(sleeper.supersede_key.as_deref(), Some("sleeper"));
assert!(sleeper.age_ms > 0);
assert!(!sleeper.cancel_requested);
// Cleanup.
rt.cancel(sleeper.id);
rt.cancel(
snap.active
.iter()
.find(|a| a.kind == JobKind::ComputeSum)
.unwrap()
.id,
);
}
/// `cancel_requested` is reflected immediately --- the snapshot
/// is what the *workers* buffer renders, and "user pressed
/// cancel but worker hasn't observed yet" is a state the user
/// needs to see.
#[test]
fn workers_snapshot_marks_cancel_requested() {
let rt = AsyncRuntime::with_pool_size(1);
let id = rt.dispatch_sleep(2_000, None);
thread::sleep(Duration::from_millis(2));
rt.cancel(id);
let snap = rt.workers_snapshot();
let job = snap.active.iter().find(|j| j.id == id).expect("present");
assert!(job.cancel_requested);
}
/// A settled job moves out of the active list and into the
/// completed ring with its outcome.
#[test]
fn workers_snapshot_records_completed_ring() {
let rt = AsyncRuntime::with_pool_size(1);
let id = rt.dispatch_compute_sum(10, None);
pump_until(&rt, || rt.is_complete(id));
let snap = rt.workers_snapshot();
assert!(
snap.active.iter().all(|j| j.id != id),
"settled job should not be active"
);
let entry = snap
.completed
.iter()
.find(|c| c.id == id)
.expect("completed slot");
assert_eq!(entry.kind, JobKind::ComputeSum);
assert!(matches!(
entry.outcome,
JobOutcome::Complete(JobResult::Sum(55))
));
}
/// Newest completion comes first; the ring keeps order stable
/// for the "Recent (newest first)" buffer section.
#[test]
fn workers_completed_ring_orders_newest_first() {
let rt = AsyncRuntime::with_pool_size(1);
let first = rt.dispatch_compute_sum(1, None);
pump_until(&rt, || rt.is_complete(first));
thread::sleep(Duration::from_millis(2));
let second = rt.dispatch_compute_sum(2, None);
pump_until(&rt, || rt.is_complete(second));
let snap = rt.workers_snapshot();
// Newest first means the second-dispatched job is at index 0.
assert_eq!(snap.completed[0].id, second);
assert_eq!(snap.completed[1].id, first);
}
/// The completed ring is bounded to [`COMPLETED_RING_CAP`].
/// Pushing more entries evicts oldest from the back.
#[test]
fn workers_completed_ring_evicts_oldest_at_capacity() {
let rt = AsyncRuntime::with_pool_size(1);
let mut ids = Vec::new();
for _ in 0..(COMPLETED_RING_CAP + 16) {
let id = rt.dispatch_compute_sum(1, None);
pump_until(&rt, || rt.is_complete(id));
ids.push(id);
}
let snap = rt.workers_snapshot();
assert_eq!(snap.completed.len(), COMPLETED_RING_CAP);
// The oldest-dispatched ids fell off the back; the newest
// remain. Specifically, the entry at index 0 is the most
// recent dispatch.
assert_eq!(snap.completed[0].id, *ids.last().unwrap());
// The earliest 16 dispatches must NOT be in the ring.
for old in &ids[..16] {
assert!(snap.completed.iter().all(|c| c.id != *old));
}
}
// T M3.8 -----------------------------------------------------
//
// Memory and lifecycle audit: 1000 dispatch/cancel cycles must
// not grow internal state. The runtime's contract is that
// `take_result` evicts a settled entry from the pending table
// and `tick` prunes the supersede slot it owns; if either path
// leaks, repeated cycles surface it as monotonic growth.
/// Dispatch + cancel + settle + `take_result`, 1000 times,
/// asserts the pending and supersede tables return to zero.
/// Cycles are short (~20ms sleep) so this finishes in seconds.
/// Cancellation outcome is racy --- the worker may finish the
/// sleep before it polls cancel --- so we accept any terminal
/// outcome and only gate on table sizes.
#[test]
fn dispatch_cancel_1000_cycles_no_leak() {
let rt = AsyncRuntime::with_pool_size(2);
for _ in 0..1000 {
let id = rt.dispatch_sleep(5, None);
rt.cancel(id);
pump_until(&rt, || rt.is_complete(id));
let _ = rt.take_result(id);
}
assert_eq!(rt.pending_len(), 0, "pending leaked across 1000 cycles");
assert_eq!(rt.supersede_len(), 0, "supersede leaked across 1000 cycles");
// The completion ring is bounded by COMPLETED_RING_CAP, not
// by cycle count --- after 1000 cycles it should be saturated
// at exactly the cap.
let snap = rt.workers_snapshot();
assert_eq!(snap.active.len(), 0);
assert_eq!(snap.completed.len(), COMPLETED_RING_CAP);
}
/// Supersede churn variant: dispatch many jobs under the same
/// key. The supersede table must hold exactly one slot at any
/// time and shrink to zero once every job has settled and been
/// taken via `take_result`.
#[test]
fn supersede_churn_500_cycles_table_returns_to_zero() {
let rt = AsyncRuntime::with_pool_size(2);
let mut ids = Vec::with_capacity(500);
for _ in 0..500 {
ids.push(rt.dispatch_sleep(3, Some("search")));
// The supersede table must never exceed one slot under
// a single key, regardless of cycle count.
assert_eq!(rt.supersede_len(), 1);
}
// Drain every dispatched id through `take_result`. Earlier
// dispatches were superseded mid-flight; their workers reply
// with `Cancelled` once they observe the token. Each entry
// lingers in `pending` until `take_result` removes it.
let deadline = Instant::now() + Duration::from_secs(15);
while rt.pending_len() > 0 {
assert!(
Instant::now() < deadline,
"drain stuck; pending={}",
rt.pending_len()
);
let _ = rt.tick();
for id in &ids {
if rt.is_complete(*id) {
let _ = rt.take_result(*id);
}
}
thread::sleep(Duration::from_millis(1));
}
assert_eq!(rt.pending_len(), 0);
assert_eq!(rt.supersede_len(), 0);
}
/// Stream lifecycle leak gate: a stream that runs to closure has
/// its pending entry evicted by `take_stream_batches` once the
/// closing batch is delivered. 200 cycles is plenty to surface
/// any per-stream allocation that escapes.
#[test]
fn stream_dispatch_close_200_cycles_no_leak() {
let rt = AsyncRuntime::with_pool_size(2);
for _ in 0..200 {
let id = rt.dispatch_emit_n(8, None, Some(8));
// Drain until the batch carrying `closed = true` for this
// id is observed.
let deadline = Instant::now() + Duration::from_secs(2);
let mut closed = false;
while !closed {
assert!(Instant::now() < deadline, "stream close deadline");
let _ = rt.tick();
for batch in rt.take_stream_batches() {
if batch.id == id && batch.closed {
closed = true;
}
}
if !closed {
thread::sleep(Duration::from_millis(1));
}
}
}
assert_eq!(rt.pending_len(), 0, "stream pending entries leaked");
}
// ---- T M4.1 dispatch_parse smoke ----------------------------------------
/// `dispatch_parse` round-trips a real grammar end-to-end:
/// settle status is `Complete(Parse{..})`, the bundle is in
/// the side handoff, and the tree's root has the language's
/// expected top-level node type.
#[test]
fn dispatch_parse_round_trips_a_rust_source_file() {
let rt = AsyncRuntime::with_pool_size(1);
let source = b"fn main() { let _x = 1 + 2; }\n";
let req = ParseRequest {
source: Arc::from(&source[..]),
language: tree_sitter_rust::LANGUAGE.into(),
language_name: "rust".to_owned(),
prior_tree: None,
edits: Vec::new(),
injection_aliases: Arc::new(std::collections::HashMap::new()),
};
let id = rt.dispatch_parse(req, None);
pump_until(&rt, || rt.is_complete(id));
let bundle = rt
.take_parse_tree(id)
.expect("parse handoff must hold a bundle on Complete");
match rt.take_result(id) {
Some(JobOutcome::Complete(JobResult::Parse { duration_ms })) => {
assert!(duration_ms < 100, "trivial parse should be fast");
}
other => panic!("unexpected outcome: {other:?}"),
}
assert_eq!(bundle.language_name, "rust");
assert_eq!(bundle.root_tree().root_node().kind(), "source_file");
// take_parse_tree was already drained, so handoff is empty.
assert_eq!(rt.parse_handoff_len(), 0);
}
/// `take_result` on a parse job drops any leftover handoff entry,
/// so a forgetful caller cannot leak bundles.
#[test]
fn take_result_drops_stale_parse_handoff() {
let rt = AsyncRuntime::with_pool_size(1);
let req = ParseRequest {
source: Arc::from(&b"fn x() {}"[..]),
language: tree_sitter_rust::LANGUAGE.into(),
language_name: "rust".to_owned(),
prior_tree: None,
edits: Vec::new(),
injection_aliases: Arc::new(std::collections::HashMap::new()),
};
let id = rt.dispatch_parse(req, None);
pump_until(&rt, || rt.is_complete(id));
// Don't drain the bundle --- take_result should clean it.
assert_eq!(rt.parse_handoff_len(), 1);
let _ = rt.take_result(id);
assert_eq!(rt.parse_handoff_len(), 0);
}
}