Events
StarLang has a built-in event system with typed events, reliable delivery, and native library integration. Events are the primary mechanism for reactive programming — instead of polling, you declare what you care about and the runtime delivers it.
Control plane: events never drop
Events are the control plane — low-rate, state-changing signals (connect/disconnect, stream start/stop, sample-rate change). Each one is critical: missing a single disconnect can desync a state machine permanently. So events are reliable by contract: there is no drop knob exposed to the programmer. The only overflow behavior is backpressure (wait), never silent loss.
This is deliberately different from queue, the data plane, where high-rate items live and where you choose the retention policy (block / drop_oldest / drop_newest). The discipline: push volume through a queue, never through events.
event
Declare a named event type with typed fields:
Event declarations are compile-time checked. Field names and types are verified when emitting or handling events. At runtime, events use the same struct machinery as struct — field access works identically.
emit
Push an event onto its type ring. From the dispatcher (main / a handler) emit returns immediately. From a spawned task (go), if the ring is full, emit applies backpressure: the task parks until the dispatcher drains a slot, then the emit re-runs. Events are never silently dropped.
Field order must match the declaration. All fields are required.
If the ring is full and no task can drain it (e.g. the dispatcher itself is the only runnable code), emit traps with fault code 12 (event storm) — an uncatchable integrity fault — rather than dropping the event. See Event Storm Protection.
on
Register a handler for an event type. The handler receives the event as a struct parameter:
Multiple handlers can be registered for the same event type. Handlers fire in registration order. Handler callbacks run during the event loop (runtime.keepAlive()).
abort
Flush the entire queue for an event type. All pending events of that type are discarded.
Abort can be called from anywhere — including from inside an event handler. This is useful for "process first, drop rest" patterns:
on Alert fn(e: Alert): void {
console.log(e.msg)
abort Alert
}
emit Alert { msg: "first" }
emit Alert { msg: "second" }
emit Alert { msg: "third" }
Only "first" is processed. The handler aborts the remaining queue after firing.
Event Loop Integration
Events are processed during the event loop, alongside timers and tasks. Call runtime.keepAlive() to start the loop, and runtime.exit() to stop it.
package main
event Tick {
count: i32
}
var total: i32 = 0
fn main(): void {
on Tick fn(e: Tick): void {
total = total + e.count
}
emit Tick { count: 10 }
emit Tick { count: 20 }
timer 50 {
runtime.exit()
}
runtime.keepAlive()
assert(total == 30)
console.log("done")
}
Native Library Events
Native libraries (mqtt, ws) can emit events directly from C into the event queue. This replaces polling — you declare event types and use mqtt.on()/ws.on() to bind lifecycle and data events to handlers.
MQTT
import "mqtt"
event MqttConnected {
conn: i32
}
event MqttDisconnected {
conn: i32
}
event MqttSubscribed {
conn: i32
topic: string
}
event MqttMessage {
topic: string
payload: string
}
fn main(): void {
var conn: i32 = mqtt.open("192.168.1.10", 1883, "sensor-01")
mqtt.on(conn, "connected", MqttConnected)
mqtt.on(conn, "disconnected", MqttDisconnected)
mqtt.on(conn, "subscribed", MqttSubscribed)
mqtt.on(conn, "message", MqttMessage)
on MqttConnected fn(e: MqttConnected): void {
console.log("connected")
mqtt.subscribe(e.conn, "sensors/#", 0)
}
on MqttDisconnected fn(e: MqttDisconnected): void {
console.log("disconnected")
}
on MqttSubscribed fn(e: MqttSubscribed): void {
console.log("subscribed to:")
console.log(e.topic)
}
on MqttMessage fn(e: MqttMessage): void {
console.log(e.topic)
console.log(e.payload)
}
runtime.keepAlive()
}
mqtt.on(conn, "connected", MqttConnected) binds a lifecycle event to a declared event type. The third argument is the event type itself (not a string) — the compiler resolves it to an event ID at compile time. When the event fires, the native layer creates a struct with the appropriate fields and emits it. Your handler fires automatically during the event loop.
WebSocket
import "ws"
event WsConnected {
conn: i32
}
event WsDisconnected {
conn: i32
}
event WsMessage {
message: string
}
fn main(): void {
var conn: i32 = ws.open("ws://192.168.1.10:8080/stream")
ws.on(conn, "connected", WsConnected)
ws.on(conn, "disconnected", WsDisconnected)
ws.on(conn, "message", WsMessage)
on WsConnected fn(e: WsConnected): void {
console.log("connected")
}
on WsDisconnected fn(e: WsDisconnected): void {
console.log("disconnected")
}
on WsMessage fn(e: WsMessage): void {
console.log(e.message)
}
runtime.keepAlive()
}
ws.on(conn, "connected", WsConnected) works the same way — bind lifecycle and data events to declared event types.
Event Field Mapping
The event type you declare must match the fields the native library emits:
| Library | Lifecycle | Event Fields |
|---|---|---|
mqtt.on |
connected |
conn: i32 |
mqtt.on |
disconnected |
conn: i32 |
mqtt.on |
subscribed |
conn: i32, topic: string |
mqtt.on |
message |
topic: string, payload: string |
ws.on |
connected |
conn: i32 |
ws.on |
disconnected |
conn: i32 |
ws.on |
message |
message: string |
Field names and order must match exactly. The compiler computes a signature hash from EventName(field1:type1,field2:type2,...) and the native library verifies it at runtime. If fields are reordered, renamed, or have wrong types, the emit is rejected with a signature mismatch error.
Auto-Reconnect and Events
Both mqtt and ws modules support automatic reconnection with configurable backoff delays. When enabled, the event flow on unexpected disconnect is:
Disconnectedevent fires — your handler runs- Native layer waits for the configured delay
- Reconnect attempt begins (new TCP socket + protocol handshake)
- On success:
Connectedevent fires again — subscriptions are restored (MQTT only) - On failure: next delay in the backoff array is used, retries until connected
The Connected handler fires on every successful reconnect, so it works naturally as setup code. Explicit disconnect (mqtt.disconnect() / ws.close()) disables auto-reconnect and does not trigger the cycle.
Event-driven Approach
on MqttMessage fn(e: MqttMessage): void {
process(e)
}
mqtt.on(conn, "message", MqttMessage)
runtime.keepAlive()
Events are non-blocking, use no CPU while idle (kernel poll), and integrate naturally with timers and tasks.
Ring Semantics
- Each event type has its own independent ring buffer (capacity: 64). Per-type rings mean a high-volume event can never evict a low-rate one like
disconnect— there is no head-of-line blocking between types emitpushes to the tail. On a full ring it backpressures (a spawned producer parks; the dispatcher re-runs the emit after draining) — it never silently drops. If nothing can drain the ring, it traps with fault code12- The event loop drains one event per iteration, runs its handler, and wakes one parked producer per freed slot, then checks timers and tasks
abortresets the ring to empty (head = tail = 0)- Handlers fire synchronously — the handler must return before the next event is processed
Limits
| Limit | Value |
|---|---|
| Max event types | 32 |
| Queue capacity per type | 64 |
| Max event handlers | 64 |
| Max fd watchers | 32 |
Event Storm Protection
Compile-time Warning
The compiler detects direct event recursion — emitting the same event type inside its own handler:
on Ping fn(e: Ping): void {
emit Ping { count: 0 } # warning: emit 'Ping' inside its own handler may cause event storm
}
This compiles but produces a warning. Because events never drop, an unbounded recursive emit cannot leak memory or silently lose data — instead the bounded ring (64) fills and the chain traps as an event storm (fault code 12). Use the runtime guard below to cap the chain explicitly.
Runtime Guard
Use runtime.guard() to set a hard limit on event chains:
The third parameter limits how many times a handler can emit the same event type it is currently handling. When exceeded, the script traps with event storm detected. The VM stays alive — only the script is killed.
event Ping { count: i32 }
fn main(): void {
runtime.guard(0, 0, 5)
on Ping fn(e: Ping): void {
emit Ping { count: e.count + 1 } # traps after 5 recursive emits
}
emit Ping { count: 0 }
runtime.keepAlive()
}
A value of 0 (default) means no limit. See runtime.guard() for the full guard API.
Signature Verification
Each event declaration produces a signature hash from EventName(field1:type1,field2:type2,...). Native libraries verify this hash at emit time:
# Correct — matches native layout
event MqttMessage {
topic: string
payload: string
}
# Wrong — field order swapped, runtime signature mismatch error
event MqttMessage {
payload: string
topic: string
}
This catches positional field mapping errors that would otherwise produce silent data corruption.
Notes
- Events are typed at compile time — mismatched field names or types are compile errors
- Native event binding uses event types directly:
mqtt.on(conn, "message", MqttMessage)(not a string) - Signature hash (FNV-1a) verifies field layout between native C and StarLang declarations
- Compiler warns on direct event recursion (
emit Xinsideon Xhandler);runtime.guard()enforces at runtime - Event handlers access enclosing variables (closures)
- Events work alongside timers and tasks in the same event loop
- Native
onuses kernelpoll()— zero CPU while waiting for data - On ESP32, fd watchers use
lwIPselect