Skip to content

Repository files navigation

Sigils

A signal and slots library implemented for the Nim programming language. The signals and slots are type checked and implemented purely in Nim. It can be used for event based programming both with GUIs or standalone.

Signals and slots is a language construct introduced in Qt for communication between objects which makes it easy to implement the observer pattern while avoiding boilerplate code. The concept is that GUI widgets, and other objects, can send signals containing event information which can be received by other objects using special member functions known as slots. This is similar to C/C++ function pointers, but the signal/slot system ensures the type-correctness of callback arguments.

  • Wikipedia

Note that this implementation shares many or most of the limitations you'd see in Qt's implementation. Sigils also includes a message-passing threading model; see docs/threading.md for the detailed architecture and safety notes.

Changelog

  • v0.22 includes several potential breaking changes

Basics

Only objects inheriting from Agent can receive signals. Slots must take an Agent object as the first argument. The rest of the arguments must match that of the signal you wish to connect a slot to.

For threaded usage, prefer AgentActor (it extends Agent with a mailbox and lock for thread-safe subscription updates). AgentActor is required by moveToThread and by the proxy-based cross-thread APIs.

You need to wrap procs with a slot to set up the proc to support receiving signals. The proc can still be used as a normal function though. Signals use the proc syntax but don't have a implementation. They just provide the type checking and naming for the signal.

Connecting signals and slots is accomplished using connect. Note that connect is idempotent, meaning that you can call it on the same objects the multiple times without ill effect.

Compile-time Options

  • Closure slots: enable -d:sigilsClosures, -d:sigils.closures, or the closures package feature, then import sigils/closures to use connectTo(...) do:.
  • String sigil names: enable -d:sigilsSigilNameString, -d:sigils.sigNameAsString, or the sigNameAsString package feature to use plain string for SigilName instead of the default fixed-size StackString[48]. The performance profile differs.
  • Chronos threads: enable the chronos package feature to make SigilChronosThread available through sigils/threads.
  • CBOR-RPC: enable the cbor or ipc package feature, import sigils/rpcs/cborRpc, and choose a selector or Chronos transport. The Chronos transport also requires the chronos feature.
  • JSON-RPC: import sigils/rpcs/jsonrpc and a desired sigils/rpcs/json/jr* transport module directly; jrStdio and jrFraming provide LSP-compatible stdin/stdout framing; no package feature or compile-time define is required.
  • Package features can be requested by dependents with requires "sigils[sigNameAsString, closures, chronos, ipc]".

Examples

import sigils

type
  Counter*[T] = ref object of Agent
    value: T

proc valueChanged*[T](tp: Counter[T], val: T) {.signal.}

proc setValue*[T](self: Counter[T], value: T) {.slot.} =
  echo "setValue! ", value
  if self.value != value:
    # we want to be careful not to set circular triggers
    self.value = value
    emit self.valueChanged(value)

var
  a = Counter[uint]()
  b = Counter[uint]()
  c = Counter[uint]()

connect(a, valueChanged,
        b, setValue)
connect(a, valueChanged,
        c, setValue)

doAssert b.value == 0
doAssert c.value == 0
emit a.valueChanged(137)

doAssert a.value == 0
doAssert b.value == 137
doAssert c.value == 137

Alternative Connect for Slots

Sometimes the Nim compiler can't determine the which slot you want to use just by the types passed into the connect template. Other times you may want to specify a parent type's slot.

The {.slot.} pragma generates some helper procs for these scenarios to allow you to ensure the specific slot passed to connect. These helpers procs take the type of their agent (the target) as the first argument. It looks like this:

let b = Counter[uint]()
connect(a, valueChanged,
        b, Counter[uint].setValue)

a.setValue(42) # we can directly call `setValue` which will then call emit

doAssert a.value == 42
doAssert b.value == 42

The {.signal.} pragma generates these provide several helper procs to make it easy to get the type of the signal argument. The SignalTypes types is used as the first argument to differentiate from normal invocation of signals. Here are some examples:

test "signal / slot types":
  doAssert SignalTypes.avgChanged(Counter[uint]) is (float, )
  doAssert SignalTypes.valueChanged(Counter[uint]) is (uint, )
  doAssert SignalTypes.setValue(Counter[uint]) is (uint, )

Threads

Move an AgentActor to a worker with moveToThread, then use the returned AgentProxy to send it work. Connect signals and slots with connectThreaded. The worker owns the actor's state; your application receives replies by processing its local scheduler's queue.

This complete example sends a value to a background counter and waits for its reply before shutting down:

import sigils
import sigils/threads

type
  App = ref object of Agent
    received: bool
    value: int
  Counter = ref object of AgentActor
    value: int

proc changeRequested(self: App, value: int) {.signal.}
proc updated(self: Counter, value: int) {.signal.}

proc setValue(self: Counter, value: int) {.slot.} =
  self.value = value
  emit self.updated(value)

proc record(self: App, value: int) {.slot.} =
  self.value = value
  self.received = true

startLocalThreadDefault()
let home = getCurrentSigilThread()
let worker = newSigilThread()
worker.start()

try:
  # This scope releases the proxy before we stop the worker.
  block:
    let app = App()
    var counter = Counter()
    let proxy = counter.moveToThread(worker)

    connectThreaded(app, changeRequested, proxy, setValue)
    connectThreaded(proxy, updated, app, App.record())

    emit app.changeRequested(42)

    # Wait for the reply, running local callbacks as they arrive.
    while not app.received:
      discard home.poll()
    doAssert app.value == 42
finally:
  worker.setRunning(false)
  worker.join()

Compile with --mm:arc --threads:on (ORC also works). The actor must have a unique strong reference when moved. After the move, use its proxy for communication.

For an owned value such as a parsed document, declare both the signal payload and the receiving slot parameter as sink Document. Emit a temporary or use ensureMove(document) at its last use. Sigils moves sink fields through the argument tuple and consumes the final delivery, including replies forwarded through a worker proxy. Earlier fanout deliveries receive copies; direct local fanout retains reference identity, while packed/threaded fanout follows the subscription's clone policy.

An explicitly retained packed request remains reusable with emit((agent, request)) or callSlotsCopy. Low-level callSlots takes ownership of its request, and rpcUnpackMove destructively extracts its payload; do not retain aliases to that payload. Direct rpcPack(Isolated[T]) supports extraction without a cloner, so cloning it raises ValueError. This overload does not make tuples containing Isolated[T] valid signal payloads. JSON and CBOR still deserialize values.

Receiver-bound closure environments stay on their creating thread. Moving a signal source preserves its local closure subscriptions and disconnect handles. Disconnect receiver-bound closures before moving their receiver; moveToThread rejects that move before changing the connections. Endpoint delivery also reports an error if it would have to send a receiver-bound environment to another thread.

poll() waits for a message; pollAll() processes messages until the queue is empty and returns. In a GUI, use pollAll() when the application loop wakes. Calling it once immediately after sending a request does not guarantee that the reply has arrived.

Live proxies keep their remote actors alive. Ordinary sends let queues grow; limit outstanding work if a producer can outpace its worker. For ownership, queue limits, shutdown, and choosing between a single worker, a thread pool, selectors, asyncdispatch, or Chronos, see the threading guide.

sigils/isolateutils provides isolateRuntime(move payload) for runtime checks of unique refs in declared fields and nested sequences or arrays. Elements with ref-free types are skipped. An IsolationError means a supported ref has more than one owner; this also rejects sharing within the payload, including cycles. It does not prove general graph ownership. Dynamic subtype fields, distinct wrappers, closures, and raw/shared pointers are outside the checker; weak refs are deliberately skipped, and SharedPtr retains its explicit sharing contract.

For exposing typed selectors through JSON-RPC 2.0 on a local selector scheduler, a selector helper thread, a Chronos helper thread, or an LSP-style stdin/stdout transport, see the JSON-RPC adapter guide.

Siwin application loop

With the siwin package feature enabled, attach Siwin to the application thread's existing scheduler before creating proxies whose home is that thread. The scheduler retains a copied EventLoopWaker, not the complete SiwinGlobals:

import sigils
import siwin

let globals = newSiwinGlobals()
let window = globals.newSoftwareRenderingWindow(title = "Sigils + Siwin")
let sigilThread = installSiwinEventLoopWaker(globals)

while not window.closed:
  globals.waitEvents()
  discard sigilThread.pollAll()
  window.serviceWindow()

Every successful Sigils send publishes its message to the destination queue before waking Siwin. Wake notifications may be coalesced, so always drain the queue after waitEvents returns. The same hook can be installed explicitly on a SigilThreadDefault, SigilSelectorThread, AsyncSigilThread, or SigilChronosThread with thread.toSigilThread().installSiwinEventLoopWaker(globals). This preserves the scheduler and its own wake mechanism; it only bridges messages enqueued in its Sigils queue. Selector file-descriptor readiness and selector/Chronos timer deadlines still need to be serviced by their respective event loops.

The underlying integration is generic: each SigilThread retains a sequence of SigilThreadWakeCallback closures registered with addWakeCallback. This lets other event queues add their own thread-safe, nonblocking wake primitive without changing the scheduler types. Registration invokes the callback once immediately so already-queued work is not stranded. After stopping producers, clearWakeCallbacks releases all integrations retained by that scheduler.

Producers may use the scheduler from worker threads, but install the waker before starting them. The Siwin globals and native event pump stay on the application thread.

CBOR RPC and IPC

The generic CBOR-RPC layer carries typed selectors, slots, and signal notifications. Registering an endpoint installs CBOR codecs for that endpoint's argument and result types; local selectors never instantiate those codecs. Import sigils/rpcs/cborRpc for routing, then import sigils/rpcs/cbor/crSelector or sigils/rpcs/cbor/crChronos for the desired scheduler. CborRpcIoAgent is the extension point for other transports. Selector I/O can run locally or on a helper selector thread; Chronos I/O runs on a helper Chronos thread.

The existing sigils/ipc module remains as a compatibility interface for the bidirectional Chronos peer API. A Unix-family Chronos address uses a Unix-domain socket on POSIX and a named pipe on Windows.

protocol Calculator:
  method addNumbers(left, right: int): int

protocol CalculatorService of Calculator:
  method addNumbers(self: DynamicAgent, left, right: int): int =
    left + right

let calculator = DynamicAgent().withProtocol(CalculatorService)
let router = newIpcRouter()
router.registerSelector("calculator", calculator, addNumbers)

createIpcServer, connectIpc, and callSelector complete the Chronos side of the flow. Build the example, then launch its server and client modes in separate terminals:

nim c examples/chronos_ipc.nim
./examples/chronos_ipc server
./examples/chronos_ipc client

Append .exe to the executable name on Windows. See examples/chronos_ipc.nim for the complete example and docs/ipc.md for the framing design and the WebSocket/CoAP comparison.

Closures

type
  Counter* = ref object of Agent

test "callback creation":
  var
    a = Counter()
    b = Counter(value: 100)

  let
    clsAgent =
      connectTo(a, valueChanged) do (val: int):
        b.value = val
  
  emit a.valueChanged(42)
  check b.value == 42 # callback modifies base
                      # beware capturing values like this
                      # it causes headaches, but can be handy
  check clsAgent.typeof() is ClosureAgent[(int,)]

Advanced

Signal names aren't string types for performance considerations. Instead they're arrays with a maximum name size of 48 bytes currently. This can be changed if needed.

Overriding Destructors

Overriding the =destroy destructors will result in bad things if you don't properly call the Agent destructor. See the following code for how to do this. Note that calling =destroy directly with casts doesn't seem to work.

type CounterWithDestroy* = ref object of Agent

proc `=destroy`*(x: var typeof(CounterWithDestroy()[])) =
  echo "CounterWithDestroy:destroy: ", x.debugName
  destroyAgent(x)

Void Slots

There's an exception to the type checking. It's common in UI programming to want to trigger a slot without caring about the actual values in the signal. To achieve this you can call connect like this:

proc valueChanged*(tp: Counter, val: int) {.signal.}

proc someAction*(self: Counter) {.slot.} =
  echo "action"

connect(a, valueChanged, c, someAction, acceptVoidSlot = true)
emit a.valueChange(42)

Now whenever valueChanged is emitted then someAction will be triggered.

WeakRefs

Calling connect does not create a new reference of either the target or source agents. This is done primarily to prevent cycles from being created accidentally. This is necessary for easing UI development with Sigil.

However, Agent objects are still memory safe to use. They have a destructor which removes an Agent from any of it's "listeners" connections to ensure freed agents aren't signaled after they're freed. Nifty!

Note however, that means you need to ensure your Agent's aren't destroyed before you're done with them. This applies to threaded signals using AgentProxy[T] as well.

Serialization

Internally sigils was based on an RPC system. There are many similarities when calling typed compiled functions in a generic fashion.

To wit sigils supports multiple serialization methods. The default uses variant. In theory we could use the any type. Additionally JSON and CBOR methods are also supported by passing -d:sigilsCborSerde or -d:sigilsJsonSerde. These can be useful for backeneds such as NimScript and JavaScript. Using CBOR can be handy for networking which might be added in the future.

Multiple Threads

This example sends one signal to two different agents living on two different threads, then collects both results back on the main thread.

import sigils, sigils/threads

type
  Trigger = ref object of AgentActor
  Worker = ref object of AgentActor
    value: int
  Collector = ref object of AgentActor
    a: int
    b: int

proc valueChanged(tp: Trigger, val: int) {.signal.}
proc updated(tp: Worker, final: int) {.signal.}

proc setValue(self: Worker, value: int) {.slot.} =
  self.value = value
  echo "worker:setValue: ", value, " (th: ", getThreadId(), ")"
  emit self.updated(self.value)

proc gotA(self: Collector, final: int) {.slot.} =
  echo "collector: gotA: ", final, " (th: ", getThreadId(), ")"
  self.a = final

proc gotB(self: Collector, final: int) {.slot.} =
  echo "collector: gotB: ", final, " (th: ", getThreadId(), ")"
  self.b = final

let trigger = Trigger()
let collector = Collector()

let threadA = newSigilThread()
let threadB = newSigilThread()
threadA.start()
threadB.start()
startLocalThreadDefault()

var wA = Worker()
var wB = Worker()

let workerA: AgentProxy[Worker] = wA.moveToThread(threadA)
let workerB: AgentProxy[Worker] = wB.moveToThread(threadB)

connectThreaded(trigger, valueChanged, workerA, setValue)
connectThreaded(trigger, valueChanged, workerB, setValue)
connectThreaded(workerA, updated, collector, Collector.gotA())
connectThreaded(workerB, updated, collector, Collector.gotB())

emit trigger.valueChanged(42)

let ct = getCurrentSigilThread()
discard ct.poll() # workerA result
discard ct.poll() # workerB result
doAssert collector.a == 42
doAssert collector.b == 42

setRunning(threadA, false)
setRunning(threadB, false)
threadA.join()
threadB.join()

Selectors

Sigils also includes selectors: a dynamic dispatch layer for delegate-style behavior, responder chains, protocols, and method wrappers when a signal is the wrong shape because you need one answer back. See docs/selectors.md for the guide and examples.

About

Sigils - a slot and signals implementation for the Nim programming language

Resources

Stars

52 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages