rust-event-loop-state
Skill5 starsChanged 10 days agoBSD-3-ClauseView on GitHub
Use when designing or reviewing an event loop, tick loop, or handler registry whose handlers all need &mut to one shared mutable state, such as a game loop, a TUI loop, or a god object. Also for E0499 in a dispatch loop, E0502 on a queue peek, E0207 or E0119 on a generic Handler trait, Rc RefCell between handlers, an ECS system and world, and coroutine resume or async fn as a suspendable routine over that state. Not for async runtime scheduling; use rust-async-internals.
In this repository46 other files it gives its agents.
- AGENTS.mdAGENTS.md
- cargo-workflowsSkill
- ffi-error-progress-cancelSkill
- memory-modelSkill
- rust-android-buildSkill
- rust-async-internalsSkill
- rust-borrow-semanticsSkill
- rust-callback-boundsSkill
- rust-cliSkill
- rust-code-styleSkill
- rust-compiler-errorsSkill
- rust-copy-on-writeSkill
- rust-crate-architectureSkill
- rust-crate-releaseSkill
- rust-databaseSkill
- rust-debuggingSkill
- rust-disciplineSkill
- rust-embedded-no-stdSkill
- rust-hot-pathSkill
- rust-ios-buildSkill
- rust-iterator-implSkill
- rust-jniSkill
- rust-lintsSkill
- rust-macrosSkill
- rust-native-linkingSkill
- rust-networkingSkill
- rust-observabilitySkill
- rust-panic-safetySkill
- rust-pattern-semanticsSkill
- rust-performanceSkill
- rust-pin-projectionSkill
- rust-portSkill
- rust-reviewSkill
- rust-sanitizers-miriSkill
- rust-securitySkill
- rust-send-syncSkill
- rust-serdeSkill
- rust-swift-ffiSkill
- rust-tddSkill
- rust-test-toolsSkill
- rust-type-erasureSkill
- rust-unsafeSkill
- rust-varianceSkill
- rust-wasmSkill
- uniffi-boundarySkill
- uniffi-packaging-versioningSkill
The file
We split this skill into the rules it gives an agent
9 rules. About 0 are common, 4 are distinctive, and 5 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈4
Distinctive · estimate
This author's own choices. Worth a look.
5
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 19
Read the left column first.
- Very specific: line 85
Read references/review-checklist.md when you review an existing loop.
- Very specific: line 90
Put the handler collection inside the state and dispatch &mut State into the handlers, and the call cannot type-check.
- Distinctive: line 106
No interior mutability fixes this.
- Distinctive: line 137
When an existing codebase cannot move the field yet, use one of two repairs:
- Very specific: line 142
Use std::mem::take(&mut st.handlers) for the tick only when a handler must also mutate the handler set.
- Very specific: line 196
Read references/generic-and-dynamic-state.md when handlers are reused across states (structure row 2), a context pairs a static state with …
- Very specific: line 198
Read references/suspendable-routines.md when a routine must pause mid-work and continue next tick, or someone proposes async fn(&mut …
- Distinctive: line 202
Use these skills when they are installed:
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Rust event loop state
The loop owns the handler set and the state as two separate values, and passes one into
the other. Every rule below follows from this sentence. E0499 in a dispatch loop almost
never needs a different line. It needs a different owner.
This skill covers synchronous loops. For runtime behavior under tokio (select!, JoinSet,
FuturesUnordered, wakers, cancellation), use the rust-async-internals skill when it is
installed. The diagnostics below are from rustc 1.98.1, edition 2024.
Pick the structure from the shape of the handler set
Read the left column first. Stop at the first row that describes your handler set. Each row down costs something concrete, and you pay it whether or not you needed it.
| Shape of the handler set | Structure | What it costs |
| Fixed at compile time. The state's fields are known to the crate that writes the loop | The loop holds Vec<Box<dyn Handler>> and State as two locals. Dispatch h.handle(&mut state, &ev) | Nothing. Start here |
| Handlers must be reused across applications that have different states | State is a trait parameter: trait Handler<S>. Each handler declares the capability traits it needs on S. The loop registers Vec<Box<dyn Handler<App>>> | Trait-solver error volume, and a blanket impl forecloses a later per-state impl on that type for every state that meets its bounds (E0119) |
| The component set is open at run time: plugins, scripting, save files, or a level editor decide it | An ECS-shaped dynamic world with type-erased components and systems | Aliasing detection stops being a compile error. It becomes a run-time panic the first time the schedule initializes that system |
| None of the above | Row 1. Do not reach further | Nothing. The shortcuts in Do not do this each move a compile error to a panic, a deadlock, or an unfixable E0499 |
Triage
| Symptom | Cause | Fix | Section |
E0499: cannot borrow *st as mutable more than once, first borrow at iter_mut(), second at the dispatch argument | The handler collection is a field of the state | Move the field out of State, dispatch a sub-struct field, or mem::take it for the tick | The loop owns both |
E0502 ... immutable borrow later used by call on while let Some(ev) = st.queue.front() | The event is borrowed out of a queue inside the state | Pop the event by value | Drain the queue |
E0499 ... first borrow later used by call, first borrow at handler construction | A handler holds a &mut into the state | Store an index or a generational key | Store a key |
E0207: the type parameter S is not constrained | The handler trait has type State | Make it trait Handler<S> | State is a trait parameter |
E0119: conflicting implementations of trait Handler<App> | A blanket impl<S: ...> Handler<S> for X whose bounds App satisfies. An unbounded one covers every state | Keep one impl. Delete the per-state impl and move its behavior behind a capability-trait method, or replace the blanket impl with per-state impls. A bound removes the overlap only when App does not satisfy it | State is a trait parameter |
E0499 on &mut ctx.field where ctx implements DerefMut | Auto-deref borrows the whole wrapper | Plain fields, or one split() method | No DerefMut |
Run-time panic ... conflicts with a previous system parameter (an ECS code such as B0001) | Two overlapping mutable views in one system | A parameter set or a disjoint filter | ECS |
RefCell already borrowed or RefCell already mutably borrowed panic, or a hang on a nested lock() | Shared state behind Rc<RefCell<_>> or Arc<Mutex<_>>, re-entered during dispatch | Row 1 of the structure table | Do not do this |
| A routine must pause mid-work and continue next tick | - | A resume(&mut self, &mut State) trait | Suspendable routines |
implementation of Coroutine is not general enough | A coroutine resume argument is not higher-ranked | Same as the row above | Suspendable routines |
Do not do this
| Move | What actually happens |
Rc<RefCell<State>> cloned into every handler, re-borrowed during dispatch | The E0499 becomes a RefCell already borrowed or RefCell already mutably borrowed panic the first time one handler calls another. The compile error told you the truth |
Arc<Mutex<State>> inside one thread, re-locked during dispatch | The nested lock() never returns. std leaves the outcome unspecified: it can deadlock or panic. A deadlock gives no output and no backtrace, and it needs no load to happen |
unsafe { &mut *ptr } to hand two handlers the state | Two live &mut to one place are undefined behavior. Miri rejects it under Stacked Borrows and Tree Borrows |
async fn(&mut State) as a suspendable routine | E0499 before any executor runs. No version of this works |
| An ECS taken for ergonomics, not for an open component set | You pay run-time conflict panics for a component set the compiler could have checked |
std::mem::take(&mut st.handlers), then st.handlers = hs | Every handler registered during the tick is silently dropped. Call hs.append(&mut st.handlers) first |
A bare usize slot key when slots are removed or reused | The key names a different slot, or an out-of-bounds slot, with no error. Use a generational key |
The first two rows are about re-entrant dispatch inside one synchronous loop. Under a runtime
the same shapes read differently: Arc<Mutex<State>> across separate tasks is a valid,
serializing choice.
Verify
The change is complete when every row that applies to it is green.
| Claim | Check | What a green result does not prove |
| The loop, handlers, and state borrow correctly (structure rows 1 and 2) | cargo check --locked --all-targets on the crate | Nothing about a RefCell or Mutex that remains, or about ECS access |
No re-entrant RefCell or Mutex path remains | rg -e 'Rc<RefCell<' -e 'Arc<Mutex<' over the state and handler types. Then a test that dispatches one event through every handler, including a handler that triggers another, under the timeout below | Paths the test does not reach. A cell behind a type alias |
| A handler that enqueues cannot stall the tick | A test with a handler that enqueues on every event. Run one tick under the timeout below | Handlers that enqueue only on some events |
| No ECS access conflict | A test that initializes and runs every real schedule once against an empty world | Systems added to a schedule at run time |
Dispatch that still contains unsafe does not alias | When a nightly toolchain with the miri component is installed, cargo +nightly miri test --locked on the dispatch tests. Otherwise report the dispatch as not checked by Miri | Paths the tests do not reach |
The rust-sanitizers-miri skill, when it is installed, owns the Miri flags and the Stacked
Borrows and Tree Borrows policy. Without it, gate on the default Stacked Borrows run and treat
MIRIFLAGS="-Zmiri-tree-borrows" as a second opinion.
A re-locked Mutex usually fails as a hang (std may also panic), and an always-enqueuing
while let drain fails as a hang. libtest warns after 60 seconds and never ends the test.
Build the state and run one tick inside std::thread::spawn. Send () on a
std::sync::mpsc channel when the tick returns. Assert
rx.recv_timeout(Duration::from_secs(5)).is_ok(). A panic in the thread drops the sender, so
the same assert also catches a RefCell panic. If cargo-nextest is installed,
slow-timeout = { period = "5s", terminate-after = 1 } under [profile.default] in
.config/nextest.toml also ends a hung test.
Read references/review-checklist.md when you review an existing loop. It asks one question
per defect class in this skill.
The loop owns both, separately
Put the handler collection inside the state and dispatch &mut State into the handlers, and
the call cannot type-check. The iterator holds a mutable borrow of *st for the whole loop
body, so the &mut State argument is a second mutable borrow of the same place:
struct Event(u32);
struct State { counter: u32, handlers: Vec<Box<dyn Handler>> }
trait Handler { fn handle(&mut self, st: &mut State, ev: &Event); }
fn tick(st: &mut State, ev: Event) {
for h in st.handlers.iter_mut() {
h.handle(st, &ev);
}
}No interior mutability fixes this. Move the collection out. Two locals in the loop are two independent owners, so the two borrows are provably disjoint:
struct Event(u32);
struct State { counter: u32 }
trait Handler { fn handle(&mut self, st: &mut State, ev: &Event); }
struct Add;
impl Handler for Add {
fn handle(&mut self, st: &mut State, ev: &Event) { st.counter += ev.0; }
}
struct Double;
impl Handler for Double {
fn handle(&mut self, st: &mut State, _ev: &Event) { st.counter *= 2; }
}
fn tick(handlers: &mut [Box<dyn Handler>], st: &mut State, ev: Event) {
for h in handlers.iter_mut() { h.handle(st, &ev); }
}
fn main() {
let mut handlers: Vec<Box<dyn Handler>> = vec![Box::new(Add), Box::new(Double)];
let mut st = State { counter: 0 };
tick(&mut handlers, &mut st, Event(3)); // (0 + 3) * 2
tick(&mut handlers, &mut st, Event(4)); // (6 + 4) * 2
assert_eq!(st.counter, 20);
println!("counter={}", st.counter); // counter=20
}When an existing codebase cannot move the field yet, use one of two repairs:
- Group the rest of the state in a sub-struct, make the handler trait take that sub-struct,
and dispatch
&mut st.rest.st.handlersandst.restare disjoint field borrows, so the handler field can stay. - Use
std::mem::take(&mut st.handlers)for the tick only when a handler must also mutate the handler set. The put-back is the trap: a handler that registers a new handler during the tick puts it into the empty field, and a plain assignment drops it. Append the newcomers first. Read the "Migration" section ofreferences/state-partitioning.mdfor the runnable version and its panic limit.
Drain the queue by value
front() returns a reference tied to *st. The reference is an argument of the dispatch
call, so NLL keeps it live across the call, and the whole state stays borrowed (E0502).
pop_front() produces an owned Event, so no borrow of the state survives into the
handler, and a handler can push more events back:
use std::collections::VecDeque;
struct Event(u32);
struct State { counter: u32, queue: VecDeque<Event> }
fn handle(st: &mut State, ev: Event) {
st.counter += ev.0;
if ev.0 > 1 { st.queue.push_back(Event(ev.0 - 1)); } // handlers may enqueue
}
fn main() {
let mut st = State { counter: 0, queue: VecDeque::from([Event(3)]) };
while let Some(ev) = st.queue.pop_front() { handle(&mut st, ev); }
assert_eq!(st.counter, 6);
println!("counter={}", st.counter); // counter=6
}A handler that enqueues on every event makes that while let spin forever. Read the length
first and drain exactly one batch per tick: for _ in 0..st.queue.len() { let Some(ev) =
st.queue.pop_front() else { break }; handle(st, ev); }.
A handler stores a key, never a borrow
A handler built from a field of the state carries that borrow into every dispatch, and the
&mut State argument is then a second borrow. Caching { cache: &mut st.caches[0] }
followed by h.handle(st) is E0499, "first borrow later used by call". Store the slot
instead, struct Caching { slot: usize }, and resolve it at the top of each call:
st.caches[self.slot].hits += 1. Handler construction then does not touch the state, so one
registry serves every tick. A handler that needs two slots at once resolves both keys in one
call with st.caches.get_disjoint_mut([a, b]) (Rust 1.86+). An overlapping or out-of-range
pair is an Err, not a panic.
A bare usize goes stale with no error when a slot is removed or reused. Use a generational
key when slots are freed. Read the "Key-holding handler" section of
references/state-partitioning.md for the diagnostic, the runnable version, and the
staleness rules.
Generic state, dynamic worlds, and suspendable routines
Read references/generic-and-dynamic-state.md when handlers are reused across states (structure row 2), a context pairs a static state with a dynamic world, or the component set is open at run time (structure row 3).
Read references/suspendable-routines.md when a routine must pause mid-work and continue next tick, or someone proposes async fn(&mut State), a coroutine, or a generator for it.
Related skills
Use these skills when they are installed:
| Skill | Boundary |
rust-async-internals | Everything under a runtime: select!, JoinSet, waker plumbing, cancellation, and two futures over disjoint fields of one struct |
rust-compiler-errors | E0499 and E0502 as diagnostics, with the general repairs |
rust-discipline | Deref on non-pointer types, blanket impls and semver |
rust-unsafe | The aliasing rules and the Miri reproduction behind the raw-pointer rows |
rust-iterator-impl | iter::from_fn for a routine that only yields values. The Generator to Coroutine rename and the gen block status |
rust-crate-architecture | Which crate the handler trait and the state type live in |
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

