Skip to content

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 SensorReading {
    device: string
    value: number
}

event Alert {
    msg: string
    level: i32
}

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.

emit SensorReading { device: "temp-01", value: 23.5 }

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:

on SensorReading fn(e: SensorReading): void {
    console.log(e.device)
    console.log(e.value)
}

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 Alert

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:

  1. Disconnected event fires — your handler runs
  2. Native layer waits for the configured delay
  3. Reconnect attempt begins (new TCP socket + protocol handshake)
  4. On success: Connected event fires again — subscriptions are restored (MQTT only)
  5. On failure: next delay in the backoff array is used, retries until connected
mqtt.setAutoReconnect(conn, true)
mqtt.setReconnectDelays(conn, [1, 2, 5, 10, 30])

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.

See mqtt and ws for details.

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
  • emit pushes 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 code 12
  • The event loop drains one event per iteration, runs its handler, and wakes one parked producer per freed slot, then checks timers and tasks
  • abort resets 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:

runtime.guard(0, 0, 5)    # max 5 consecutive same-type emits per handler

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 X inside on X handler); runtime.guard() enforces at runtime
  • Event handlers access enclosing variables (closures)
  • Events work alongside timers and tasks in the same event loop
  • Native on uses kernel poll() — zero CPU while waiting for data
  • On ESP32, fd watchers use lwIP select