├── tests ├── config.nims ├── test_server.nim ├── test_server_complex_messages.nim └── test_async.nim ├── examples ├── config.nims ├── owlkettle │ ├── config.nims │ ├── ex_owlkettle.nim │ ├── ex_owlkettle_customloop.nim │ ├── servers.nim │ └── widget.nim ├── ex_stdinput_no_server.nim ├── ex_stdinput.nim ├── ex_stdinput_async.nim ├── ex_server.nim ├── ex_stdinput_illegalaccess.nim ├── ex_stdinput_tasks.nim ├── ex_stdinput_3threads.nim ├── ex_stdinput_customloop.nim ├── stresstest.nim └── ex_benchmark.nim ├── assets ├── architecture.png ├── app_architecture.png ├── architecture.drawio └── app_architecture.drawio ├── docs └── book │ ├── index.nim │ ├── contributing.nim │ ├── reference.nim │ ├── integrations.nim │ ├── glossary.nim │ ├── examples.nim │ ├── flags.nim │ ├── threadServer.nim │ ├── generatedCodeDocs.nim │ ├── leaks.nim │ └── basics.nim ├── nimib.toml ├── src ├── threadButler │ ├── log.nim │ ├── integration │ │ ├── owlButler.nim │ │ └── owlCodegen.nim │ ├── events.nim │ ├── utils.nim │ ├── types.nim │ ├── channelHub.nim │ ├── validation.nim │ ├── register.nim │ └── codegen.nim └── threadButler.nim ├── .github ├── dependabot.yml └── workflows │ ├── docs.yml │ └── tests.yml ├── nbook.nim ├── CONTRIBUTING.md ├── README.md └── threadButler.nimble /tests/config.nims: -------------------------------------------------------------------------------- 1 | switch("path", "$projectDir/../src") -------------------------------------------------------------------------------- /examples/config.nims: -------------------------------------------------------------------------------- 1 | switch("path", "$projectDir/../src") -------------------------------------------------------------------------------- /examples/owlkettle/config.nims: -------------------------------------------------------------------------------- 1 | switch("path", "$projectDir/../../src") -------------------------------------------------------------------------------- /assets/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhilippMDoerner/ThreadButler/HEAD/assets/architecture.png -------------------------------------------------------------------------------- /assets/app_architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhilippMDoerner/ThreadButler/HEAD/assets/app_architecture.png -------------------------------------------------------------------------------- /docs/book/index.nim: -------------------------------------------------------------------------------- 1 | import nimib, nimibook 2 | 3 | nbInit(theme = useNimibook) 4 | 5 | nbText: readFile("../../README.md") 6 | 7 | nbSave 8 | 9 | -------------------------------------------------------------------------------- /docs/book/contributing.nim: -------------------------------------------------------------------------------- 1 | import nimib, nimibook 2 | 3 | nbInit(theme = useNimibook) 4 | 5 | nbText: readFile("../../CONTRIBUTING.md") 6 | 7 | nbSave 8 | 9 | -------------------------------------------------------------------------------- /docs/book/reference.nim: -------------------------------------------------------------------------------- 1 | import nimib, nimibook 2 | 3 | 4 | nbInit(theme = useNimibook) 5 | 6 | nbText: """ 7 | # Reference 8 | 9 | This section provides various pieces of reference documentation that do not 10 | have a proper place inside of the regular reference docs. 11 | """ 12 | 13 | nbSave -------------------------------------------------------------------------------- /nimib.toml: -------------------------------------------------------------------------------- 1 | [nimib] 2 | srcDir = "docs/book" 3 | homeDir = "docs/bookCompiled" 4 | 5 | [nimibook] 6 | language = "English" 7 | title = "ThreadButler" 8 | description = "Easy multithreading with a client-server model" 9 | git_repository_url = "https://github.com/PhilippMDoerner/ThreadButler" 10 | 11 | -------------------------------------------------------------------------------- /src/threadButler/log.nim: -------------------------------------------------------------------------------- 1 | import chronicles 2 | export chronicles 3 | ##[ 4 | 5 | A simple module handling logging within threadbutler, using std/logging. 6 | The log-level is set at compile-time using the `-d:butlerloglevel=` (e.g. `-d:butlerloglevel='lvlAll'`) compiler flag. 7 | Logging at a specific level is compiled in or out based on the log-level allowed at compile-time. 8 | 9 | This module is only intended for use within threadButler and for integrations. 10 | ]## 11 | -------------------------------------------------------------------------------- /examples/owlkettle/ex_owlkettle.nim: -------------------------------------------------------------------------------- 1 | import std/[options] 2 | import threadButler 3 | import threadButler/integration/owlButler 4 | import owlkettle 5 | import ./widget 6 | 7 | proc main() = 8 | let hub = new(ChannelHub) 9 | 10 | hub.withServer(SERVER_THREAD): 11 | let listener = createListenerEvent(hub, AppState, CLIENT_THREAD) 12 | owlkettle.brew( 13 | gui(App(server = hub)), 14 | startupEvents = [listener] 15 | ) 16 | 17 | hub.destroy() 18 | 19 | main() 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /nbook.nim: -------------------------------------------------------------------------------- 1 | import nimibook 2 | 3 | var book = initBookWithToc: 4 | entry("Welcome to ThreadButler!", "index.nim") 5 | entry("Getting Started", "basics.nim") 6 | entry("Examples", "examples.nim") 7 | entry("Integrations", "integrations.nim") 8 | entry("Memory Leak FAQ", "leaks.nim") 9 | section("Reference Docs", "reference.nim"): 10 | entry("Flags", "flags.nim") 11 | entry("ThreadServer", "threadServer.nim") 12 | entry("Docs for generated code", "generatedCodeDocs.nim") 13 | entry("Glossary", "glossary.nim") 14 | entry("Contributing", "contributing.nim") 15 | nimibookCli(book) 16 | -------------------------------------------------------------------------------- /examples/owlkettle/ex_owlkettle_customloop.nim: -------------------------------------------------------------------------------- 1 | import std/[options, tables] 2 | import threadButler 3 | import threadButler/integration/owlButler 4 | import owlkettle 5 | import ./widget 6 | import chronicles 7 | 8 | proc runServerLoop(client: Server[ClientMessage]) {.gcsafe.} = 9 | # Gui Thread within the context of having a server thread 10 | echo "Clientloop" 11 | let listener = createListenerEvent(client.hub, AppState, CLIENT_THREAD) 12 | {.gcsafe.}: 13 | owlkettle.brew( 14 | gui(App(server = client.hub)), 15 | startupEvents = [listener] 16 | ) 17 | 18 | proc main() = 19 | let hub = new(ChannelHub) 20 | 21 | hub.withServer(SERVER_THREAD): # Runs "Backend" Server 22 | hub.withServer(CLIENT_THREAD): # Runs owlkettle gui 23 | while keepRunning(): 24 | let terminalInput = readLine(stdin) 25 | discard hub.sendMessage(terminalInput.Response) 26 | destroy(hub) 27 | 28 | main() 29 | -------------------------------------------------------------------------------- /examples/owlkettle/servers.nim: -------------------------------------------------------------------------------- 1 | import threadButler 2 | import threadButler/integration/owlButler 3 | import std/[sugar] 4 | import chronicles 5 | 6 | const CLIENT_THREAD* = "client" 7 | const SERVER_THREAD* = "server" 8 | type Response* = distinct string 9 | type Request* = distinct string 10 | 11 | threadServer(SERVER_THREAD): 12 | properties: 13 | startUp = @[ 14 | initEvent(() => debug "Server startin up!") 15 | ] 16 | shutDown = @[initEvent(() => debug "Server shutting down!")] 17 | 18 | messageTypes: 19 | Request 20 | 21 | handlers: 22 | proc handleRequest*(msg: Request, hub: ChannelHub) = 23 | let resp = Response(fmt("Response to: {msg.string}")) 24 | discard hub.sendMessage(resp) 25 | 26 | owlThreadServer(CLIENT_THREAD): 27 | messageTypes: 28 | Response 29 | 30 | handlers: 31 | proc handleResponse(msg: Response, hub: ChannelHub, state: AppState) = 32 | debug "On Client: Handling msg: ", msg = msg.string 33 | state.receivedMessages.add(msg.string) 34 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: github pages 2 | 3 | # Execute this workflow only for Pushes to your main branch, not for PRs 4 | on: 5 | push: 6 | branches: 7 | - main 8 | 9 | 10 | # Provides the implicitly used and hidden GITHUB_TOKEN the necessary permissions to deploy github_pages 11 | permissions: 12 | contents: write 13 | pages: write 14 | id-token: write 15 | 16 | # Execute a job called "api-docs" 17 | jobs: 18 | api-docs: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup nim 25 | uses: jiro4989/setup-nim-action@v1 26 | with: 27 | nim-version: '2.0.0' 28 | 29 | - run: nimble install -Y 30 | 31 | 32 | - name: Build reference docs 33 | run: nimble docs 34 | 35 | - name: Build nimibook docs 36 | run: nimble nimidocs 37 | 38 | - name: Copy files to _site directory 39 | run: | 40 | mkdir _site 41 | cp -r ./docs/htmldocs _site 42 | cp -r ./docs/bookCompiled _site 43 | 44 | - name: Upload _site directory for deploy job 45 | uses: actions/upload-pages-artifact@v3 # This will automatically upload an artifact from the '/_site' directory 46 | 47 | 48 | # Deploy _site directory with permissions of GITHUB_TOKEN 49 | deploy: 50 | environment: 51 | name: github-pages 52 | runs-on: ubuntu-latest 53 | needs: api-docs 54 | steps: 55 | - name: Deploy to GitHub Pages 56 | id: deployment 57 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /examples/ex_stdinput_no_server.nim: -------------------------------------------------------------------------------- 1 | import threadButler 2 | import std/[logging, options, strformat] 3 | import taskpools 4 | 5 | addHandler(newConsoleLogger(fmtStr="[CLIENT $levelname] ")) 6 | 7 | type TerminalInput = distinct string 8 | 9 | proc requestUserInput(hub: ChannelHub) {.gcsafe, raises: [].} 10 | 11 | var threadPool: TaskPool 12 | 13 | threadServer("client"): 14 | properties: 15 | taskPoolSize = 4 16 | startUp = @[initCreateTaskpoolEvent(size = 2, threadPool)] 17 | shutDown = @[initDestroyTaskpoolEvent(threadPool)] 18 | messageTypes: 19 | TerminalInput 20 | 21 | handlers: 22 | proc userInputReceived(msg: TerminalInput, hub: ChannelHub) = 23 | let msg = msg.string 24 | case msg: 25 | of "kill", "q", "quit": 26 | shutdownServer() 27 | else: 28 | debug "New message: ", msg 29 | threadPool.spawn requestUserInput(hub) 30 | 31 | 32 | prepareServers() 33 | 34 | proc requestUserInput(hub: ChannelHub) {.gcsafe, raises: [].} = 35 | echo "\nType in a message to send to the Backend!" 36 | try: 37 | let terminalInput = readLine(stdin) # This is blocking, so this while-loop doesn't run and thus no responses are read unless the user puts something in 38 | echo fmt"send: Thread '{getThreadId()}' => ClientMessage(kind: TerminalInputKind, terminalInputMsg: '{terminalInput}')" 39 | discard hub.sendMessage(terminalInput.TerminalInput) 40 | except IOError, ChannelHubError, ValueError: 41 | echo "Failed to read in input" 42 | 43 | 44 | proc main() = 45 | let hub = new(ChannelHub) 46 | 47 | requestUserInput(hub) 48 | let server = initServer(hub, ClientMessage) 49 | serverProc[ClientMessage](server) 50 | hub.clearServerChannel(ClientMessage) 51 | 52 | destroy(hub) 53 | 54 | main() -------------------------------------------------------------------------------- /examples/owlkettle/widget.nim: -------------------------------------------------------------------------------- 1 | import owlkettle 2 | import threadButler 3 | import threadButler/integration/owlButler 4 | import ./servers 5 | import std/[strformat] 6 | 7 | export servers 8 | 9 | viewable App: 10 | server: ChannelHub 11 | inputText: string 12 | receivedMessages: seq[string] 13 | 14 | proc sendAppMsg(app: AppState) = 15 | discard app.server.sendMessage(app.inputText.Request) 16 | app.inputText = "" 17 | 18 | method view(app: AppState): Widget = 19 | result = gui: 20 | Window: 21 | defaultSize = (500, 150) 22 | title = "Client Server Example" 23 | 24 | Box(orient = OrientY, margin = 12, spacing = 6): 25 | Box(orient = OrientX) {.expand: false.}: 26 | Entry(placeholder = "Send message to server!", text = app.inputText): 27 | proc changed(newText: string) = 28 | app.inputText = newText 29 | proc activate() = 30 | app.sendAppMsg() 31 | 32 | Button {.expand: false}: 33 | style = [ButtonSuggested] 34 | proc clicked() = 35 | app.sendAppMsg() 36 | 37 | Box(orient = OrientX, spacing = 6): 38 | Label(text = "send") {.vAlign: AlignFill.} 39 | Icon(name = "mail-unread-symbolic") {.vAlign: AlignFill, hAlign: AlignCenter, expand: false.} 40 | 41 | Button(): 42 | Label(text = "Murder") 43 | proc clicked() = 44 | shutdownAllServers() 45 | 46 | Separator(margin = Margin(top: 24, bottom: 24, left: 0, right: 0)) 47 | 48 | Label(text = "Responses from server:", margin = Margin(bottom: 12)) 49 | for msg in app.receivedMessages: 50 | Label(text = msg) {.hAlign: AlignStart.} 51 | 52 | export AppState, App 53 | 54 | prepareOwlServers(App) 55 | -------------------------------------------------------------------------------- /docs/book/integrations.nim: -------------------------------------------------------------------------------- 1 | import nimib, nimibook 2 | 3 | 4 | nbInit(theme = useNimibook) 5 | 6 | nbText: """ 7 | # Integrations 8 | 9 | This page provides examples for the various frameworks that 10 | threadButler provides special integration utilities for. 11 | 12 | ## [Owlkettle](https://github.com/PhilippMDoerner/ThreadButler/blob/main/examples/owlkettle/ex_owlkettle.nim) 13 | Integration with owlkettle works by threadButler essentially not providing an event-loop. 14 | Instead it hooks into owlkettle's GTK event-loop to listen for and react to messages. 15 | 16 | The following "Special rules" need to be kept in mind when running ThreadButler with owlkettle: 17 | - Add a field to `App` for the `Server` instance for the owlkettle thread. 18 | - Create a listener startup event and add it to the `brew` call 19 | - Use [`owlThreadServer`](https://philippmdoerner.github.io/ThreadButler/htmldocs/threadButler/integrations/owlCodegen.html#owlThreadServer) instead of `threadServer`, but only for your owlkettle thread
20 | Note: `owlThreadServer` requires handlers to have a different proc signature. See the reference docs for more details. 21 | - Use [`prepareOwlServers`](https://philippmdoerner.github.io/ThreadButler/htmldocs/threadButler/integrations/owlCodegen.html#prepareOwlServers) instead of `prepareServers` 22 | 23 | In order to make data from messages available within owlkettle, add more fields 24 | to `AppState`. 25 | You can then assign values from your messages to those fields and use them 26 | in your `view` method. 27 | 28 | ## [Owlkettle - Custom Event Loop](https://github.com/PhilippMDoerner/ThreadButler/blob/main/examples/owlkettle/ex_owlkettle_customloop.nim) 29 | Demonstrates how to put owlkettle's GTK event-loop into its own threadServer, essentially turning this into a 3 threads setup 30 | with a main-thread, a thread for owlkettle and a thread for the server. 31 | 32 | Listens for user-input from the terminal and sends it to the owlkettle thread. 33 | """ 34 | 35 | nbSave -------------------------------------------------------------------------------- /src/threadButler/integration/owlButler.nim: -------------------------------------------------------------------------------- 1 | import std/[options] 2 | import pkg/owlkettle 3 | import ./owlCodegen 4 | import ../codegen 5 | import ../channelHub 6 | import ../../threadButler 7 | 8 | export 9 | owlCodegen.owlThreadServer, 10 | owlCodegen.prepareOwlServers 11 | 12 | ##[ 13 | Utilities for easier integration with [Owlkettle](https://github.com/can-lehmann/owlkettle). 14 | ]## 15 | 16 | proc addServerListener[State: WidgetState, CMsg]( 17 | app: State, 18 | hub: ChannelHub, 19 | clientMsgType: typedesc[CMsg], 20 | sleepMs: int 21 | ) = 22 | ## Adds a callback function to the GTK app that checks every 5 ms whether the 23 | ## server sent a new message and routes it to the handler proc registered for that 24 | ## message kind. Triggers a UI update after executing the handler. 25 | 26 | mixin routeMessage 27 | proc listener(): bool = 28 | let msg = hub.readMsg(clientMsgType) 29 | if msg.isSome(): 30 | routeMessage(msg.get(), hub, app) 31 | discard app.redraw() 32 | 33 | const KEEP_LISTENER_ACTIVE = true 34 | return KEEP_LISTENER_ACTIVE 35 | 36 | discard addGlobalTimeout(sleepMs, listener) 37 | 38 | proc createListenerEvent*[State: WidgetState]( 39 | hub: ChannelHub, 40 | stateType: typedesc[State], 41 | threadName: static string, 42 | sleepMs: int = 5 43 | ): ApplicationEvent = 44 | ## Creates an Owlkettle.ApplicationEvent that registers a global timeout with owlkettle. 45 | ## Owlkettle executes these events when the owlkettle application starts. 46 | ## 47 | ## The global timeout checks for and routes new messages every `sleepMs` for the 48 | ## `threadName` threadServer on `hub`. 49 | ## 50 | ## The handler-proc for that message type then gets executed with the message, `hub` and 51 | ## owlkettle's root widget's WidgetState `stateType`. 52 | proc(state: WidgetState) = 53 | let state = stateType(state) 54 | addServerListener[State, threadName.toVariantType()](state, hub, threadName.toVariantType(), sleepMs) -------------------------------------------------------------------------------- /examples/ex_stdinput.nim: -------------------------------------------------------------------------------- 1 | import threadButler 2 | import std/[sugar, options, os] 3 | import chronicles 4 | 5 | const CLIENT_THREAD = "client" 6 | const SERVER_THREAD = "server" 7 | type Response = distinct string 8 | type Request = distinct string 9 | 10 | threadServer(CLIENT_THREAD): 11 | messageTypes: 12 | Response 13 | 14 | handlers: 15 | proc handleResponseOnClient(msg: Response, hub: ChannelHub) = 16 | debug "On Client: ", msg = msg.string 17 | 18 | threadServer(SERVER_THREAD): 19 | properties: 20 | startUp = @[ 21 | initEvent(() => debug "Server startin up!") 22 | ] 23 | shutDown = @[initEvent(() => debug "Server shutting down!")] 24 | 25 | messageTypes: 26 | Request 27 | 28 | handlers: 29 | proc handleRequestOnServer(msg: Request, hub: ChannelHub) = 30 | debug "On Server: ", msg = msg.string 31 | discard hub.sendMessage(Response("Handled: " & msg.string)) 32 | 33 | prepareServers() 34 | 35 | proc runClientLoop(hub: ChannelHub) = 36 | while keepRunning(): 37 | echo "\nType in a message to send to the Backend!" 38 | let terminalInput = readLine(stdin) # This is blocking, so this while-loop doesn't run and thus no responses are read unless the user puts something in 39 | if terminalInput == "kill": 40 | hub.sendKillMessage(ServerMessage) 41 | hub.clearServerChannel(ClientMessage) 42 | break 43 | 44 | elif terminalInput.len() > 0: 45 | let msg = terminalInput.Request 46 | discard hub.sendMessage(msg) 47 | 48 | ## Guarantees that we'll have the response from server before we listen for user input again. 49 | ## This is solely for better logging, do not use in actual code. 50 | sleep(100) 51 | 52 | let response: Option[ClientMessage] = hub.readMsg(ClientMessage) 53 | if response.isSome(): 54 | routeMessage(response.get(), hub) 55 | 56 | proc main() = 57 | let hub = new(ChannelHub) 58 | 59 | hub.withServer(SERVER_THREAD): 60 | runClientLoop(hub) 61 | 62 | destroy(hub) 63 | 64 | main() -------------------------------------------------------------------------------- /src/threadButler/events.nim: -------------------------------------------------------------------------------- 1 | import std/[asyncdispatch, sugar] 2 | import taskpools 3 | import chronicles 4 | 5 | ##[ 6 | Defines the Events that should happen when starting a thread-server or when shutting it down 7 | ]## 8 | 9 | type 10 | AsyncEvent* = proc(): Future[void] {.closure, gcsafe.} 11 | SyncEvent* = proc() {.closure, gcsafe.} 12 | 13 | Event* = object ## `startup` or `shutdown` event which is executed once. 14 | case async*: bool 15 | of true: 16 | asyncHandler*: AsyncEvent 17 | of false: 18 | syncHandler*: SyncEvent 19 | 20 | func initEvent*(handler: AsyncEvent): Event = 21 | ## Initializes a new asynchronous event. 22 | Event(async: true, asyncHandler: handler) 23 | 24 | func initEvent*(handler: SyncEvent): Event = 25 | ## Initializes a new synchronous event. 26 | Event(async: false, syncHandler: handler) 27 | 28 | proc exec*(event: Event) {.inline.} = 29 | ## Executes a single event 30 | if event.async: 31 | waitFor event.asyncHandler() 32 | else: 33 | event.syncHandler() 34 | 35 | proc execEvents*(events: seq[Event]) = 36 | ## Executes a list of events 37 | for event in events: 38 | event.exec() 39 | 40 | ## Premade Events 41 | template initCreateTaskpoolEvent*(size: int, taskPoolVar: untyped): Event = 42 | ## Convenience Utility for status/nim-taskpools. 43 | ## Creates an Event that creates/initializes the threadpool in the variable contained in `taskPoolVar`. 44 | block: 45 | proc createTaskpool() = 46 | taskPoolVar = Taskpool.new(numThreads = size) 47 | debug "Create Threadpool", poolPtr = cast[uint64](taskPoolVar) 48 | initEvent(() => createTaskpool()) 49 | 50 | template initDestroyTaskpoolEvent*(taskPoolVar: untyped): Event = 51 | ## Convenience Utility for status/nim-taskpools. 52 | ## Creates an Event that destroys the threadpool in the variable contained in `taskPoolVar`. 53 | block: 54 | proc destroyTaskpool() = 55 | taskPoolVar.shutDown() 56 | debug "Destroy Threadpool", poolPtr = cast[uint64](taskPoolVar) 57 | 58 | initEvent(() => destroyTaskpool()) -------------------------------------------------------------------------------- /examples/ex_stdinput_async.nim: -------------------------------------------------------------------------------- 1 | import threadButler 2 | import std/[sugar, logging, options, os, async, httpclient] 3 | 4 | addHandler(newConsoleLogger()) 5 | 6 | const MAIN_THREAD = "main" 7 | const SERVER_THREAD = "server" 8 | type Response = distinct string 9 | type Request = distinct string 10 | 11 | threadServer(MAIN_THREAD): 12 | messageTypes: 13 | Response 14 | 15 | handlers: 16 | proc handleResponse(msg: Response, hub: ChannelHub) = 17 | debug "Finally received: ", msg = msg.string 18 | 19 | threadServer(SERVER_THREAD): 20 | properties: 21 | startUp = @[ 22 | initEvent(() => debug "Server startin up!") 23 | ] 24 | shutDown = @[initEvent(() => debug "Server shutting down!")] 25 | 26 | messageTypes: 27 | Request 28 | 29 | handlers: 30 | proc handleRequestOnServer(msg: Request, hub: ChannelHub) {.async.} = 31 | let client = newAsyncHttpClient() 32 | let page = await client.getContent("http://www.google.com/search?q=" & msg.string) 33 | discard hub.sendMessage(Response(page)) 34 | 35 | 36 | prepareServers() 37 | 38 | let hub = new(ChannelHub) 39 | 40 | hub.withServer(SERVER_THREAD): 41 | when not defined(butlerDocs): 42 | while keepRunning(): 43 | echo "\nType in a message to send to the Backend for a google request!" 44 | # This is blocking, so this while-loop stalls here until the user hits enter. 45 | # Thus the entire loop only runs once whenever the user hits enter. 46 | # Thus it can only receive one message per enter press. 47 | let terminalInput = readLine(stdin) 48 | case terminalInput 49 | of "kill", "q", "quit": 50 | hub.sendKillMessage(ServerMessage) 51 | hub.clearServerChannel(MainMessage) 52 | break 53 | else: 54 | let msg = terminalInput.Request 55 | discard hub.sendMessage(msg) 56 | 57 | ## Guarantees that the server has responded before we listen for user input again. 58 | ## This is solely for neater logging when running the example. 59 | sleep(10) 60 | 61 | let response: Option[MainMessage] = hub.readMsg(MainMessage) 62 | if response.isSome(): 63 | routeMessage(response.get(), hub) 64 | 65 | destroy(hub) -------------------------------------------------------------------------------- /examples/ex_server.nim: -------------------------------------------------------------------------------- 1 | import threadButler 2 | import std/[sugar, options, asyncdispatch] 3 | 4 | const CLIENT_THREAD = "client" 5 | const SERVER_THREAD = "server" 6 | type Response = distinct string 7 | type Request = distinct string 8 | 9 | threadServer(CLIENT_THREAD): 10 | properties: 11 | startUp = @[] 12 | shutDown = @[] 13 | 14 | messageTypes: 15 | Response 16 | 17 | handlers: 18 | proc handleResponseOnClient(msg: Response, hub: ChannelHub) {.async, gcsafe.} = 19 | debug "On Client: ", msg = msg.string 20 | await sleepAsync(500) 21 | debug "Post sleep" 22 | discard hub.sendMessage(Request("Continue: " & msg.string)) 23 | 24 | threadServer(SERVER_THREAD): 25 | properties: 26 | startUp = @[ 27 | initEvent(() => debug "Server startin up!") 28 | ] 29 | shutDown = @[initEvent(() => debug "Server shutting down!")] 30 | 31 | messageTypes: 32 | Request 33 | 34 | handlers: 35 | proc handleRequestOnServer(msg: Request, hub: ChannelHub) = 36 | debug "On Server: ", msg = msg.string 37 | discard hub.sendMessage(Response("Handled: " & msg.string)) 38 | 39 | prepareServers() 40 | 41 | proc runClientLoop(hub: ChannelHub) = 42 | while keepRunning(): 43 | echo "\nType in a message to send to the Backend!" 44 | let terminalInput = readLine(stdin) # This is blocking, so this while-loop doesn't run and thus no responses are read unless the user puts something in 45 | if terminalInput == "kill": 46 | hub.sendKillMessage(ServerMessage) 47 | hub.clearServerChannel(ClientMessage) 48 | break 49 | 50 | elif terminalInput.len() > 0: 51 | let msg = terminalInput.Request 52 | discard hub.sendMessage(msg) 53 | 54 | ## Guarantees that we'll have the response from server before we listen for user input again. 55 | ## This is solely for better logging, do not use in actual code. 56 | if hasPendingOperations(): 57 | poll(100) 58 | 59 | let response: Option[ClientMessage] = hub.readMsg(ClientMessage) 60 | if response.isSome(): 61 | routeMessage(response.get(), hub) 62 | 63 | proc main() = 64 | let hub = new(ChannelHub) 65 | 66 | hub.withServer(SERVER_THREAD): 67 | runClientLoop(hub) 68 | 69 | hub.destroy() 70 | 71 | main() -------------------------------------------------------------------------------- /examples/ex_stdinput_illegalaccess.nim: -------------------------------------------------------------------------------- 1 | import threadButler 2 | import std/[sugar, logging, options, os] 3 | 4 | const CLIENT_THREAD = "client" 5 | const SERVER_THREAD = "server" 6 | type Response = distinct string 7 | type Request = ref object 8 | text: ref string 9 | 10 | threadServer(CLIENT_THREAD): 11 | messageTypes: 12 | Response 13 | 14 | handlers: 15 | proc handleResponseOnClient(msg: Response, hub: ChannelHub) = 16 | debug "On Client: ", msg = msg.string 17 | 18 | threadServer(SERVER_THREAD): 19 | properties: 20 | startUp = @[ 21 | initEvent(() => debug "Server startin up!") 22 | ] 23 | shutDown = @[initEvent(() => debug "Server shutting down!")] 24 | 25 | messageTypes: 26 | Request 27 | 28 | handlers: 29 | proc handleRequestOnServer(msg: Request, hub: ChannelHub) = 30 | debug "On Server: ", msgPtr = cast[uint64](msg) 31 | 32 | discard hub.sendMessage(Response("Handled: " & msg.text[])) 33 | 34 | prepareServers() 35 | 36 | proc runClientLoop(hub: ChannelHub) = 37 | while keepRunning(): 38 | echo "\nType in a message to send to the Backend!" 39 | let terminalInput = readLine(stdin) # This is blocking, so this while-loop doesn't run and thus no responses are read unless the user puts something in 40 | if terminalInput == "kill": 41 | hub.sendKillMessage(ServerMessage) 42 | hub.clearServerChannel(ClientMessage) 43 | break 44 | 45 | elif terminalInput.len() > 0: 46 | let str = new(string) 47 | str[] = terminalInput 48 | let msg = Request(text: str) 49 | debug "On Client: ", msgPtr = cast[uint64](msg) 50 | discard hub.sendMessage(msg) 51 | sleep(3000) 52 | debug "Unsafe access: ", msgPtr = cast[uint64](msg), msgContent = msg[].repr 53 | ## Guarantees that we'll have the response from server before we listen for user input again. 54 | ## This is solely for better logging, do not use in actual code. 55 | sleep(100) 56 | 57 | let response: Option[ClientMessage] = hub.readMsg(ClientMessage) 58 | if response.isSome(): 59 | routeMessage(response.get(), hub) 60 | 61 | proc main() = 62 | let hub = new(ChannelHub) 63 | 64 | hub.withServer(SERVER_THREAD): 65 | runClientLoop(hub) 66 | 67 | destroy(hub) 68 | 69 | main() -------------------------------------------------------------------------------- /docs/book/glossary.nim: -------------------------------------------------------------------------------- 1 | import nimib, nimibook 2 | 3 | 4 | nbInit(theme = useNimibook) 5 | 6 | nbText: """ 7 | # Glossary 8 | The core idea behind ThreadButler is to define **ThreadServers**. 9 | 10 | **ThreadServers** are long-running threads that receive and can send **messages** from other threads. 11 | 12 | **ThreadServers** have **ThreadNames**. 13 | 14 | **ThreadServers** run **server/event-loops** which define what the threadServer does. ThreadButler provides one by default, but it can be overwritten. 15 | 16 | **ThreadServers** can have **threadpools** to execute short-lived **tasks**. 17 | 18 | **ThreadNames** are used to associate **handlers** and **types** (or **message-types**) with them. 19 | 20 | The process of associating them and informing ThreadButler about which ThreadNames exist is called **registering**. 21 | 22 | **Messages** are any instances of a registered **message-type** that are being sent to the ChannelHub. 23 | 24 | **ThreadNames** also define the name of **Message-Wrapper-types**. 25 | 26 | **Message-Wrapper-types** are object variants that are generated from and thus can contain any message-type of one specific **ThreadServer**. 27 | 28 | **Message-Wrapper-types** therefore identify to which **ThreadServer** a message of a specific **message-type** is supposed to go. 29 | 30 | **Routing-procs** are generated procs that route a **message** received by a **ThreadServer** to its **handler**. 31 | 32 | **Sending** a message means calling a generated `sendMessage` proc on a message. 33 | 34 | **KillMessages** are special messages that can be sent via generated `sendKillMessage` procs to shut down a **ThreadServer**. 35 | 36 | **Channel** is a queue of messages to a given **ThreadServer**. Each **ThreadServer** has its own **Channel**. 37 | That **Channel** only carries the **1 Message-wrapper-type** specific for that **ThreadServer**. 38 | 39 | **ChannelHub** is an object containing all **channels**. It is the central place through which all **messages** are sent. 40 | 41 | **Handlers** are user-defined procs that get **registered** with ThreadButler. They get called when a **ThreadServer** receives a **message** for their type. 42 | They may be async, but must follow this proc pattern: 43 | ```nim 44 | proc (msg: , hub: ChannelHub) 45 | ``` 46 | """ 47 | 48 | nbSave() -------------------------------------------------------------------------------- /docs/book/examples.nim: -------------------------------------------------------------------------------- 1 | import nimib, nimibook 2 | 3 | nbInit(theme = useNimibook) 4 | 5 | nbText: """ 6 | # [Examples](https://github.com/PhilippMDoerner/ThreadButler/tree/main/examples) 7 | This is a list of further examples that show off various details of ThreadButler. 8 | You can also find them in their github folder. Their filenames have the `ex_` prefix. 9 | 10 | ## [Async](https://github.com/PhilippMDoerner/ThreadButler/blob/main/examples/ex_stdinput_async.nim) 11 | ThreadButlers own eventLoop comes with simple support for asynchronous handlers. 12 | 13 | Just annotate your handler with {.async.} and the code generated by `generateRouting` will adjust to account for that. 14 | 15 | ## [Custom Event Loops](https://github.com/PhilippMDoerner/ThreadButler/blob/main/examples/ex_stdinput_customloop.nim) 16 | ThreadButler allows replacing the default event-loop it uses for ThreadServers with your own. 17 | 18 | Therefore, if you need to supply your own, e.g. because you need to run the [event-loop from a GUI framework on that thread](https://github.com/PhilippMDoerner/ThreadButler/blob/main/examples/owlkettle/ex_owlkettle_customloop.nim), you can do so. 19 | 20 | Just overload the `runServerLoop` proc. 21 | 22 | ## [Tasks](https://github.com/PhilippMDoerner/ThreadButler/blob/main/examples/ex_stdinput_tasks.nim) 23 | ThreadButler itself does provide any threadpools. 24 | However, you can initialize and destroy any threadpool implementation you want 25 | using its startUp/shutDown events. 26 | 27 | To that end, threadButler provides convenience events to quickly set up: 28 | - [status-im/nim-taskpools](https://github.com/status-im/nim-taskpools) 29 | 30 | Keep in mind that the threadServer macro does **not** emit the handler procs defined inside of it. 31 | This is done by `prepareServers`. So if a handler spawns a task proc, that task proc must be available where you call `prepareServers`. 32 | 33 | ## [No Server](https://github.com/PhilippMDoerner/ThreadButler/blob/main/examples/ex_stdinput_no_server.nim) 34 | ThreadButler can be useful even without running a dedicated thread as a server. 35 | 36 | This example uses ThreadButlers code-generation to just set up a threadpool that sends messages back and handling those automatically. 37 | No threadServer is spawned, just the main-thread and a task-pool. 38 | """ 39 | 40 | nbSave() -------------------------------------------------------------------------------- /src/threadButler/utils.nim: -------------------------------------------------------------------------------- 1 | import std/[terminal, strformat, macros, sequtils] 2 | 3 | ##[ 4 | Defines utilities for making validation of individual steps in code-generation easy. 5 | 6 | This module is only intended for use within threadButler and for integrations. 7 | ]## 8 | 9 | proc assertKind*(node: NimNode, kind: seq[NimNodeKind], msg: string = "") = 10 | ## Custom version of expectKind, uses doAssert which can never be turned off. 11 | ## Use this throughout procs to validate that the nodes they get are of specific kinds. 12 | ## Also enables custom error messages. 13 | let boldCode = ansiStyleCode(styleBright) 14 | let msg = if msg == "": fmt"{boldCode} Expected a node of kind '{kind}', got '{node.kind}'" else: msg 15 | let errorMsg = msg & "\nThe node: " & node.repr & "\n" & node.treeRepr 16 | doAssert node.kind in kind, errorMsg 17 | 18 | proc assertKind*(node: NimNode, kind: NimNodeKind, msg: string = "") = 19 | assertKind(node, @[kind], msg) 20 | 21 | proc expectKind*(node: NimNode, kinds: seq[NimNodeKind], msg: string) = 22 | ## Custom version of expectKind, uses "error" which can be turned off. 23 | ## Use this within every macro to validate the user input 24 | ## Also enforces custom error messages to be helpful to users. 25 | if node.kind notin kinds: 26 | let boldCode = ansiStyleCode(styleBright) 27 | let msgEnd = fmt"Caused by: Expected a node of kind in '{kinds}', got '{node.kind}'" 28 | let errorMsg = boldCode & msg & "\n" & msgEnd 29 | error(errorMsg) 30 | 31 | proc getNodesOfKind*(node: NimNode, nodeKind: NimNodeKind): seq[NimNode] = 32 | ## Recursively traverses the AST in `node` and returns all nodes of the given 33 | ## `nodeKind`. 34 | for childNode in node.children: 35 | let isDesiredNode = childNode.kind == nodeKind 36 | if isDesiredNode: 37 | result.add(childNode) 38 | else: 39 | let desiredChildNodes: seq[NimNode] = getNodesOfKind(childNode, nodeKind) 40 | result.add(desiredChildNodes) 41 | 42 | proc isAsyncProc*(procDef: NimNode): bool = 43 | ## Checks if a given procDef represents an async proc 44 | procDef.assertKind(nnkProcDef) 45 | 46 | let resultType = procDef.params[0] 47 | let pragmaNodes: seq[NimNode] = procDef.getNodesOfKind(nnkPragma) 48 | return case pragmaNodes.len: 49 | of 0: 50 | false 51 | else: 52 | let pragmaNames = pragmaNodes[0].mapIt($it) 53 | pragmaNames.anyIt(it == "async") 54 | -------------------------------------------------------------------------------- /docs/book/flags.nim: -------------------------------------------------------------------------------- 1 | import nimib, nimibook 2 | 3 | 4 | nbInit(theme = useNimibook) 5 | 6 | nbText: """ 7 | # ThreadButler Compilation flags 8 | ThreadButler provides various compilation flags as options. 9 | 10 | This page serves as a reference list for all of them. 11 | 12 | Flag | Example | Description 13 | ---|---|--- 14 | butlerDebug | -d:butlerDebug | Prints all generated code to the terminal 15 | butlerThreading | -d:butlerThreading | Changes the underlying implementation from using system.Channels to threading/channels.Chan 16 | butlerLogLevel | -d:butlerLogLevel='lvlDebug' | Sets the internal log level for threadButler (Based on std/logging's [Level](https://nim-lang.org/docs/logging.html#Level)). All logging calls beneath that level get removed at compile time. Defaults to "lvlerror". 17 | butlerDocs | -d:butlerDocs | Internal Switch. Solely used to avoid actually running example-code when compiling docs with examples. 18 | butlerDocsDebug | -d:butlerDocsDebug | Internal Switch. Solely used to avoid doc compilation bugs introduced by other libraries (does not apply to nimibook docs). 19 | ## butlerThreading (! experimental !) 20 | Normally threadButler uses [system.Channel](https://nim-lang.org/docs/system.html#Channel) for communication through the ChannelHub. 21 | 22 | `butlerThreading` is an experimental flag that changes the used channel type 23 | to [threading/channels.Chan](https://nim-lang.github.io/threading/channels.html#Chan). 24 | 25 | This should provide better performance in some scenarios, as `Channel` always does a deep copy of any message sent from one thread to another. 26 | With `Chan` you might be able to avoid copying in some scenarios, as it may simply move ownership of the message-memory from sender- to receiver-thread. 27 | 28 | Note that while similar, `Channel` instances are **growable message-queues**, meaning it is unlikely that you will ever drop a message. 29 | `Channel` will simply allocate more memory for its growing message-queue as needed. 30 | 31 | `Chan` instances however are **fixed-size message-queues**. 32 | Therefore, should a threadServer not work through messages quickly enough and cause its `Chan` to fill up to its capacity, it will drop messages (which you can notice by the boolean "sendMessage" procs return). 33 | 34 | You may therefore want to think about what should happen if a message is dropped, e.g. implement a "retry" mechanism in some scenarios, or just letting it drop in others. 35 | """ 36 | 37 | nbSave -------------------------------------------------------------------------------- /docs/book/threadServer.nim: -------------------------------------------------------------------------------- 1 | import nimib, nimibook 2 | 3 | 4 | nbInit(theme = useNimibook) 5 | 6 | nbText: """ 7 | # ThreadServer 8 | This is a more in-depth description over the `threadServer` block. 9 | 10 | ThreadServers are basically just threads running a while-loop (aka event-loop). 11 | The code to run them is generated via the `threadServer` and `prepareServers` macro. 12 | 13 | ## `threadServer` 14 | The `threadServer` macro accepts any of the following blocks: 15 | - properties 16 | - messageTypes 17 | - handlers 18 | 19 | ### properties 20 | The properties section is where you can define special properties that influence a threadServer's behaviour unrelated to messages and their handling. 21 | 22 | Properties are used to generate the `Server` instance representing a threadServer. 23 | See [`Server`s reference docs](https://philippmdoerner.github.io/ThreadButler/htmldocs/threadButler/types.html#Server) for what each field means. 24 | 25 | The properties we set here are `startUp` and `shutDown`, which define events that get executed before/after the server has run. 26 | 27 | 28 | ### messageTypes 29 | The messageTypes section defines one or more types of messages that a given threadServer can receive. 30 | 31 | Types in the messageType block **must** be unique. 32 | That means that a type can only be registered *for one threadServer*. 33 | If a type is registered multiple times, threadButler will error out at compile-time. 34 | 35 | ### handlers 36 | The handlers section defines how to handle a message of a specific message type that the threadServer may receive. 37 | 38 | It is a bunch of procs that **must** cover all types defined in `messageTypes`. 39 | ThreadButler will tell you at compile-time if you forgot to define a handler for one of the types or added a handler whose type is not mentioned in `messageTypes`. 40 | 41 | Procs in the handlers section are consumed by the `threadServer` macro. 42 | They are only emitted later by the `prepareServers` macro. 43 | 44 | Generally these procs must have this signature: 45 | ```nim 46 | proc (msg: , hub: ChannelHub) 47 | ``` 48 | Where `` can be whatever you want and `` is one of the types in `messageTypes`. 49 | 50 | This is because the `routeMessage` proc that will be generated relies on the handlers having this signature. 51 | 52 | However, integrations may provide their own `routeMessage` procs (generated by their own `prepareServer` variation). 53 | This can allow for handler-procs with different signatures. 54 | 55 | """ 56 | 57 | nbSave -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to ThreadButler 2 | 3 | ## Documentation 4 | ### General 5 | The public API of any module must have doc comments. 6 | 7 | Private procs that are used to build a NimNode (e.g. a proc or a type) should have doc comments, even if only for maintainers. 8 | 9 | ### Wording 10 | Words are important. Introducing new terms with specific meaning therefore should come together with an explanation for it in the [Glossary](https://philippmdoerner.github.io/ThreadButler/bookCompiled/glossary.html). 11 | 12 | Also try to limit terms used to those in the glossary for consistency. 13 | 14 | #### Changes to procs inferring names from ThreadName 15 | Many identifiers are inferred from a given `ThreadName` that the user provides via macro. 16 | The procs that are the central points for these operations are at the start of `codegen`. 17 | 18 | If the output of one of these procs is changed, then also check the doc comments of every proc using it as well, as they might now require updating. Also update `generatedCodeDocs.nim`. 19 | 20 | ### Features 21 | Consider whether a new given feature might benefit from being provided as an example. Examples are part of the test-suite as well as acting as documentation and thus will enable better stability. 22 | 23 | When adding an Example, also add it to the [nimibook examples page](https://philippmdoerner.github.io/ThreadButler/bookCompiled/examples.html) 24 | 25 | ### Compiler Flags 26 | When contributing code that introduces new compiler flags, make sure they are mentioned and explained in the [nimibook docs page on flags](https://philippmdoerner.github.io/ThreadButler/bookCompiled/flags.html) 27 | 28 | Compiler flags are typically prefixed with `butler`. 29 | 30 | ### Generated Code 31 | When contributing features that generate new code, make sure that they are mentioned and documented in the [nimibook docs page for generated code](https://philippmdoerner.github.io/ThreadButler/bookCompiled/generatedCodeDocs.html) 32 | 33 | ## Coding Style 34 | ### General 35 | This project uses camelCase. 36 | 37 | Contants are written using SCREAMING_CASE. 38 | 39 | ### Macros 40 | This project aggressively validates every step of the way that NimNodes have their anticipated NimNodeKinds. 41 | 42 | If a proc acting on NimNodes or a macro requires NimNodes to be of a certain kind, use `expectKind` (for macros) and `assertKind` (for procs). The goal is to figure out as early as possible if nodes are not behaving as expected, which makes macro-debugging easier and allows for better error messages. 43 | 44 | When using `expectKind`, please provide actionable user-facing error messages. -------------------------------------------------------------------------------- /examples/ex_stdinput_tasks.nim: -------------------------------------------------------------------------------- 1 | import threadButler 2 | import threadButler/log 3 | import std/[sugar, options, os] 4 | import taskpools 5 | import chronicles 6 | 7 | const CLIENT_THREAD = "client" 8 | const SERVER_THREAD = "server" 9 | type Response = distinct string 10 | type Request = distinct string 11 | 12 | proc runLate(hub: ChannelHub) {.gcsafe, raises: [].} 13 | 14 | threadServer(CLIENT_THREAD): 15 | messageTypes: 16 | Response 17 | 18 | handlers: 19 | proc handleResponseOnClient(msg: Response, hub: ChannelHub) = 20 | debug "On Client: ", msg = msg.string 21 | 22 | var threadPool: TaskPool 23 | 24 | threadServer(SERVER_THREAD): 25 | properties: 26 | taskPoolSize = 2 27 | startUp = @[ 28 | initCreateTaskpoolEvent(size = 2, threadPool), 29 | initEvent(() => debug "Server startin up!"), 30 | ] 31 | shutDown = @[ 32 | initDestroyTaskpoolEvent(threadPool), 33 | initEvent(() => debug "Server shutting down!") 34 | ] 35 | 36 | messageTypes: 37 | Request 38 | 39 | handlers: 40 | proc handleRequestOnServer(msg: Request, hub: ChannelHub) = 41 | debug "On Server: ", msg = msg.string 42 | threadPool.spawn hub.runLate() 43 | discard hub.sendMessage(Response("Handled: " & msg.string)) 44 | 45 | prepareServers() 46 | 47 | proc runClientLoop(hub: ChannelHub) = 48 | while keepRunning(): 49 | echo "\nType in a message to send to the Backend!" 50 | let terminalInput = readLine(stdin) # This is blocking, so this while-loop doesn't run and thus no responses are read unless the user puts something in 51 | if terminalInput == "kill": 52 | hub.sendKillMessage(ServerMessage) 53 | hub.clearServerChannel(ClientMessage) 54 | break 55 | 56 | elif terminalInput.len() > 0: 57 | let msg = terminalInput.Request 58 | discard hub.sendMessage(msg) 59 | 60 | ## Guarantees that we'll have the response from server before we listen for user input again. 61 | ## This is solely for better logging, do not use in actual code. 62 | sleep(100) 63 | 64 | let response: Option[ClientMessage] = hub.readMsg(ClientMessage) 65 | if response.isSome(): 66 | routeMessage(response.get(), hub) 67 | 68 | proc runLate(hub: ChannelHub) = 69 | debug "Start" 70 | sleep(1000) 71 | let msg = "Run with delay: " & $getThreadId() 72 | try: 73 | discard hub.sendMessage(msg.Response) 74 | except ChannelHubError as e: 75 | error "Failed to send message. ", error = e.repr 76 | 77 | proc main() = 78 | let hub = new(ChannelHub) 79 | 80 | hub.withServer(SERVER_THREAD): 81 | runClientLoop(hub) 82 | 83 | destroy(hub) 84 | 85 | main() -------------------------------------------------------------------------------- /examples/ex_stdinput_3threads.nim: -------------------------------------------------------------------------------- 1 | import threadButler 2 | import std/[sugar, options, strutils, os] 3 | import chronicles 4 | 5 | const CLIENT_THREAD = "client" 6 | const SERVER1_THREAD = "mainServer" 7 | const SERVER2_THREAD = "offloadServer" 8 | type Response = distinct string 9 | type OffloadRequest = distinct string 10 | type Request = distinct string 11 | 12 | threadServer(CLIENT_THREAD): 13 | messageTypes: 14 | Response 15 | 16 | handlers: 17 | proc handleResponseOnClient(msg: Response, hub: ChannelHub) = 18 | debug "On Client: ", msg = msg.string 19 | 20 | threadServer(SERVER1_THREAD): 21 | properties: 22 | startUp = @[ 23 | initEvent(() => debug "Server startin up!") 24 | ] 25 | shutDown = @[initEvent(() => debug "Server shutting down!")] 26 | 27 | messageTypes: 28 | Request 29 | 30 | handlers: 31 | proc handleRequestOnServer(msg: Request, hub: ChannelHub) = 32 | debug "On Server: ", msg = msg.string 33 | discard hub.sendMessage(OffloadRequest("Forwarding: " & msg.string)) 34 | discard hub.sendMessage(Response("Handled: " & msg.string)) 35 | 36 | threadServer(SERVER2_THREAD): 37 | properties: 38 | startUp = @[ 39 | initEvent(() => debug "Server startin up!") 40 | ] 41 | shutDown = @[initEvent(() => debug "Server shutting down!")] 42 | 43 | messageTypes: 44 | OffloadRequest 45 | 46 | handlers: 47 | proc handleOffloadRequest(msg: OffloadRequest, hub: ChannelHub) = 48 | debug "Work offloaded to OffloadSerevr" 49 | 50 | prepareServers() 51 | 52 | proc runClientLoop(hub: ChannelHub) = 53 | while keepRunning(): 54 | echo "\nType in a message to send to the Backend!" 55 | let terminalInput = readLine(stdin) # This is blocking, so this while-loop doesn't run and thus no responses are read unless the user puts something in 56 | if terminalInput == "kill": 57 | hub.sendKillMessage(MainServerMessage) 58 | hub.clearServerChannel(ClientMessage) 59 | break 60 | 61 | elif terminalInput.len() > 0: 62 | if terminalInput.startsWith("main"): 63 | let msg = terminalInput.Request 64 | discard hub.sendMessage(msg) 65 | else: 66 | let msg = terminalInput.OffloadRequest 67 | discard hub.sendMessage(msg) 68 | 69 | ## Guarantees that we'll have the response from server before we listen for user input again. 70 | ## This is solely for better logging, do not use in actual code. 71 | sleep(100) 72 | 73 | let response: Option[ClientMessage] = hub.readMsg(ClientMessage) 74 | if response.isSome(): 75 | routeMessage(response.get(), hub) 76 | 77 | proc main() = 78 | let hub = new(ChannelHub) 79 | 80 | hub.withServer(SERVER1_THREAD): 81 | hub.withServer(SERVER2_THREAD): 82 | runClientLoop(hub) 83 | 84 | destroy(hub) 85 | 86 | main() -------------------------------------------------------------------------------- /src/threadButler/types.nim: -------------------------------------------------------------------------------- 1 | import std/[sets, sequtils] 2 | import ./[channelHub, events] 3 | import chronos 4 | when not defined(butlerDocsDebug): # See https://github.com/status-im/nim-chronos/issues/499 5 | import chronos/threadsync 6 | 7 | type Server*[Msg] = object ## Data representing a single threadServer 8 | hub*: ChannelHub ## The ChannelHub. Set internally by threadButler 9 | msgType*: Msg ## The Message-Wrapper-Type of all messages that this threadServer receives. Set internally by threadButler 10 | startUp*: seq[Event] ## parameterless closures to execute before running the server 11 | shutDown*: seq[Event] ## parameterless closures to execute after when the server is shutting down 12 | taskPoolSize*: int = 2 ## The number of threads in the threadPool that execute tasks. Needs to be at least 2 to have a functioning pool. It must because the thread of the threadServer gets counted as part of the pool but will not contribute to working through tasks. 13 | signalReceiver*: ThreadSignalPtr ## For internal usage only. Signaller to wake up thread from low power state. 14 | 15 | else: 16 | type Server*[Msg] = object ## Data representing a single threadServer 17 | hub*: ChannelHub ## The ChannelHub. Set internally by threadButler 18 | msgType*: Msg ## The Message-Wrapper-Type of all messages that this threadServer receives. Set internally by threadButler 19 | startUp*: seq[Event] ## parameterless closures to execute before running the server 20 | shutDown*: seq[Event] ## parameterless closures to execute after when the server is shutting down 21 | taskPoolSize*: int = 2 ## The number of threads in the threadPool that execute tasks. Needs to be at least 2 to have a functioning pool. It must because the thread of the threadServer gets counted as part of the pool but will not contribute to working through tasks. 22 | 23 | type Property* = enum ## The fields on `Server`_ that can be set via a `properties` section 24 | startUp 25 | shutDown 26 | taskPoolSize 27 | const PROPERTY_NAMES*: HashSet[string] = Property.toSeq().mapIt($it).toHashSet() 28 | 29 | type Section* = enum 30 | MessageTypes = "messageTypes" ## Section that defines all message-types that a threadServer can receive. 31 | Properties = "properties" ## Section for the various definable `Property`_ fields of a threadServer. 32 | Handlers = "handlers" ## Section for all procs that handle the various message-types this threadServer can receive. 33 | const SECTION_NAMES*: HashSet[string] = Section.toSeq().mapIt($it).toHashSet() 34 | 35 | when not defined(butlerDocs): 36 | proc waitForSendSignal*[Msg](server: Server[Msg]) = 37 | ## Causes the server to work through its remaining async-work 38 | ## and go into a low powered state afterwards. Receiving a singla 39 | ## will wake the server up again. 40 | waitFor server.signalReceiver.wait() -------------------------------------------------------------------------------- /tests/test_server.nim: -------------------------------------------------------------------------------- 1 | import balls 2 | import threadButler 3 | import std/[sugar, options, os, sequtils] 4 | const CLIENT_THREAD = "client" 5 | const SERVER_THREAD = "server" 6 | type Response = distinct int 7 | type Request = distinct int 8 | 9 | var responses: seq[int] = @[] 10 | var requests: seq[int] = @[] 11 | var clientThreadStartupCounter: int = 0 12 | var clientThreadShutdownCounter: int = 0 13 | var serverThreadStartupCounter: int = 0 14 | var serverThreadShutdownCounter: int = 0 15 | 16 | threadServer(CLIENT_THREAD): 17 | properties: 18 | startUp = @[initEvent(() => clientThreadStartupCounter.inc)] 19 | shutDown = @[initEvent(() => clientThreadShutdownCounter.inc)] 20 | 21 | messageTypes: 22 | Response 23 | 24 | handlers: 25 | proc handleResponseOnClient(msg: Response, hub: ChannelHub) = 26 | responses.add(msg.int) 27 | 28 | threadServer(SERVER_THREAD): 29 | properties: 30 | startUp = @[initEvent(() => serverThreadStartupCounter.inc)] 31 | shutDown = @[initEvent(() => serverThreadShutdownCounter.inc)] 32 | 33 | messageTypes: 34 | Request 35 | 36 | handlers: 37 | proc handleRequestOnServer(msg: Request, hub: ChannelHub) = 38 | requests.add(msg.int) 39 | discard hub.sendMessage(Response(msg.int + 1)) 40 | 41 | prepareServers() 42 | 43 | suite "Single Server Example": 44 | let hub = new(ChannelHub) 45 | 46 | block whenBlock: 47 | discard "When SERVER_THREAD gets started and the main thread sends 10 messages with the numbers 0-9" 48 | hub.withServer(SERVER_THREAD): 49 | for i in 0..<10: 50 | while not hub.sendMessage(i.Request): discard 51 | 52 | while responses.len() < 10: 53 | var response: Option[ClientMessage] = hub.readMsg(ClientMessage) 54 | if response.isSome(): 55 | routeMessage(response.get(), hub) 56 | 57 | block thenBlock: 58 | discard "Then SERVER_THREAD should have variable of thread 'serverButlerThread', ran once and be shut down" 59 | check serverThreadStartupCounter == 1 60 | check serverThreadShutdownCounter == 1 61 | check serverButlerThread.running() == false 62 | 63 | block thenBlock: 64 | discard "Then CLIENT_THREAD should have variable of thread 'clientButlerThread' which should have never run" 65 | check clientThreadStartupCounter == 0 66 | check clientThreadShutdownCounter == 0 67 | check clientButlerThread.running() == false 68 | 69 | block thenBlock: 70 | discard "Then SERVER_THREAD should fill requests with the numbers 0-9 and send the responses 1-10 to the main thread" 71 | check requests == (0..9).toSeq(), "Server did not receive Requests correctly" 72 | check responses == (1..10).toSeq(), "Client did not receive Responses correctly" 73 | 74 | block thenBlock: 75 | when not defined(butlerLoony): 76 | discard "Then channel for ClientMessage should be empty" 77 | check hub.getChannel(ClientMessage).peek() == 0 78 | check hub.getChannel(ServerMessage).peek() == 0 79 | 80 | hub.destroy() 81 | -------------------------------------------------------------------------------- /examples/ex_stdinput_customloop.nim: -------------------------------------------------------------------------------- 1 | import threadButler 2 | import std/[sugar, options, os] 3 | import chronicles 4 | 5 | const MAIN_THREAD = "main" 6 | const SERVER_THREAD = "server" 7 | const TERMINAL_THREAD = "terminal" 8 | type Response = distinct string 9 | type Pong = distinct int 10 | type Input = distinct string 11 | type Ping = distinct int 12 | type Request = distinct string 13 | 14 | # ======= Define Message Types ======= 15 | threadServer(MAIN_THREAD): 16 | properties: 17 | shutDown = @[initEvent(() => debug "Main Thread shutting down!")] 18 | 19 | messageTypes: 20 | Response 21 | Input 22 | 23 | handlers: 24 | proc handleTerminalInput(msg: Input, hub: ChannelHub) = 25 | debug "On Main: ", msg = msg.string 26 | case msg.string: 27 | of "kill": 28 | hub.sendKillMessage(ServerMessage) 29 | hub.sendKillMessage(TerminalMessage) 30 | shutdownServer() 31 | else: 32 | discard hub.sendMessage(msg.Request) 33 | 34 | proc handleResponse(msg: Response, hub: ChannelHub) = 35 | debug "Finally received: ", msg = msg.string 36 | 37 | 38 | threadServer(SERVER_THREAD): 39 | properties: 40 | shutDown = @[initEvent(() => debug "Server shutting down!")] 41 | 42 | messageTypes: 43 | Request 44 | 45 | handlers: 46 | proc handleRequestOnServer(msg: Request, hub: ChannelHub) = 47 | debug "On Server: ", msg = msg.string 48 | discard hub.sendMessage(Response("Handled: " & msg.string)) 49 | 50 | 51 | threadServer(TERMINAL_THREAD): 52 | properties: 53 | shutDown = @[initEvent(() => debug "Main Thread shutting down!")] 54 | 55 | 56 | 57 | prepareServers() 58 | 59 | # ======= Define Custom ServerLoop for Terminal Thread ======= 60 | proc runServerLoop(data: Server[TerminalMessage]) {.gcsafe.} = 61 | debug "Starting up custom Server Loop" 62 | while keepRunning(): 63 | let terminalInput = readLine(stdin) # This is blocking, so this while-loop doesn't run and thus no responses are read unless the user puts something in 64 | debug "From Terminal ", terminalInput 65 | discard data.hub.sendMessage(terminalInput.Input) 66 | 67 | let msg: Option[TerminalMessage] = data.hub.readMsg(TerminalMessage) 68 | if msg.isSome() and msg.get().kind == KillTerminalKind: 69 | data.hub.clearServerChannel(TerminalMessage) 70 | break 71 | 72 | # ======= Define ServerLoop for Main Thread ======= 73 | proc runMainLoop(hub: ChannelHub) = 74 | while keepRunning(): 75 | let msg: Option[MainMessage] = hub.readMsg(MainMessage) 76 | if msg.isSome(): 77 | try: 78 | routeMessage(msg.get(), hub) 79 | except KillError: 80 | hub.clearServerChannel(MainMessage) 81 | debug "Cleared MainMessage" 82 | break 83 | 84 | except CatchableError as e: 85 | error "Message caused Exception", msg = msg.get()[], error = e.repr 86 | 87 | sleep(5) 88 | 89 | proc main() = 90 | let hub = new(ChannelHub) 91 | 92 | hub.withServer(SERVER_THREAD): 93 | hub.withServer(TERMINAL_THREAD): 94 | runMainLoop(hub) 95 | 96 | destroy(hub) 97 | 98 | main() -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | # Execute this workflow only for PRs/Pushes to your develop branch 4 | on: 5 | push: 6 | branches: 7 | - develop 8 | - main 9 | pull_request: 10 | branches: 11 | - develop 12 | - main 13 | 14 | #Defines the permissions that the default GITHUB_TOKEN secret has that gets generated (see: https://docs.github.com/en/actions/security-guides/automatic-token-authentication) 15 | permissions: 16 | contents: write 17 | id-token: write 18 | 19 | # Execute a job called "Tests" once for each combination of defined nim-versions and os's. 20 | # It will execute on ubuntu-latest, window-latest and macOs-latest. 21 | # For execution it will install the package according to the nimble file 22 | # and then run the nimble command `test` that executes the tests 23 | jobs: 24 | build: 25 | strategy: 26 | fail-fast: false 27 | max-parallel: 2 28 | matrix: 29 | branch: [master] 30 | target: 31 | - os: linux 32 | cpu: amd64 33 | nim_branch: devel 34 | - os: linux 35 | cpu: amd64 36 | nim_branch: version-2-0 37 | include: 38 | - target: 39 | os: linux 40 | builder: ubuntu-latest 41 | 42 | name: '${{ matrix.target.os }}-${{ matrix.target.cpu }}-nim-${{ matrix.target.nim_branch }} (${{ matrix.branch }})' 43 | runs-on: ${{ matrix.builder }} 44 | env: 45 | NIM_DIR: nim-${{ matrix.target.nim_branch }}-${{ matrix.target.cpu }} 46 | NIM_BRANCH: ${{ matrix.target.nim_branch }} 47 | NIM_ARCH: ${{ matrix.target.cpu }} 48 | steps: 49 | - name: Checkout 50 | uses: actions/checkout@v4 51 | 52 | - name: Restore Nim from cache 53 | if: > 54 | steps.nim-compiler-cache.outputs.cache-hit != 'true' && 55 | matrix.target.nim_branch != 'devel' 56 | id: nim-compiler-cache 57 | uses: actions/cache@v4 58 | with: 59 | path: '${{ github.workspace }}/nim-${{ matrix.target.nim_branch }}-${{ matrix.target.cpu }}' 60 | key: 'nim-${{ matrix.target.cpu }}-${{ matrix.target.nim_branch }}' 61 | 62 | - name: Setup Nim 63 | uses: alaviss/setup-nim@0.1.1 64 | with: 65 | path: 'nim' 66 | version: ${{ matrix.target.nim_branch }} 67 | architecture: ${{ matrix.target.cpu }} 68 | 69 | - name: Setup Dependencies 70 | run: nimble --accept install 71 | 72 | - name: Prepare balls 73 | shell: bash 74 | run: | 75 | sudo apt-get update 76 | sudo apt install --fix-missing valgrind 77 | nimble --accept develop 78 | nimble --accept install "https://github.com/disruptek/balls@#v4" 79 | 80 | - name: Run tests 81 | shell: bash 82 | run: | 83 | balls --define:ballsFailFast=off --path="." -d:useMalloc --panics:on --exceptions:goto --backend:c --mm:arc --mm:orc --debugger:native --passc:"-fno-omit-frame-pointer -mno-omit-leaf-frame-pointer" --passl:"-fno-omit-frame-pointer -mno-omit-leaf-frame-pointer" -d:butlerThreading -d:chronicles_enabled=off 84 | -------------------------------------------------------------------------------- /tests/test_server_complex_messages.nim: -------------------------------------------------------------------------------- 1 | import balls 2 | import threadButler 3 | import std/[sugar, options, os, sequtils, asyncdispatch] 4 | const CLIENT_THREAD = "client" 5 | const SERVER_THREAD = "server" 6 | type Response = distinct int 7 | 8 | type ChildObj = ref object 9 | id: int 10 | type Request = ref object 11 | child: ChildObj 12 | 13 | var responses: seq[int] = @[] 14 | var requests: seq[int] = @[] 15 | var clientThreadStartupCounter: int = 0 16 | var clientThreadShutdownCounter: int = 0 17 | var serverThreadStartupCounter: int = 0 18 | var serverThreadShutdownCounter: int = 0 19 | 20 | threadServer(CLIENT_THREAD): 21 | properties: 22 | startUp = @[initEvent(() => clientThreadStartupCounter.inc)] 23 | shutDown = @[initEvent(() => clientThreadShutdownCounter.inc)] 24 | 25 | messageTypes: 26 | Response 27 | 28 | handlers: 29 | proc handleResponseOnClient(msg: Response, hub: ChannelHub) = 30 | responses.add(msg.int) 31 | 32 | threadServer(SERVER_THREAD): 33 | properties: 34 | startUp = @[initEvent(() => serverThreadStartupCounter.inc)] 35 | shutDown = @[initEvent(() => serverThreadShutdownCounter.inc)] 36 | 37 | messageTypes: 38 | Request 39 | 40 | handlers: 41 | proc handleRequestOnServer(msg: Request, hub: ChannelHub) = 42 | let id = msg.child.id 43 | requests.add(id) 44 | var resp = Response(id + 1) 45 | discard hub.sendMessage(resp) 46 | 47 | prepareServers() 48 | 49 | suite "Single Server Example": 50 | let hub = new(ChannelHub) 51 | 52 | block whenBlock: 53 | discard "When SERVER_THREAD gets started and the main thread sends 10 messages with the numbers 0-9" 54 | hub.withServer(SERVER_THREAD): 55 | for i in 0..<10: 56 | var msg = Request(child: ChildObj(id: i)) 57 | while not hub.sendMessage(move(msg)): discard 58 | 59 | while responses.len() < 10: 60 | var response: Option[ClientMessage] = hub.readMsg(ClientMessage) 61 | if response.isSome(): 62 | routeMessage(response.get(), hub) 63 | 64 | block thenBlock: 65 | discard "Then SERVER_THREAD should have variable of thread 'serverButlerThread', ran once and be shut down" 66 | check serverThreadStartupCounter == 1 67 | check serverThreadShutdownCounter == 1 68 | check serverButlerThread.running() == false 69 | 70 | block thenBlock: 71 | discard "Then CLIENT_THREAD should have variable of thread 'clientButlerThread' which should have never run" 72 | check clientThreadStartupCounter == 0 73 | check clientThreadShutdownCounter == 0 74 | check clientButlerThread.running() == false 75 | 76 | block thenBlock: 77 | discard "Then SERVER_THREAD should fill requests with the numbers 0-9 and send the responses 1-10 to the main thread" 78 | check requests == (0..9).toSeq(), "Server did not receive Requests correctly" 79 | check responses == (1..10).toSeq(), "Client did not receive Responses correctly" 80 | 81 | block thenBlock: 82 | when not defined(butlerLoony): 83 | discard "Then channel for ClientMessage should be empty" 84 | check hub.getChannel(ClientMessage).peek() == 0 85 | check hub.getChannel(ServerMessage).peek() == 0 86 | 87 | hub.destroy() 88 | -------------------------------------------------------------------------------- /tests/test_async.nim: -------------------------------------------------------------------------------- 1 | import balls 2 | import threadButler 3 | import std/[sugar, options, os, atomics, sequtils] 4 | import chronos 5 | 6 | const CLIENT_THREAD = "client" 7 | const SERVER_THREAD = "server" 8 | type Response = distinct int 9 | type Request = distinct int 10 | 11 | var responses: Atomic[int] 12 | var requests: Atomic[int] 13 | var clientThreadStartupCounter: int = 0 14 | var clientThreadShutdownCounter: int = 0 15 | var serverThreadStartupCounter: int = 0 16 | var serverThreadShutdownCounter: int = 0 17 | 18 | threadServer(CLIENT_THREAD): 19 | properties: 20 | startUp = @[initEvent(() => clientThreadStartupCounter.inc)] 21 | shutDown = @[initEvent(() => clientThreadShutdownCounter.inc)] 22 | 23 | messageTypes: 24 | Response 25 | 26 | handlers: 27 | proc handleResponseOnClient(msg: Response, hub: ChannelHub) = 28 | responses.atomicInc 29 | 30 | threadServer(SERVER_THREAD): 31 | properties: 32 | startUp = @[initEvent(() => serverThreadStartupCounter.inc)] 33 | shutDown = @[initEvent(() => serverThreadShutdownCounter.inc)] 34 | 35 | messageTypes: 36 | Request 37 | 38 | handlers: 39 | proc handleRequestOnServer(msg: Request, hub: ChannelHub) {.async.} = 40 | requests.atomicInc 41 | await sleepAsync(10) 42 | discard hub.sendMessage(Response(msg.int + 1)) 43 | 44 | prepareServers() 45 | 46 | const MESSAGE_COUNT = 10 47 | 48 | suite "Single Server Example": 49 | let hub = new(ChannelHub) 50 | 51 | if not defined(gcArc): 52 | block whenBlock: 53 | discard "When SERVER_THREAD gets started and the main thread sends 10 messages with the numbers 0-9" 54 | hub.withServer(SERVER_THREAD): 55 | for i in 0..Kinds = enum` 14 | Enum representing all kinds of messages a given ThreadServer can receive. 15 | It is used to generate the Message-Wrapper-Type. 16 | 17 | Includes 1 kind per registered type + [1 KillKind](https://philippmdoerner.github.io/ThreadButler/htmldocs/threadButler/codegen.html#killKindName%2CThreadName) 18 | 19 | ## threadServer: `type Message = object` 20 | The Message-Wrapper-Type. 21 | Object variant capable of wrapping any message-type that can be sent to a ThreadServer. 22 | Instances of this type are always sent to the Channel associated with it and thus to the associated ThreadServer. 23 | 24 | ## threadServer: proc sendMessage 25 | A proc of shape: `proc sendMessage*(hub: ChannelHub, msg: sink `msgType`): bool` 26 | 27 | Tries to send a message through the ChannelHub. 28 | Returns true if that succeeded, false if it didn't - because the channel was full - and the message was dropped. 29 | 30 | ## threadServer: [proc sendKillMessage](https://philippmdoerner.github.io/ThreadButler/htmldocs/threadButler/codegen.html#genSendKillMessageProc%2CThreadName) 31 | Procs generated to send killMessages to individual servers. 32 | 33 | ## threadServer: [proc initServer](https://philippmdoerner.github.io/ThreadButler/htmldocs/threadButler/codegen.html#genInitServerProc%2CThreadName) 34 | A proc used by threadButler internally to instantiate `Server` 35 | objects. 36 | 37 | ## threadServer: var ButlerThread 38 | A global mutable variable containing a thread. 39 | This is for the thread to run specifically the threadServer associated with `threadname`. 40 | 41 | ## prepareServers: [proc new(ChannelHub)](https://philippmdoerner.github.io/ThreadButler/htmldocs/threadButler/codegen.html#genNewChannelHubProc) 42 | Constructor for ChannelHub. 43 | Only calls this proc once to have a single instance that you pass around. 44 | 45 | ## prepareServers: [proc destroy(ChannelHub)](https://philippmdoerner.github.io/ThreadButler/htmldocs/threadButler/codegen.html#genDestroyChannelHubProc) 46 | Destructor for ChannelHub. 47 | Only call this proc once to destroy the single ChannelHub instance that you have. 48 | 49 | ## prepareServers: handler procs 50 | Procs added to the `handlers` section of each threadServer are spawned here. 51 | This allows threadButler to guarantee that a handler has access to all types of other threadServers once they are generated. 52 | That way, even without the importing the types of other threadservers, you can send messages to them as the place where "prepareServers" gets called is the place where the procs will appear. 53 | 54 | ## prepareServers: [proc routeMessage](https://philippmdoerner.github.io/ThreadButler/htmldocs/threadButler/codegen.html#genMessageRouter%2CThreadName%2Cseq%5BNimNode%5D%2Cseq%5BNimNode%5D) 55 | The routing proc generated per threadServer. 56 | 57 | """ 58 | 59 | nbSave() -------------------------------------------------------------------------------- /examples/stresstest.nim: -------------------------------------------------------------------------------- 1 | import threadButler 2 | import threadButler/channelHub 3 | import threading/channels 4 | import std/[logging, options, times, tables, strformat, os, sequtils] 5 | 6 | const CLIENT_THREAD_NAME = "client" 7 | const SERVER_THREAD_NAME = "backend" 8 | const MESSAGE_COUNT = 100_000 9 | const SEQ_SIZE = 1000 10 | 11 | type SmallMessage = distinct string 12 | type LargeMessage = object 13 | lotsOfNumbers: seq[int] 14 | sets: set[0..SEQ_SIZE] = {0..SEQ_SIZE} 15 | type SmallResponse = distinct SmallMessage 16 | type LargeResponse = distinct LargeMessage 17 | type SmallRequest = distinct SmallMessage 18 | type LargeRequest = distinct LargeMessage 19 | 20 | var smallReceivedCounter = 0 21 | var largeReceivedCounter = 0 22 | var smallSendCounter = 0 23 | var largeSendCounter = 0 24 | var smallFailCounter = 0 25 | var largeFailCounter = 0 26 | 27 | threadServer(CLIENT_THREAD_NAME): 28 | messageTypes: 29 | SmallResponse 30 | LargeResponse 31 | 32 | handlers: 33 | proc handleSmallResponse(msg: sink SmallResponse, hub: ChannelHub) = 34 | smallReceivedCounter.inc 35 | 36 | proc handleLargeResponse(msg: sink LargeResponse, hub: ChannelHub) = 37 | largeReceivedCounter.inc 38 | 39 | threadServer(SERVER_THREAD_NAME): 40 | messageTypes: 41 | SmallRequest 42 | LargeRequest 43 | 44 | handlers: 45 | proc handleSmallRequest(msg: sink SmallRequest, hub: ChannelHub) = 46 | discard hub.sendMessage(msg.SmallResponse) 47 | 48 | proc handleLargeRequest(msg: sink LargeRequest, hub: ChannelHub) = 49 | let resp = LargeResponse(msg) 50 | let success = hub.sendMessage(resp) 51 | if not success: 52 | echo "Failed sending large message" 53 | 54 | prepareServers() 55 | 56 | 57 | proc main() = 58 | let hub = new(ChannelHub, capacity = MESSAGE_COUNT * 2) 59 | 60 | let exampleSmall = "A small and short string".SmallRequest 61 | let exampleLarge = LargeMessage(lotsOfNumbers: (0..SEQ_SIZE).toSeq()).LargeRequest 62 | 63 | hub.withServer(SERVER_THREAD_NAME): 64 | while keepRunning(): 65 | var t0 = cpuTime() 66 | for _ in 1..MESSAGE_COUNT: 67 | smallSendCounter.inc 68 | let success = hub.sendMessage(exampleSmall) 69 | if not success: 70 | smallFailCounter.inc 71 | 72 | var counter = 0 73 | while counter < MESSAGE_COUNT: 74 | let response: Option[ClientMessage] = hub.readMsg(ClientMessage) 75 | if response.isSome(): 76 | routeMessage(response.get(), hub) 77 | counter.inc 78 | var t1 = cpuTime() 79 | echo "\nCPU time for small Messages (in s): ", t1 - t0 80 | echo "SmallMessages: Sent: ", smallSendCounter, " - Received: ", smallReceivedCounter, " - Failed: ", smallFailCounter 81 | 82 | var t2 = cpuTime() 83 | for _ in 1..MESSAGE_COUNT: 84 | largeSendCounter.inc 85 | let success = hub.sendMessage(exampleLarge) 86 | if not success: 87 | largeFailCounter.inc 88 | 89 | counter = 0 90 | while counter < MESSAGE_COUNT: 91 | let response: Option[ClientMessage] = hub.readMsg(ClientMessage) 92 | if response.isSome(): 93 | routeMessage(response.get(), hub) 94 | counter.inc 95 | var t3 = cpuTime() 96 | 97 | echo "\nCPU time for Large Messages (in s): ", t3 - t2 98 | echo "LargeMessages: Sent: ", largeSendCounter, " - Received: ", largeReceivedCounter, " - Failed: ", largeFailCounter 99 | main() -------------------------------------------------------------------------------- /examples/ex_benchmark.nim: -------------------------------------------------------------------------------- 1 | import threadButler 2 | import threadButler/channelHub 3 | import std/[logging, options, times, tables, sequtils] 4 | 5 | addHandler(newConsoleLogger(fmtStr="[CLIENT $levelname] ")) 6 | 7 | const CLIENT_THREAD = "client" 8 | const SERVER_THREAD = "backend" 9 | const MESSAGE_COUNT = 50_00 10 | const SEQ_SIZE = 10_000 11 | 12 | var smallReceivedCounter = 0 13 | var largeReceivedCounter = 0 14 | var smallSendCounter = 0 15 | var largeSendCounter = 0 16 | var smallFailCounter = 0 17 | var largeFailCounter = 0 18 | 19 | type SmallMessage = distinct string 20 | type LargeMessage = object 21 | lotsOfNumbers: seq[int] 22 | sets: set[0..SEQ_SIZE] = {0..SEQ_SIZE} 23 | 24 | type SmallResponse = distinct SmallMessage 25 | type LargeResponse = distinct LargeMessage 26 | type SmallRequest = distinct SmallMessage 27 | type LargeRequest = distinct LargeMessage 28 | 29 | threadServer(CLIENT_THREAD): 30 | messageTypes: 31 | SmallResponse 32 | LargeResponse 33 | 34 | handlers: 35 | proc handleSmallResponse(msg: sink SmallResponse, hub: ChannelHub) = 36 | smallReceivedCounter.inc 37 | 38 | proc handleLargeResponse(msg: sink LargeResponse, hub: ChannelHub) = 39 | largeReceivedCounter.inc 40 | 41 | threadServer(SERVER_THREAD): 42 | messageTypes: 43 | SmallRequest 44 | LargeRequest 45 | 46 | handlers: 47 | proc handleSmallRequest(msg: sink SmallRequest, hub: ChannelHub) = 48 | discard hub.sendMessage(msg.SmallResponse) 49 | 50 | proc handleLargeRequest(msg: sink LargeRequest, hub: ChannelHub) = 51 | let resp = LargeResponse(msg) 52 | let success = hub.sendMessage(resp) 53 | if not success: 54 | echo "Failed sending large message" 55 | 56 | prepareServers() 57 | 58 | proc main() = 59 | let hub = new(ChannelHub, capacity = MESSAGE_COUNT * 2) 60 | 61 | let exampleSmall = "A small and short string".SmallRequest 62 | let exampleLarge = LargeMessage(lotsOfNumbers: (0..SEQ_SIZE).toSeq()).LargeRequest 63 | 64 | hub.withServer(SERVER_THREAD): 65 | var t0 = cpuTime() 66 | for _ in 1..MESSAGE_COUNT: 67 | smallSendCounter.inc 68 | let success = hub.sendMessage(exampleSmall) 69 | if not success: 70 | smallFailCounter.inc 71 | 72 | var counter = 0 73 | while counter < MESSAGE_COUNT: 74 | let response: Option[ClientMessage] = hub.readMsg(ClientMessage) 75 | if response.isSome(): 76 | routeMessage(response.get(), hub) 77 | counter.inc 78 | var t1 = cpuTime() 79 | echo "\nCPU time for small Messages (in s): ", t1 - t0, "s" 80 | echo "SmallMessages: Sent: ", smallSendCounter, " - Received: ", smallReceivedCounter, " - Failed: ", smallFailCounter 81 | 82 | var t2 = cpuTime() 83 | for _ in 1..MESSAGE_COUNT: 84 | largeSendCounter.inc 85 | let success = hub.sendMessage(exampleLarge) 86 | if not success: 87 | largeFailCounter.inc 88 | 89 | counter = 0 90 | while counter < MESSAGE_COUNT: 91 | let response: Option[ClientMessage] = hub.readMsg(ClientMessage) 92 | if response.isSome(): 93 | routeMessage(response.get(), hub) 94 | counter.inc 95 | var t3 = cpuTime() 96 | 97 | echo "\nCPU time for Large Messages (in s): ", t3 - t2, "s" 98 | echo "LargeMessages: Sent: ", largeSendCounter, " - Received: ", largeReceivedCounter, " - Failed: ", largeFailCounter 99 | 100 | # destroy(hub) 101 | main() -------------------------------------------------------------------------------- /docs/book/leaks.nim: -------------------------------------------------------------------------------- 1 | import nimib, nimibook 2 | 3 | 4 | nbInit(theme = useNimibook) 5 | 6 | nbText: """ 7 | # ThreadButler Memory Leak FAQ 8 | ThreadButler is checked for memory leaks regularly using [address sanitizers](https://github.com/google/sanitizers/wiki/AddressSanitizer) and does provide a leak-free experience on its own. 9 | 10 | However, due to its multi-threaded nature, it is very easy to introduce memory leaks yourself, particularly when a thread dies without cleaning up all of its ref-variables. 11 | 12 | ### How big of a problem is this for me? 13 | Generally it is unlikely to be a problem unless you use threadButler for starting and stopping the same `threadServer`s multiple times throughout the runtime of your program. 14 | 15 | If you don't do that, it is unlikely to be a problem since the `threadServer`s shutting down typically means the application is about to end, leading to a "leak" of memory only at the end of the application. 16 | 17 | It may however annoy you if you want to use tools such as address sanitizers or valgrind to check your own program for memory leaks. 18 | 19 | ### How can I see if I have a problem? 20 | You can use valgrind or address sanitiziers. This project itself is only validated using address sanitizers. 21 | 22 | For detailed docs on their general usage refer to the link above. 23 | 24 | As an example for nim, you can compile your program with a command like this: 25 | ```txt 26 | nim r 27 | --cc:clang 28 | --mm: 29 | --debugger:native 30 | -d:release 31 | -d:useMalloc # Sets the allocator doe Malloc for address sanitizers 32 | --passc:"-fsanitize=address -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer" # Tells Clang to use address sanitizers with frame pointers for better debugging 33 | --passl:"-fsanitize=address -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer" # Tells the linker to use address sanitizers with frame pointers for better debugging 34 | 35 | ``` 36 | 37 | Compile and run your program with the command above and after exiting you should either get a bunch of stacktraces (if you have leaks) or nothing. 38 | 39 | You then have to go through the stacktraces and look for ref-type instances that might not be cleaned up along the way. 40 | You'll want to keep an eye out for the following: 41 | 42 | - threadVariables of ref-types, used either by yourself, a library, or a library of a library. [Nim does not clean those up automatically yet](https://github.com/nim-lang/Nim/issues/23165) 43 | - Generally any ref-type variable that you might be sending to another thread 44 | - Generally any memory where you allocate memory directly 45 | - Anywhere you have `pointer` 46 | 47 | ### I found the source of a leak, what now? 48 | 49 | For general variables that leak the generally preferred method is calling [reset](https://nim-lang.org/docs/system.html#reset%2CT) on them. 50 | 51 | Calling `=destroy` on them or setting these variables to `nil` is also a valid strategy. 52 | 53 | Some variables may not have a `=destroy` hook defined for them. 54 | In those cases look for specific procs in the library you're using about de-initializing them. 55 | 56 | ### The leaking variable is a private variable from a library I use, how do I access it? 57 | Ideally you inform the library author of the leaky behaviour their library causes in multi-threaded constellations and they provide a solution. 58 | 59 | In the meantime, you can use imports with the `all` pragma to force nim to import all symbols from a module, including privates ones. 60 | 61 | Example: `import std/times {.all.}` 62 | """ 63 | 64 | nbSave() -------------------------------------------------------------------------------- /src/threadButler/channelHub.nim: -------------------------------------------------------------------------------- 1 | import std/[strformat, options, tables] 2 | import ./log 3 | 4 | ##[ 5 | Defines utilities for interacting with a ChannelHub. 6 | Further utilities may be generated by `codegen`. 7 | 8 | A ChannelHub is a table of **all** Channels to *all* thread-servers in an application. 9 | It contains one Channel per registered Thread through which one specific "Message"-object variant can be sent. 10 | The channel for a given Message-object-variant is stored with (and can thus be retrieved with) data inferred from the object-variant. 11 | ]## 12 | 13 | type ChannelHubError* = object of KeyError 14 | 15 | type ChannelHub* = object 16 | channels*: Table[pointer, pointer] 17 | 18 | template generateGetChannelProc(typ: untyped) = 19 | proc getChannel*[Msg](hub: ChannelHub, t: typedesc[Msg]): var typ[Msg] {.raises: [ChannelHubError].} = 20 | ## Fetches the `Channel` associated with `Msg` from `hub`. 21 | let key: pointer = default(t).getTypeInfo() 22 | var channelPtr: pointer = nil 23 | try: 24 | channelPtr = hub.channels[key] 25 | except KeyError as e: 26 | const msgName = $Msg 27 | raise (ref ChannelHubError)( 28 | msg: "There is no Channel for the message type '" & msgName & "'.", 29 | parent: e 30 | ) 31 | 32 | return cast[ptr typ[Msg]](channelPtr)[] 33 | 34 | # CHANNEL SPECIFICS 35 | when defined(butlerThreading): 36 | import pkg/threading/channels 37 | import std/isolation 38 | export channels 39 | 40 | generateGetChannelProc(Chan) 41 | 42 | proc createChannel[Msg](capacity: int): ptr Chan[Msg] = 43 | result = createShared(Chan[Msg]) 44 | result[] = newChan[Msg](capacity) 45 | 46 | proc destroyChannel*[Msg](chan: Chan[Msg]) = 47 | `=destroy`(chan) 48 | 49 | elif defined(butlerLoony): 50 | import pkg/loony 51 | export loony 52 | 53 | generateGetChannelProc(LoonyQueue) 54 | 55 | proc createChannel[Msg](capacity: int): ptr LoonyQueue[Msg] = 56 | result = createShared(LoonyQueue[Msg]) 57 | result[] = newLoonyQueue[Msg]() 58 | 59 | proc tryRecv[T](c: LoonyQueue[T]): tuple[dataAvailable: bool, msg: T] = 60 | let msg = c.pop() 61 | result.msg = msg 62 | result.dataAvailable = not msg.isNil() 63 | 64 | proc trySend[T](c: LoonyQueue[T]; msg: sink T): bool = 65 | c.push(msg) 66 | return true 67 | 68 | proc destroyChannel*[Msg](chan: LoonyQueue[Msg]) = 69 | `=destroy`(chan) 70 | discard 71 | 72 | else: 73 | generateGetChannelProc(Channel) 74 | 75 | proc createChannel[Msg](capacity: int): ptr Channel[Msg] = 76 | result = createShared(Channel[Msg]) 77 | result[] = Channel[Msg]() 78 | result[].open() 79 | 80 | proc destroyChannel*[Msg](chan: var Channel[Msg]) = 81 | chan.close() 82 | `=destroy`(chan) 83 | 84 | const SEND_PROC_NAME* = "sendMsgToChannel" 85 | proc sendMsgToChannel*[Msg](hub: ChannelHub, msg: sink Msg): bool {.raises: [ChannelHubError].} = 86 | ## Sends a message through the Channel associated with `Msg`. 87 | ## This is non-blocking. 88 | ## Returns `bool` stating if sending was successful. 89 | debug "send: Thread => Channel", msgTyp = $Msg, msg = msg.kind 90 | 91 | try: 92 | when defined(butlerThreading): 93 | result = hub.getChannel(Msg).trySend(unsafeIsolate(move(msg))) 94 | else: 95 | result = hub.getChannel(Msg).trySend(move(msg)) 96 | 97 | if not result: 98 | debug "Failed to send message" 99 | 100 | except Exception as e: 101 | raise (ref ChannelHubError)( 102 | msg: "Error while sending message", 103 | parent: e 104 | ) 105 | 106 | proc readMsg*[Msg](hub: ChannelHub, resp: typedesc[Msg]): Option[Msg] = 107 | ## Reads message from the Channel associated with `Msg`. 108 | ## This is non-blocking. 109 | let response: tuple[dataAvailable: bool, msg: Msg] = 110 | when defined(butlerThreading): 111 | var msg: Msg 112 | let hasMsg = hub.getChannel(Msg).tryRecv(msg) 113 | (hasMsg, msg) 114 | else: 115 | hub.getChannel(Msg).tryRecv() 116 | 117 | result = if response.dataAvailable: 118 | debug "read: Thread <= Channel", msgTyp = $Msg, msg = response.msg.kind 119 | some(response.msg) 120 | else: 121 | none(Msg) 122 | 123 | proc addChannel*[Msg](hub: var ChannelHub, t: typedesc[Msg], capacity: int) = 124 | ## Instantiates and opens a `Channel` to `hub` specifically for type `Msg`. 125 | ## This associates it with `Msg`. 126 | let key: pointer = default(Msg).getTypeInfo() 127 | let channel: pointer = createChannel[Msg](capacity) 128 | hub.channels[key] = channel 129 | 130 | let keyInt = cast[uint64](key) 131 | let channelInt = cast[uint64](channel) 132 | let typ = $Msg 133 | notice "Added Channel", typ, keyInt, channelInt -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ThreadButler 2 | 3 | > [!WARNING] 4 | > This library is currently in an alpha version. Some API changes may still happen. Testing as of right now consists of compiling and manually testing the examples. 5 | 6 | #### _They're here to serve_ 7 | **ThreadButler** is a package for multithreading in applications. 8 | 9 | It simplifies setting up "threadServers" - threads that live as long as your application does and may also be described as "microservices". 10 | These threads communicate with your main thread via messages, which trigger procs for handling them on the receiving thread. 11 | 12 | ThreadServers act as a "backend" for any heavy computation you do not wish to perform in your client loop. 13 | 14 | The message passing is done through nim's [Channels](https://nim-by-example.github.io/channels/). 15 | 16 | - [Documentation](https://philippmdoerner.github.io/ThreadButler/bookCompiled/index.html) (built with [nimibook](https://github.com/pietroppeter/nimibook)) 17 | - [Index](https://philippmdoerner.github.io/ThreadButler/htmldocs/theindex.html) 18 | - [RootModule](https://philippmdoerner.github.io/ThreadButler/htmldocs/threadButler.html) 19 | 20 | ## Installation 21 | 22 | Install ThreadButler with Nimble: 23 | 24 | $ nimble install -y threadButler 25 | 26 | Add ThreadButler to your .nimble file: 27 | 28 | requires "threadButler" 29 | 30 | ## Provided/Supported: 31 | - Defining and spawning long-running threads with threadServers that receive and send messages 32 | - Typesafe message passing 33 | - Async message handlers 34 | - Running procs as tasks on a threadPool (by creating/destroying via startUp/shutDown events) 35 | - Customizable ServerLoops 36 | - Kill-Thread mechanisms 37 | - Startup/Shutdown events per Thread 38 | 39 | ## General Architecture 40 | 41 | The following statements describe the architecture behind ThreadButler: 42 | - 1 ThreadServer is an event-loop running on 1 Thread, defined by `proc runServerLoop` 43 | - Each ThreadServer has a name called `` 44 | - Each ThreadServer has 1 dedicated Channel for messages sent to it 45 | - All Channels are combined into a single hub, the ChannelHub, which is accessible by all threads. 46 | - Each Thread has 1 Object Variant `Message` wrapping any kind of message it can receive 47 | - The ThreadServer's Channel can only carry instances of `Message` 48 | - Messages are wrapped in the Object Variant using helper procs `proc sendMessage` 49 | - Each ThreadServer has its own routing `proc routeMessage`. It "unwraps" the object variant `Message` and calls the registered handler proc for it. 50 | Tasks can have access to the ChannelHub to send messages with their results if necessary. 51 | 52 | ### General Flow of Actions 53 | 54 | 55 | 56 | ## Special Integrations 57 | ThreadButler provides small, simple utilities for easier integration with specific frameworks/libraries. 58 | 59 | Currently the following packages/frameworks have such modules: 60 | 61 | - [Owlkettle](https://philippmdoerner.github.io/ThreadButler/htmldocs/threadButler/integrations/owlButler.html) 62 | 63 | ## Limitations 64 | #### No explicit support for --mm:refc 65 | This package is only validated for the ARC/ORC memory management strategies (--mm:arc and --mm:orc respectively). 66 | 67 | If you are not familiar with those flags, check out the [nim compiler docs](https://nim-lang.org/docs/nimc.html). 68 | 69 | #### No support for --mm:orc when using -d:butlerLoony 70 | See the associated context in the [LoonyQueue project](https://github.com/nim-works/loony#issues) 71 | 72 | #### Must use -d:useMalloc 73 | Due to memory issues that occurred while running some stress-tests it is currently discouraged to use nim's default memory allocator. Use malloc with `-d:useMalloc` instead. 74 | 75 | See [nim-lang issue#22510](https://github.com/nim-lang/Nim/issues/22510) for more context. 76 | 77 | #### Using ref type messages with -d:butlerThreading is not guaranteed to be thread-safe 78 | threading/channels require a message be isolateable before sending it. This is to guarantee that the sending thread no longer accesses the memory of the message after its memory ownership was moved to another thread. You risk segfaults if you do. 79 | 80 | However, ref-types can not be properly isolated when users pass them into the various sending procs, as the compiler can not reason about whether the user still has references to that data somewhere and may access it. 81 | 82 | There are currently no mechanisms to do anything about this, so ThreadButler disables those isolation checks. The burden is therefore on you, the user. You must ensure to **never acccess** a message's memory after you pass it to any of ThreadButler's sending procs. If you do, even if it works, don't. It is undefined behaviour and **will** cause segfaults eventually, if only because you upgraded the nim version to the next patch version. 83 | 84 | #### Having logging enabled is not datarace-safe 85 | 86 | Currently logging inside the package is done through chronicles, which so far appears to not be free of data-races based on current tests with thread-sanitizers. Disable it as per their docs if you want to have the safety. -------------------------------------------------------------------------------- /src/threadButler/integration/owlCodegen.nim: -------------------------------------------------------------------------------- 1 | import std/[macros, tables, macrocache, sequtils] 2 | import ../codegen 3 | import ../register 4 | import ../validation 5 | import ../types 6 | 7 | when defined(butlerDebug): 8 | import std/[strformat, sequtils] 9 | 10 | const owlThreads = CacheSeq"owl" 11 | 12 | ##[ 13 | 14 | Defines all code for owlkettle-specific code generation. 15 | This is an extension of the `codegen` module. 16 | 17 | ]## 18 | 19 | macro owlThreadServer*(name: static string, body: untyped) = 20 | ## An owlkettle specific version of `threadServer`_ . 21 | ## 22 | ## Differs from `threadServer` by requiring procs in the 23 | ## handler section to have a different shape: 24 | ## ``` 25 | ## proc (msg: , hub: ChannelHub, state: State) 26 | ## ``` 27 | body.expectKind(nnkStmtList) 28 | let name = name.ThreadName 29 | body.validateSectionNames() 30 | name.registerThread() 31 | owlThreads.add(newStrLitNode(name.string)) 32 | 33 | let sections = body.getSections() 34 | 35 | let hasTypes = sections.hasKey(MessageTypes) 36 | if hasTypes: 37 | let typeSection = sections[MessageTypes] 38 | name.validateTypeSection(typeSection) 39 | for typ in typeSection: 40 | name.addType(typ) 41 | 42 | let hasHandlers = sections.hasKey(Handlers) 43 | if hasHandlers: 44 | let handlerSection = sections[Handlers] 45 | name.validateHandlerSection(handlerSection, expectedParamCount = 3) 46 | for handler in handlerSection: 47 | name.addRoute(handler) 48 | 49 | name.validateAllTypesHaveHandlers() 50 | 51 | let hasProperties = sections.hasKey(Properties) 52 | if hasProperties: 53 | let propertiesSection = sections[Properties] 54 | name.validatePropertiesSection(propertiesSection) 55 | for property in propertiesSection: 56 | name.addProperty(property) 57 | 58 | result = name.generateCode() 59 | 60 | when defined(butlerDebug): 61 | echo fmt"=== Actor: {name.string} ===", "\n", result.repr 62 | 63 | 64 | proc genOwlRouter(name: ThreadName, widgetName: string): NimNode = 65 | ## Generates a proc `routeMessage` for unpacking the object variant type for `name` and calling a handler proc with the unpacked value. 66 | ## The variantTypeName is inferred from `name`, see the proc `variantName`_. 67 | ## The name of the killKind is inferred from `name`, see the proc `killKindName`_. 68 | ## The name of msgField is inferred from `type`, see the proc `fieldName`_. 69 | ## The proc is generated based on the registered routes according to this pattern: 70 | ## ``` 71 | ## proc routeMessage\*(msg: , hub: ChannelHub, state: State) = 72 | ## case msg.kind: 73 | ## --- Repeat per route - start --- 74 | ## of : 75 | ## (msg., hub, state) 76 | ## --- Repeat per route - end --- 77 | ## of : shutDownServer() 78 | ## ``` 79 | ## Returns an empty proc if types is empty 80 | 81 | result = newProc(name = postfix(ident("routeMessage"), "*")) 82 | 83 | let msgParamName = "msg" 84 | let msgParam = newIdentDefs(ident(msgParamName), ident(name.variantName)) 85 | result.params.add(msgParam) 86 | 87 | let hubParamName = "hub" 88 | let hubParam = newIdentDefs( 89 | ident(hubParamName), 90 | ident("ChannelHub") 91 | ) 92 | result.params.add(hubParam) 93 | 94 | let stateParamName = "state" 95 | let widgetStateParam = newIdentDefs(ident(stateParamName), ident(widgetName & "State")) 96 | result.params.add(widgetStateParam) 97 | 98 | let caseStmt = nnkCaseStmt.newTree( 99 | newDotExpr(ident(msgParamName), ident("kind")) 100 | ) 101 | 102 | for handlerProc in name.getRoutes(): 103 | # Generates proc call `(., hub, state)` 104 | let firstParamType = handlerProc.firstParamType 105 | let handlerCall = nnkCall.newTree( 106 | handlerProc.name, 107 | newDotExpr(ident(msgParamName), ident(firstParamType.fieldName)), 108 | ident(hubParamName), 109 | ident(stateParamName) 110 | ) 111 | 112 | # Generates `of : (...)` 113 | let branchNode = nnkOfBranch.newTree( 114 | ident(firstParamType.kindName), 115 | newStmtList(handlerCall) 116 | ) 117 | caseStmt.add(branchNode) 118 | 119 | # Generates `of : shutdownServer()`: 120 | let killBranchNode = nnkOfBranch.newTree( 121 | ident(name.killKindName), 122 | nnkCall.newTree(ident("shutdownServer")) 123 | ) 124 | caseStmt.add(killBranchNode) 125 | 126 | result.body.add(caseStmt) 127 | 128 | macro prepareOwlServers*(widgetNode: typed) = 129 | ## An owlkettle specific version of `prepareServers`_ . 130 | ## 131 | ## Differs from `prepareServers` by generating a special routing proc instead of the "normal" 132 | ## one from `genMessageRouter`_ for threadServers defined with `owlThreadServer`_ . 133 | let widgetName = $widgetNode 134 | result = newStmtList() 135 | 136 | result.add(genNewChannelHubProc()) 137 | result.add(genDestroyChannelHubProc()) 138 | 139 | for name in getRegisteredThreadnames(): 140 | let handlers = name.getRoutes() 141 | for handler in handlers: 142 | result.add(handler) 143 | 144 | let isClientThread: bool = owlThreads.toSeq().anyIt($it == name.string) 145 | let routingProc: NimNode = if isClientThread: 146 | name.genOwlRouter(widgetName) 147 | else: 148 | name.genMessageRouter(name.getRoutes(), name.getTypes()) 149 | result.add(routingProc) 150 | 151 | when defined(butlerDebug): 152 | echo "=== OverallOwl ===\n", result.repr 153 | -------------------------------------------------------------------------------- /src/threadButler.nim: -------------------------------------------------------------------------------- 1 | import std/[options, tables, os, atomics] 2 | import ./threadButler/[types, codegen, channelHub, events, log] 3 | import chronicles 4 | import std/times {.all.} # Only needed for `clearThreadVariables` 5 | import system {.all.} # Only needed for `clearThreadVariables` 6 | import chronos 7 | export chronicles 8 | 9 | when not defined(butlerDocsDebug): # See https://github.com/status-im/nim-chronos/issues/499 10 | import chronos/threadsync 11 | export threadsync 12 | 13 | ##[ 14 | .. importdoc:: threadButler/integrations/owlButler 15 | 16 | This package provides a way to set-up multithreading with 17 | multiple long-running threads that talk to one another via 18 | message passing. 19 | 20 | The architecture is modeled after a client-server architecture 21 | between threads, with one thread running the GUI loop and one 22 | or more other threads acting running their own event-loops, 23 | listening for messages and acting as backend "servers". 24 | 25 | Threadbutler groups message-types and handler procs defining 26 | what to do with a given message-type into a single thread. 27 | It then defines one overarching message-variant that encompasses 28 | all messages that are allowed to be sent to that thread. 29 | 30 | For integration utilities with other frameworks see: 31 | * `createListenerEvent`_ 32 | ]## 33 | 34 | export 35 | codegen.threadServer, 36 | codegen.prepareServers 37 | export channelHub 38 | export events 39 | export types.Server 40 | 41 | type KillError* = object of CatchableError ## A custom error. Throwing this will gracefully shut down the server 42 | 43 | var IS_RUNNING*: Atomic[bool] ## \ 44 | ## Global switch that controls whether threadServers keep running or shut down. 45 | ## Change this value to false to trigger shut down of all threads running 46 | ## ThreadButler default event-loops. 47 | IS_RUNNING.store(true) 48 | 49 | proc keepRunning*(): bool = IS_RUNNING.load() 50 | proc shutdownAllServers*() = IS_RUNNING.store(false) 51 | 52 | proc shutdownServer*() = 53 | ## Triggers the graceful shut down of the thread-server this proc is called on. 54 | raise newException(KillError, "Shutdown") 55 | 56 | proc clearServerChannel*[Msg](hub: ChannelHub, t: typedesc[Msg]) = 57 | ## Throws away remaining messages in the channel. 58 | ## This avoids those messages leaking should the channel be destroyed. 59 | while hub.readMsg(Msg).isSome(): 60 | discard 61 | 62 | proc clearServerChannel*[Msg](data: Server[Msg]) = 63 | ## Convenience proc for clearServerChannel_ 64 | data.hub.clearServerChannel(Msg) 65 | 66 | proc clearThreadVariables*() = 67 | ## Internally, this clears up known thread variables 68 | ## that were likely set to avoid memory leaks. 69 | ## May become unnecessary if https://github.com/nim-lang/Nim/issues/23165 ever gets fixed 70 | when not defined(butlerDocs): 71 | {.cast(gcsafe).}: 72 | times.localInstance = nil 73 | times.utcInstance = nil 74 | `=destroy`(getThreadDispatcher()) 75 | when defined(gcOrc): 76 | GC_fullCollect() # from orc.nim. Has no destructor. 77 | 78 | proc processRemainingMessages[Msg](data: Server[Msg]) {.gcsafe.} = 79 | mixin routeMessage 80 | var msg: Option[Msg] = data.hub.readMsg(Msg) 81 | while msg.isSome(): 82 | try: 83 | {.gcsafe.}: 84 | routeMessage(msg.get(), data.hub) 85 | 86 | msg = data.hub.readMsg(Msg) 87 | 88 | except CatchableError as e: 89 | error "Message caused exception", msg = msg.get()[], error = e.repr 90 | 91 | proc runServerLoop[Msg](server: Server[Msg]) {.gcsafe.} = 92 | mixin routeMessage 93 | 94 | block serverLoop: 95 | while keepRunning(): 96 | server.waitForSendSignal() 97 | var msg: Option[Msg] = server.hub.readMsg(Msg) 98 | while msg.isSome(): 99 | try: 100 | {.gcsafe.}: routeMessage(msg.get(), server.hub) 101 | 102 | msg = server.hub.readMsg(Msg) 103 | 104 | except KillError as e: 105 | break serverLoop 106 | 107 | except CatchableError as e: 108 | error "Message caused exception", msg = msg.get()[], error = e.repr 109 | 110 | proc serverProc*[Msg](data: Server[Msg]) {.gcsafe.} = 111 | mixin runServerLoop 112 | data.startUp.execEvents() 113 | 114 | runServerLoop[Msg](data) 115 | 116 | # process remaining messages 117 | processRemainingMessages(data) 118 | # while hasPendingOperations(): poll() # TODO: How to wrap up async work until its done in chronos? 119 | 120 | data.shutDown.execEvents() 121 | clearThreadVariables() 122 | 123 | proc run*[Msg](thread: var Thread[Server[Msg]], data: Server[Msg]) = 124 | when not defined(butlerDocs): 125 | system.createThread(thread, serverProc[Msg], data) 126 | 127 | template withServer*(hub: ChannelHub, threadName: static string, body: untyped) = 128 | ## Spawns the server on the thread associated with `threadName`. 129 | ## 130 | ## The server listens for new messages and executes `routeMessage` for every message received, 131 | ## which will call the registered handler proc for this message type. 132 | ## startup and shutdown events in `data` are executed before and after the event-loop of the server. 133 | ## 134 | ## Sends message to shut the server down gracefully and waits for shutdown 135 | ## to complete once the code in `body` has finished executing. 136 | mixin sendKillMessage 137 | let server = initServer(hub, threadName.toVariantType()) 138 | 139 | run[threadName.toVariantType()](threadName.toThreadVariable(), server) 140 | 141 | body 142 | 143 | server.hub.sendKillMessage(threadName.toVariantType()) 144 | when not defined(butlerDocs): 145 | joinThread(threadName.toThreadVariable()) 146 | 147 | proc send*[Msg](server: Server[Msg], msg: auto): bool = 148 | ## Utility proc to allow sending messages directly from a server object. 149 | server.hub.sendMessage(msg) -------------------------------------------------------------------------------- /src/threadButler/validation.nim: -------------------------------------------------------------------------------- 1 | ##[ 2 | Defines procs for various validation stpes within threadButler. 3 | Mostly related to compile-time validation of macros. 4 | 5 | This module is only intended for use within threadButler and for integrations. 6 | ]## 7 | import std/[strformat, sets, macros, options, sequtils, strutils] 8 | import ./register 9 | import ./utils 10 | import ./types 11 | 12 | proc raiseRouteValidationError(procDef: NimNode, msg: string) = 13 | error(fmt""" 14 | Failed to register proc '{procDef.name}' from '{procDef.lineInfo}'. {msg} 15 | Proc: {procDef.repr} 16 | """.dedent(2)) 17 | 18 | proc validateMsgType*(name: ThreadName, procDef: NimNode) = 19 | ## Validates for a route `procDef` that the message type it gets called with 20 | ## is also a type registered with threadButler. 21 | procDef.assertKind(nnkProcDef) 22 | 23 | let firstParamTypeName = procDef.firstParamType.typeName 24 | if not name.hasTypeOfName(firstParamTypeName): 25 | raiseRouteValidationError(procDef, fmt"No matching type '{firstParamTypeName}' has been registered for '{name.string}'.") 26 | 27 | proc validateFreeType*(name: ThreadName, procDef: NimNode) = 28 | ## Validates for a route `procDef` that the message type it gets called with 29 | ## is not already registered with another route. 30 | procDef.assertKind(nnkProcDef) 31 | let firstParamTypeName = procDef.firstParamType.typeName 32 | 33 | let procForType = getProcForType(name, firstParamTypeName) 34 | let isAlreadyRegistered = procForType.isSome() 35 | if isAlreadyRegistered: 36 | raiseRouteValidationError(procDef, fmt"A handler proc for type '{firstParamTypeName}' has already been registered for '{name.string}' at '{procForType.get().lineInfo}'") 37 | 38 | proc validateAllTypesHaveHandlers*(name: ThreadName) = 39 | let routeMsgTypeNames = name.getRoutes().mapIt(it.firstParamType().typeName()).toHashSet() 40 | for typ in name.getTypes(): 41 | if typ.typeName() notin routeMsgTypeNames: 42 | error(fmt""" 43 | Incorrect threadServer definition '{name.string}'. 44 | The message type '{typ.typeName()}' was registered for thread '{name.string}' but no handler for it was registered! 45 | """.dedent(8)) 46 | 47 | proc validateParamCount*(name: ThreadName, procDef: NimNode, expectedParamCount: int) = 48 | procDef.assertKind(nnkProcDef) 49 | 50 | let paramCount = procDef.params.len() - 1 51 | if paramCount != expectedParamCount: 52 | raiseRouteValidationError(procDef, fmt"Handler proc did not have '{expectedParamCount}' parameters, but '{paramCount}'. Handler procs must follow the pattern `proc (msg: , hub: ChannelHub)`") 53 | 54 | proc validateChannelHubParam*(name: ThreadName, procDef: NimNode, expectedHubParamPosition: int) = 55 | procDef.assertKind(nnkProcDef) 56 | 57 | let hubParam = procDef.params[expectedHubParamPosition] 58 | let hubParamTypeName = $hubParam[1] 59 | let isChannelHubType = hubParamTypeName == "ChannelHub" 60 | if not isChannelHubType: 61 | raiseRouteValidationError(procDef, fmt"Handler proc parameter {expectedHubParamPosition} was not `ChannelHub` even though it was") 62 | 63 | proc sectionErrorText(name: ThreadName): string = fmt""" 64 | Failed to parse actor '{name.string}' messageType/handler blocks. 65 | Expected syntax of: 66 | messageType: 67 | Type1 68 | Type2 69 | ... 70 | 71 | handlers: 72 | proc handler1(msg: Type1, hub: ChannelHub) = 73 | 74 | 75 | proc handler2(msg: Type2, hub: ChannelHub) = 76 | 77 | 78 | ... 79 | """ 80 | 81 | proc validateRoute(name: ThreadName, procDef: NimNode) = 82 | procDef.assertKind(nnkProcDef) 83 | 84 | validateMsgType(name, procDef) 85 | validateFreeType(name, procDef) 86 | validateParamCount(name, procDef, expectedParamCount = 2) 87 | validateChannelHubParam(name, procDef, expectedHubParamPosition = 2) 88 | 89 | proc validateRoute( 90 | name: ThreadName, 91 | procDef: NimNode, 92 | expectedParamCount: int, 93 | expectedHubParamPosition: int 94 | ) = 95 | procDef.assertKind(nnkProcDef) 96 | 97 | validateMsgType(name, procDef) 98 | validateFreeType(name, procDef) 99 | validateParamCount(name, procDef, expectedParamCount) 100 | validateChannelHubParam(name, procDef, expectedHubParamPosition) 101 | 102 | 103 | proc validateTypeSection*(name: ThreadName, typeSection: NimNode) = 104 | typeSection.assertKind(nnkStmtList, sectionErrorText(name)) 105 | 106 | for typeDef in typeSection: 107 | typeDef.assertKind(nnkIdent) 108 | 109 | proc validateHandlerSection*( 110 | name: ThreadName, 111 | handlerSection: NimNode, 112 | expectedParamCount: int = 2, 113 | expectedHubParamPosition: int = 2 114 | ) = 115 | handlerSection.assertKind(nnkStmtList, sectionErrorText(name)) 116 | 117 | for handlerDef in handlerSection: 118 | handlerDef.assertKind(nnkProcDef) 119 | validateRoute(name, handlerDef, expectedParamCount, expectedHubParamPosition) 120 | 121 | proc validatePropertiesSection*(name: ThreadName, propertySection: NimNode) = 122 | propertySection.assertKind(nnkStmtList, sectionErrorText(name)) 123 | 124 | for propertyNode in propertySection: 125 | propertyNode.assertKind(@[nnkAsgn, nnkCall]) 126 | let propertyName = $propertyNode[0] 127 | if propertyName notin PROPERTY_NAMES: 128 | error(fmt"Invalid property name '{propertyName}'. Only the following properties are allowed: '{PROPERTY_NAMES}'") 129 | 130 | proc validateSectionNames*(body: NimNode) = 131 | let sectionParents: seq[NimNode] = body.getNodesOfKind(nnkCall) 132 | let sectionNames = sectionParents.mapIt($it[0]) 133 | for name in sectionNames: 134 | let isValidSectionName = name in SECTION_NAMES 135 | if not isValidSectionName: 136 | error(fmt"Invalid section name '{name}'. Only the following sections are allowed: '{SECTION_NAMES}'") -------------------------------------------------------------------------------- /threadButler.nimble: -------------------------------------------------------------------------------- 1 | # Package 2 | 3 | version = "0.1.0" 4 | author = "Philipp Doerner" 5 | description = "Use threads as if they were servers/microservices to enable multi-threading with a simple mental model." 6 | license = "MIT" 7 | srcDir = "src" 8 | 9 | 10 | # Dependencies 11 | 12 | requires "nim >= 2.0.0" 13 | requires "taskpools >= 0.0.5" 14 | requires "chronicles >= 0.10.3" 15 | requires "chronos >= 4.0.0" 16 | taskRequires "nimidocs", "nimibook#head" 17 | requires "threading#head" # for -d:butlerThreading 18 | requires "https://github.com/nim-works/loony.git >= 0.1.12" # for -d:butlerLoony 19 | 20 | # Dev Dependencies 21 | # requires "https://github.com/disruptek/balls#v4" 22 | 23 | # Example-Dependencies 24 | requires "owlkettle#head" 25 | 26 | import std/[strutils, strformat, sequtils] 27 | 28 | proc isNimFile(path: string): bool = path.endsWith(".nim") 29 | proc isExampleFile(path: string): bool = 30 | let file = path.split("/")[^1] 31 | return file.startsWith("ex_") 32 | 33 | proc findExamples(path: string): seq[string] = 34 | for file in listFiles(path): 35 | if file.isNimFile() and file.isExampleFile(): 36 | result.add(file) 37 | 38 | for dir in listDirs(path): 39 | result.add(findExamples(dir)) 40 | 41 | proc echoSeparator() = 42 | echo "=".repeat(100) 43 | 44 | task example, "run a single example from the examples directory": 45 | let params = commandLineParams.filterIt(it.startsWith("-")).join(" ") 46 | let fileName = commandLineParams.filterIt(it.endsWith(".nim"))[0] 47 | for example in findExamples("./examples"): 48 | if example.endsWith(fileName): 49 | let command = fmt"nim r {params} {example}" 50 | echo "Command: ", command 51 | exec command 52 | break 53 | 54 | task examples, "compile all examples": 55 | let params = commandLineParams.filterIt(it.startsWith("-")).join(" ") 56 | let queues = @[ 57 | ("std/system.Channels", ""), 58 | ("threading/channels.Chan", "-d:butlerThreading"), 59 | ("LoonyQueue", "-d:butlerLoony") 60 | ] 61 | 62 | for (title, queueFlag) in queues: 63 | echo fmt"INFO: ### COMPILE WITH {title} ###" 64 | for file in findExamples("./examples"): 65 | let command = fmt"nim c {queueFlag} {params} {file}" 66 | echo "INFO: Compile ", command 67 | exec command 68 | 69 | echo "INFO: OK" 70 | echoSeparator() 71 | echoSeparator() 72 | echo fmt"INFO: {title} - OK" 73 | echoSeparator() 74 | echoSeparator() 75 | 76 | task exampleList, "list all available examples": 77 | for file in findExamples("./examples"): 78 | echo file 79 | 80 | task docs, "Generate the nim docs": 81 | const outdir = "./docs/htmldocs" 82 | if dirExists(outdir): exec fmt"rm -r {outdir}" 83 | 84 | const outdirParam = fmt"--outdir:{outdir}" 85 | let paramSets = @[ 86 | @[ 87 | "--project", 88 | "--index:only", 89 | outdirParam, 90 | "src/threadButler.nim" 91 | ], 92 | @[ 93 | "--index:on", 94 | fmt"{outdirParam}/threadButler/integrations", 95 | "src/threadButler/integration/owlButler.nim" 96 | ], 97 | @[ 98 | "--index:on", 99 | fmt"{outdirParam}/threadButler/integrations", 100 | "src/threadButler/integration/owlCodegen.nim" 101 | ], 102 | @[ 103 | "--project", 104 | "--index:on", 105 | outdirParam, 106 | "src/threadButler.nim" 107 | ] 108 | ] 109 | 110 | for paramSet in paramSets: 111 | echoSeparator() 112 | let paramStr = paramSet.join(" ") 113 | let command = fmt"nim doc -d:butlerDocs -d:butlerDocsDebug --git.url:git@github.com:PhilippMDoerner/ThreadButler.git --git.commit:master --hints:off {paramStr}" 114 | echo "Command: ", command 115 | exec command 116 | 117 | task benchmark, "Run the benchmark to check if there are performance differences between system.Channel and threading/channels.Chan": 118 | let file = "examples/ex_benchmark.nim" 119 | var params = @[ 120 | "-d:release", 121 | "--warnings:off", 122 | "--hints:off", 123 | # "--define:butlerDebug", 124 | "-f", 125 | "-d:useMalloc", 126 | "-d:chronicles_enabled=off", 127 | ] 128 | let paramStr = params.join(" ") 129 | let command = fmt"nim r {paramStr} {file}" 130 | echoSeparator() 131 | 132 | echo fmt"INFO system.Channel: {command}" 133 | exec command 134 | 135 | params.add("--define:butlerThreading") 136 | let paramStr2 = params.join(" ") 137 | let command2 = fmt"nim r {paramStr2} {file}" 138 | 139 | echoSeparator() 140 | 141 | echo fmt"INFO threading/channel.Chan: {command2}" 142 | exec command 143 | 144 | echoSeparator() 145 | 146 | task stress, "Runs the stress test permanently. For memory leak detection": 147 | var params = @[ 148 | "-d:release", 149 | "--warnings:off", 150 | "--hints:off", 151 | "--define:butlerDebug", 152 | "-f", 153 | "-d:useMalloc" 154 | ] 155 | let paramStr = params.join(" ") 156 | let command = fmt"nim r {paramStr} examples/stresstest.nim" 157 | echo fmt"INFO Running Stresstest: {command}" 158 | exec command 159 | 160 | task tests, "Runs the test-suite": 161 | let params = @[ 162 | "--mm:arc", 163 | "--mm:orc", 164 | "--cc:clang", 165 | "--debugger:native", 166 | "--threads:on", 167 | "-d:butlerThreading", 168 | "--passc:\"-fno-omit-frame-pointer\"", 169 | "--passc:\"-mno-omit-leaf-frame-pointer\"", 170 | "-d:chronicles_enabled=off", 171 | "-d:useMalloc", 172 | ] 173 | let paramsStr = params.join(" ") 174 | let command = fmt"balls {paramsStr}" 175 | echo command 176 | exec command 177 | 178 | task runTest, "Runs a single test file with asan or tsan": 179 | let params = @[ 180 | "--cc:clang", 181 | "--debugger:native", 182 | """--passc:"-fno-omit-frame-pointer -mno-omit-leaf-frame-pointer" """, 183 | "-d:chronicles_enabled=off", 184 | "--threads:on", 185 | "-d:butlerThreading", 186 | "-d:danger", 187 | "-d:useMalloc", 188 | ] 189 | let paramsStr = params.join(" ") 190 | let file = commandLineParams[^1] 191 | 192 | for sanitizer in ["address", "thread"]: 193 | for memoryModel in ["arc", "orc"]: 194 | let command = fmt"""nim r --mm:{memoryModel} --passl:"-fsanitize={sanitizer}" --passc:"-fsanitize={sanitizer}" {paramsStr} tests/{file}""" 195 | echo command 196 | exec command 197 | 198 | task nimidocs, "Compiles the nimibook docs": 199 | rmDir "docs/bookCompiled" 200 | exec "nimble install -y nimib@#head nimibook@#head" 201 | exec "nim c -d:release --mm:refc nbook.nim" 202 | exec "./nbook --mm:refc update" 203 | exec "cp -r ./assets ./docs/bookCompiled" 204 | exec "./nbook --path:./src -d:butlerDocs --mm:refc build" -------------------------------------------------------------------------------- /src/threadButler/register.nim: -------------------------------------------------------------------------------- 1 | import std/[macros, sequtils, macrocache, strformat, options] 2 | import ./utils 3 | 4 | ## Deals with storing procs and types in the CacheTables `types` (typeTable) and `routes` (routeTable). 5 | ## Abstracts them away in order to hide the logic needed to store and retrieve multiple NimNodes for a single key. 6 | 7 | 8 | const types = CacheTable"typeTable" ## \ 9 | ## Stores a list of types for a given "threadServer" based on a given name 10 | ## The procs are stored in a StatementList-NimNode for later retrieval, 11 | ## turning this effectively in a complicated Version of CacheTable[string, CacheSeq] 12 | 13 | const routes = CacheTable"routeTable" ## \ 14 | ## Stores a list of procs for a given "threadServer" based on a given name 15 | ## The procs are stored in a StatementList-NimNode for later retrieval, 16 | ## turning this effectively in a complicated Version of CacheTable[string, CacheSeq] 17 | 18 | const properties = CacheTable"propertiesTable" ## \ 19 | ## Stores a list of properties for a given "threadServer" based on a given name. 20 | ## The properties are stoerd in a StatementList-NimNode for later retrieval, 21 | ## turning this effectively in a complicated Version of CacheTable[string, CacheSeq] 22 | 23 | const registeredThreads = CacheSeq"threads" 24 | 25 | type ThreadName* = distinct string 26 | proc `==`*(x, y: ThreadName): bool {.borrow.} 27 | 28 | proc typeName*(node: NimNode): string = 29 | ## Extracts the name of a type from an nnkTypeDef NimNode 30 | node.assertKind(nnkIdent) 31 | return $node 32 | 33 | proc firstParamType*(node: NimNode): NimNode = 34 | ## Extracts the nnkTypeDef NimNode of the first parameter from 35 | ## an nnkProcDef NimNode 36 | node.assertKind(nnkProcDef) 37 | let firstParam = node.params[1] 38 | let typeNode = firstParam[1] 39 | 40 | case typeNode.kind: 41 | of nnkIdent: 42 | return typeNode 43 | of nnkSym: 44 | return ($typeNode).ident() 45 | of nnkCommand: 46 | let isSinkCommand = $typeNode[0] == "sink" 47 | assert isSinkCommand 48 | return typeNode[1] 49 | else: error("This type of message type is not supported: " & $typeNode.kind) 50 | 51 | proc getRoutes*(name: ThreadName): seq[NimNode] = 52 | ## Returns a list of all registered routes for `name` 53 | ## Returns an empty list if no routes were ever registered for `name`. 54 | let name = name.string 55 | let hasRoutes = routes.hasKey(name) 56 | if not hasRoutes: 57 | return @[] 58 | 59 | for route in routes[name]: 60 | result.add(route) 61 | 62 | proc getTypes*(name: ThreadName): seq[NimNode] = 63 | ## Returns a list of all registered types for `name` 64 | ## Returns an empty list if no types were ever registered for `name`. 65 | let name = name.string 66 | let hasTypes = types.hasKey(name) 67 | if not hasTypes: 68 | return @[] 69 | 70 | for typ in types[name]: 71 | result.add(typ) 72 | 73 | proc hasTypeOfName*(name: ThreadName, typName: string): bool = 74 | ## Checks if a type of name `typName` is already registered for `name`. 75 | for typ in name.getTypes(): 76 | if typ.typeName() == typName: 77 | return true 78 | 79 | return false 80 | 81 | proc getTypeOfName*(name: ThreadName, typName: string): Option[NimNode] = 82 | ## Fetches the nnkTypeDef NimNode of a type with the name `typName` registered for `name`. 83 | for typ in name.getTypes(): 84 | if typ.typeName() == typName: 85 | return some(typ) 86 | 87 | return none(NimNode) 88 | 89 | proc validateType(name: ThreadName, typeDef: NimNode) = 90 | let typeName = typeDef.typeName() 91 | let isAlreadyRegistered = name.hasTypeOfName(typeName) 92 | if isAlreadyRegistered: 93 | let otherType = name.getTypeOfName(typeName) 94 | let addInfo = if otherType.isSome(): 95 | fmt"(see: {otherType.get().lineInfo})" 96 | else: 97 | "(but could not find the type)" 98 | error(fmt"Failed to register '{typeName}' from '{typeDef.lineInfo}'. A type with that name was already registered {addInfo}") 99 | 100 | proc addType*(name: ThreadName, typeDef: NimNode) = 101 | ## Stores the nnkTypeDef NimNode `typeDef` for `name` in the CacheTable `types`. 102 | ## Raises a compile-time error if a type with the same name was already registered for `name`. 103 | typeDef.assertKind(nnkIdent, "You need a type name to store a type") 104 | let isFirstType = not types.hasKey(name.string) 105 | if isFirstType: 106 | types[name.string] = newStmtList() 107 | 108 | validateType(name, typeDef) 109 | 110 | types[name.string].add(typeDef) 111 | 112 | proc hasProcForType*(name: ThreadName, typName: string): bool = 113 | ## Checks if a handler proc whose first parameter type is `typName` 114 | ## is already registered for `name`. 115 | for handlerProc in name.getRoutes(): 116 | if handlerProc.firstParamType().typeName() == typName: 117 | return true 118 | 119 | return false 120 | 121 | proc getProcForType*(name: ThreadName, typName: string): Option[NimNode] = 122 | ## Fetches the nnkProcDef NimNode of a handler proc whose first parameter type 123 | ## name is `typName` registered for `name`. 124 | for handlerProc in name.getRoutes(): 125 | if handlerProc.firstParamType().typeName() == typName: 126 | return some(handlerProc) 127 | 128 | return none(NimNode) 129 | 130 | proc addRoute*(name: ThreadName, procDef: NimNode) = 131 | ## Stores the nnkProcDef NimNode `procDef` for `name` in the CacheTable `routes`. 132 | ## Raises a compile-time error if: 133 | ## - No type was registered matching the first parameter type of procDef 134 | ## - A handler proc with the same first parameter type was already registered for `name` 135 | procDef.assertKind(nnkProcDef, "You need a proc definition to add a route in order to extract the first parameter") 136 | 137 | let name = name.string 138 | let isFirstRoute = not routes.hasKey(name) 139 | if isFirstRoute: 140 | routes[name] = newStmtList() 141 | 142 | routes[name].add(procDef) 143 | 144 | proc hasRoutes*(name: ThreadName): bool = 145 | name.getRoutes().len > 0 146 | 147 | proc hasTypes*(name: ThreadName): bool = 148 | name.getTypes().len > 0 149 | 150 | proc addProperty*(name: ThreadName, property: NimNode) = 151 | property.assertKind(@[nnkCall, nnkAsgn], "You need a property assignment with ':' or '=' to add a property") 152 | let name = name.string 153 | 154 | let isFirstProperty = not properties.hasKey(name) 155 | if isFirstProperty: 156 | properties[name] = newStmtList() 157 | 158 | properties[name].add(property) 159 | 160 | proc getProperties*(name: ThreadName): seq[NimNode] = 161 | ## Returns a list of all registered properties for `name` 162 | ## Returns an empty list if no properties were ever registered for `name`. 163 | let name = name.string 164 | 165 | let hasProperties = properties.hasKey(name) 166 | if not hasProperties: 167 | return @[] 168 | 169 | for property in properties[name]: 170 | result.add(property) 171 | 172 | proc registerThread*(name: ThreadName) = 173 | let node = newStrLitNode(name.string) 174 | registeredThreads.add(node) 175 | 176 | proc getRegisteredThreadnames*(): seq[ThreadName] = 177 | registeredThreads.mapIt(ThreadName($it)) -------------------------------------------------------------------------------- /docs/book/basics.nim: -------------------------------------------------------------------------------- 1 | import nimib, nimibook 2 | 3 | 4 | nbInit(theme = useNimibook) 5 | 6 | nbText: """ 7 | # First example 8 | 9 | Note: Throughout this code example it may be helpful to also have the **glossary** page open. 10 | It defines various terms used throughout the book (e.g. `Message-Wrapper-Type`). 11 | 12 | If you want to first look at the code in its entirety, take a look at the full example at the bottom of the page. 13 | 14 | ## The Example 15 | Our example is a main thread that runs a "client" that reads from the terminal. 16 | It either sends a message to a "server" or shuts down the program based on what the terminal input is. 17 | 18 | So we have 2 threads, each running a single threadServer: 19 | 1) A client threadServer listening to user input in the terminal and messages from the backend server 20 | 2) A backend threadServer listening for messages from the client and sending responses 21 | 22 | But before we can define them, we first need to know the messages we're about to send around. 23 | 24 | 25 | ## Message Types 26 | ```nim 27 | type Response = distinct string 28 | type Request = distinct string 29 | ``` 30 | This defines a message type for messages we want to send from the client to the server (`Request`) 31 | and one for sending back messages from the server to the client (`Response`) 32 | 33 | Basically, before anything, we need to define the types of the messages that we want to send around. 34 | This is essential, as we later connect those message types to the threadServers 35 | and use them in our handler procs. 36 | 37 | 38 | ## The Client 39 | With the message types defined, we now can define our client threadServer: 40 | ```nim 41 | import std/[sugar, options, strformat, os] 42 | import threadButler 43 | 44 | threadServer("client"): 45 | messageTypes: 46 | Response 47 | 48 | handlers: 49 | proc handleResponseOnClient(msg: Response, hub: ChannelHub) = 50 | echo "On Client: ", msg.string 51 | ``` 52 | This defines a threadServer with the threadname "client". 53 | That name is important, because based on it threadButler will generate a lot of code and derive variable- and typenames from it. 54 | For an overview over all the things it generates, see the [docs](https://philippmdoerner.github.io/ThreadButler/htmldocs/threadButler/codegen.html#threadServer.m%2Cstaticstring%2Cuntyped) 55 | 56 | We see here that "client" has 2 sections: 57 | 1) messageTypes 58 | 2) handlers 59 | 60 | ### messageTypes 61 | The messageTypes section defines one or more types of messages that "client" can receive (here only `Response`). 62 | 63 | Types in the messageType block **must** be unique. 64 | That means that a type can only be registered *for one threadServer*. 65 | 66 | This is why we defined `Response` and `Request` as distinct strings earlier. 67 | Even if they're both just strings, you can not register the type `string` for both 68 | "client" and "server" (which we will define later). 69 | 70 | This also gives threadButler important information: 71 | ThreadButler now knows that `Response` is only registered for and can only be received by "client". 72 | So when we later want to send a message of the `Response` type, threadButler knows that it is intended for "client". 73 | 74 | ### handlers 75 | The handlers section defines how to handle a message of a specific message type that "client" may receive. 76 | 77 | It is a bunch of procs that **must** cover all types defined in `messageTypes`. 78 | ThreadButler will tell you at compile-time if you forgot to define a handler for one of the types or added a handler whose type is not mentioned in `messageTypes`. 79 | 80 | These procs must have this signature: 81 | ```nim 82 | proc (msg: , hub: ChannelHub) 83 | ``` 84 | Where `` can be whatever you want and `` is one of the types in `messageTypes`. 85 | 86 | This also is our first encounter with `ChannelHub`. 87 | We will see it quite often, as sending a message is only possible through `ChannelHub`, which is an object 88 | shared and used by all threadServers. 89 | 90 | 91 | ## The Server 92 | With our client defined, lets define our server which shall receive `Request` messages and in turn 93 | send back `Response` messages. 94 | 95 | ```nim 96 | threadServer("server"): 97 | properties: 98 | startUp = @[initEvent(() => echo "Server startin up!")] 99 | shutDown = @[initEvent(() => echo "Server shutting down!")] 100 | 101 | messageTypes: 102 | Request 103 | 104 | handlers: 105 | proc handleRequestOnServer(msg: Request, hub: ChannelHub) = 106 | echo "On Server: ", msg.string 107 | discard hub.sendMessage(Response("Handled: " & msg.string)) 108 | ``` 109 | 110 | We see the familiar "messageTypes" which defines that "server" can receive `Request` messages. 111 | There's also the expected "handler" with `handleRequestOnServer` telling us what happens when a `Request` 112 | message is received. 113 | 114 | However, properties is new! 115 | 116 | The properties section is where you can define special properties that influence a threadServer's behaviour unrelated to messages and their handling. 117 | The properties we set here are `startUp` and `shutDown`, which define events that get executed before/after the server has run. 118 | 119 | In this case we're just writing some text to the terminal before and after the server runs. 120 | Other useful applications for them are: 121 | - Initializing/closing resources required by this threadServer 122 | - Initializing Loggers for the server 123 | 124 | There are more properties that you can define. For more details see the "threadServer" page. 125 | 126 | ## Finishing touches 127 | With our servers defined, we can now bring it all together. 128 | 129 | ```nim 130 | prepareServers() 131 | 132 | let hub = new(ChannelHub) 133 | 134 | withServer(hub, "server"): 135 | while keepRunning(): 136 | echo "\nType in a message to send to the Backend!" 137 | let terminalInput = readLine(stdin) 138 | case terminalInput 139 | of "kill", "q", "quit": 140 | break 141 | else: 142 | let msg = terminalInput.Request 143 | discard hub.sendMessage(msg) 144 | 145 | sleep(100) 146 | 147 | let response: Option[ClientMessage] = hub.readMsg(ClientMessage) 148 | if response.isSome(): 149 | routeMessage(response.get(), hub) 150 | 151 | destroy(hub) 152 | ``` 153 | That's quite a lot at once, let's look at the sections individually. 154 | 155 | ### prepareServers 156 | The first thing that stands out is `prepareServers`. 157 | 158 | That is a special macro from threadButler that generates some of the code that it can only generate 159 | once all threadServers were defined. 160 | 161 | For an overview over all the things it generates, look at the [docs](https://philippmdoerner.github.io/ThreadButler/htmldocs/threadButler/codegen.html#prepareServers.m). 162 | 163 | ### new(ChannelHub) 164 | This instantiates the one instance of `ChannelHub` that will be used everywhere. 165 | 166 | ### withServer 167 | "server" is then started at the beginning of `withServer` and automatically shut down once the scope of `withServer` ends. 168 | 169 | Inside of the scope of `withServer` we can then define the "event-loop" code that should run on the main-thread which has executed the code so far. 170 | 171 | ThreadButler has a global switch that can be used to shut all remaining servers off. 172 | We're using that switch by using `keepRunning()`, which will return false if `shutdownAllServers()` is ever called. 173 | Every thread spawned by thredButler with its default event-loop uses that switch. 174 | 175 | We then stop the loop to listen for user-input and do not continue until user-input was provided. 176 | 177 | If the user types in "kill", "q" or "quit" this will break the main-event-loop, we reach the end of `withServer`, "server" shuts down and the program ends. 178 | 179 | If the user types in anything else, our main-event-loop (aka "client") will send a `Request` message to "server" with the terminal input. 180 | It will then sleep for 100ms (to give "server" plenty of time to reply) and check for messages send to "client" on the ChannelHub. 181 | 182 | Note how we do this using `ClientMessage`. 183 | This is a type generated by `threadServer` that can contain any of the messages sent to "client". 184 | The name of this type is inferred from the name we provided earlier, "client". 185 | For more details see the "Docs for generated code" page. 186 | 187 | If we have a message, we then handle it using the also generated `routeMessage` convenience proc. 188 | It will unpack `ClientMessage` to `Response` and route it to `handleResponseOnClient`, which then handles the message as we defined earlier. 189 | 190 | 191 | ## Full example 192 | """ 193 | 194 | nbCode: 195 | import std/[sugar, options, strformat, os] 196 | import threadButler 197 | 198 | const CLIENT_THREAD = "client" 199 | const SERVER_THREAD = "server" 200 | type Response = distinct string 201 | type Request = distinct string 202 | 203 | # === DEFINE YOUR THREADSERVERS === # 204 | threadServer(CLIENT_THREAD): 205 | messageTypes: 206 | Response 207 | 208 | handlers: 209 | proc handleResponseOnClient(msg: Response, hub: ChannelHub) = 210 | echo "On Client: ", msg.string 211 | 212 | threadServer(SERVER_THREAD): 213 | properties: 214 | startUp = @[initEvent(() => echo "Server startin up!")] 215 | shutDown = @[initEvent(() => echo "Server shutting down!")] 216 | 217 | messageTypes: 218 | Request 219 | 220 | handlers: 221 | proc handleRequestOnServer(msg: Request, hub: ChannelHub) = 222 | echo "On Server: ", msg.string 223 | discard hub.sendMessage(Response("Handled: " & msg.string)) 224 | 225 | prepareServers() 226 | 227 | # === Bringing it all together === # 228 | when defined(butlerDocs): ## Needed so that compiling the docs does not run the server 229 | shutdownAllServers() 230 | 231 | let hub = new(ChannelHub) 232 | hub.withServer(SERVER_THREAD): 233 | while keepRunning(): 234 | echo "\nType in a message to send to the Backend!" 235 | # This is blocking, so this while-loop stalls here until the user hits enter. 236 | # Thus the entire loop only runs once whenever the user hits enter. 237 | # Thus it can only receive one message per enter press. 238 | let terminalInput = readLine(stdin) 239 | case terminalInput 240 | of "kill", "q", "quit": 241 | hub.sendKillMessage(ServerMessage) 242 | break 243 | else: 244 | let msg = terminalInput.Request 245 | discard hub.sendMessage(msg) 246 | 247 | ## Guarantees that the server has responded before we listen for user input again. 248 | ## This is solely for neater logging when running the example. 249 | sleep(100) 250 | 251 | let response: Option[ClientMessage] = hub.readMsg(ClientMessage) 252 | if response.isSome(): 253 | routeMessage(response.get(), hub) 254 | 255 | destroy(hub) 256 | 257 | nbSave() -------------------------------------------------------------------------------- /assets/architecture.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | -------------------------------------------------------------------------------- /assets/app_architecture.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | -------------------------------------------------------------------------------- /src/threadButler/codegen.nim: -------------------------------------------------------------------------------- 1 | import std/[macros, tables, genasts, strformat, sets, strutils, unicode, sequtils] 2 | import ./utils 3 | import ./register 4 | import ./channelHub 5 | import ./validation 6 | import ./types 7 | 8 | export utils 9 | 10 | ##[ .. importdoc:: channelHub.nim 11 | Defines all code for code generation in threadbutler. 12 | All names of generated types are inferred from the name they are being registered with. 13 | All names of fields and enum kinds are inferred based on the data registered. 14 | 15 | .. note:: Only the macros are for general users. The procs are only for writing new integrations. 16 | 17 | ]## 18 | proc variantName*(name: ThreadName): string = 19 | ## Infers the name of the Message-object-variant-type associated with `name` from `name` 20 | name.string.capitalize() & "Message" 21 | 22 | proc threadVariableName*(name: ThreadName): string = 23 | ## Infers the name of the variable containing the Thread that runs the server from `name` 24 | name.string.toLower() & "ButlerThread" 25 | 26 | proc enumName*(name: ThreadName): string = 27 | ## Infers the name of the enum-type associated with `name` from `name` 28 | name.string.capitalize() & "Kinds" 29 | 30 | proc firstParamName*(node: NimNode): string = 31 | ## Extracts the name of the first parameter of a proc 32 | node.assertKind(@[nnkProcDef]) 33 | let firstParam = node.params[1] 34 | firstParam.assertKind(nnkIdentDefs) 35 | return $firstParam[0] 36 | 37 | proc kindName*(node: NimNode): string = 38 | ## Infers the name of a kind of an enum from a type 39 | node.assertKind(@[nnkIdent]) 40 | let typeName = node.typeName 41 | return capitalize(typeName) & "Kind" 42 | 43 | proc killKindName*(name: ThreadName): string = 44 | ## Infers the name of the enum-kind for a message that kills the thread 45 | ## associated with `name` from `name`. 46 | "Kill" & name.string.capitalize() & "Kind" 47 | 48 | proc fieldName*(node: NimNode): string = 49 | ## Infers the name of a field on a Message-object-variant-type from a type 50 | node.assertKind(@[nnkIdent]) 51 | let typeName = node.typeName() 52 | return normalize(typeName) & "Msg" 53 | 54 | proc signalName*(name: ThreadName): string = 55 | ## Infers the name of the thread-signal used to wake up the thread from 56 | ## sleeping 57 | name.string.toLower() & "Signal" 58 | 59 | macro toThreadVariable*(name: static string): untyped = 60 | ## Generates the identifier for the global variable containing the thread for 61 | ## `name`. 62 | let varName = name.ThreadName.threadVariableName() 63 | return varName.ident() 64 | 65 | macro toVariantType*(name: static string): untyped = 66 | ## Generate the typedesc identifier for the message-wrapper-type 67 | let variantName = name.ThreadName.variantName() 68 | return newIdentNode(variantName) 69 | 70 | proc extractTypeDefs(node: NimNode): seq[NimNode] = 71 | ## Extracts nnkTypeDef-NimNodes from a given node. 72 | ## Does not extract all nnkTypeDef-NimNodes, only those that were added using supported Syntax. 73 | ## For the supported syntax constellations see `registerTypeFor`_ 74 | node.assertKind(@[nnkTypeDef, nnkSym, nnkStmtList, nnkTypeSection]) 75 | 76 | case node.kind: 77 | of nnkTypeDef: 78 | result.add(node) 79 | 80 | of nnkSym: 81 | let typeDef = node.getImpl() 82 | typeDef.assertKind(nnkTypeDef) 83 | result.add(typeDef) 84 | 85 | of nnkStmtList, nnkTypeSection: 86 | for subNode in node: 87 | case subNode.kind: 88 | of nnkTypeDef: 89 | result.add(subNode) 90 | 91 | of nnkTypeSection: 92 | for subSubNode in subNode: 93 | subSubNode.assertKind(nnkTypeDef) 94 | result.add(subSubNode) 95 | 96 | else: 97 | error(fmt"Inner node of kind '{subNode.kind}' is not supported!") 98 | else: 99 | error(fmt"Node of kind '{node.kind}' not supported!") 100 | 101 | proc asEnum(name: ThreadName, types: seq[NimNode]): NimNode = 102 | ## Generates an enum type for `name`. 103 | ## It has one kind per type in `types` + a "killKind". 104 | ## The name of the 'killKind' is inferred from `name`, see the proc `killKindName`_. 105 | ## The name of the enum-type is inferred from `name`, see the proc `enumName`_. 106 | ## The name of the individual other enum-kinds is inferred from the various typeNames, see the proc `kindName`_. 107 | ## The enum is generated according to the pattern: 108 | ## ``` 109 | ## type Kinds = enum 110 | ## --- Repeat per type - start --- 111 | ## 112 | ## --- Repeat per type - end --- 113 | ## 114 | ## ``` 115 | var enumFields: seq[NimNode] = types.mapIt(ident(it.kindName)) 116 | let killThreadKind = ident(name.killKindName) 117 | enumFields.add(killThreadKind) 118 | 119 | return newEnum( 120 | name = ident(name.enumName), 121 | fields = enumFields, 122 | public = true, 123 | pure = true 124 | ) 125 | 126 | proc asVariant(name: ThreadName, types: seq[NimNode] ): NimNode = 127 | ## Generates a object variant type for `name`. 128 | ## The variantName is inferred from `name`, see the proc `variantName`_. 129 | ## The name of the killKind is inferred from `name`, see the proc `killKindName`_. 130 | ## The name of msgField is inferred from `type`, see the proc `fieldName`_. 131 | ## Uses the enum-type generated by `asEnum`_ for the discriminator. 132 | ## The variant is generated according to the pattern: 133 | ## ``` 134 | ## type = ref object 135 | ## case kind*: 136 | ## --- Repeat per type - start --- 137 | ## of : 138 | ## : 139 | ## --- Repeat per type - end --- 140 | ## of : discard 141 | ## ``` 142 | # Generates: case kind*: 143 | let caseNode = nnkRecCase.newTree( 144 | nnkIdentDefs.newTree( 145 | postfix(newIdentNode("kind"), "*"), 146 | newIdentNode(name.enumName), 147 | newEmptyNode() 148 | ) 149 | ) 150 | 151 | for typ in name.getTypes(): 152 | # Generates: of : : 153 | typ.assertKind(nnkIdent) 154 | let branchNode = nnkOfBranch.newTree( 155 | newIdentNode(typ.kindName), 156 | nnkRecList.newTree( 157 | newIdentDefs( 158 | postfix(newIdentNode(typ.fieldName), "*"), 159 | ident(typ.typeName) 160 | ) 161 | ) 162 | ) 163 | 164 | caseNode.add(branchNode) 165 | 166 | # Generates: of : discard 167 | let killBranchNode = nnkOfBranch.newTree( 168 | newIdentNode(name.killKindName), 169 | nnkRecList.newTree(newNilLit()) 170 | ) 171 | caseNode.add(killBranchNode) 172 | 173 | result = nnkTypeSection.newTree( 174 | nnkTypeDef.newTree( 175 | postfix(newIdentNode(name.variantName), "*"), 176 | newEmptyNode(), 177 | nnkRefTy.newTree( 178 | nnkObjectTy.newTree( 179 | newEmptyNode(), 180 | newEmptyNode(), 181 | nnkRecList.newTree( caseNode ) 182 | ) 183 | ) 184 | ) 185 | ) 186 | 187 | proc asThreadVar*(name: ThreadName): NimNode = 188 | ## Generates a global variable containing the thread for `name`: 189 | ## 190 | ## `var ButlerThread*: Thread[Server[Message]]` 191 | let variableName = name.threadVariableName().ident() 192 | let variantName = name.variantName().ident() 193 | 194 | return quote do: 195 | var `variableName`*: Thread[Server[`variantName`]] 196 | 197 | proc asSignalVar*(name: ThreadName): NimNode = 198 | ## Generates a global variable containing the signal for `name`: 199 | ## 200 | ## `var Signal*: ThreadSignalPtr` 201 | let variableName = name.signalName().ident() 202 | 203 | return quote do: 204 | var `variableName`* = new(ThreadSignalPtr)[] 205 | 206 | proc genMessageRouter*(name: ThreadName, routes: seq[NimNode], types: seq[NimNode]): NimNode = 207 | ## Generates a proc `routeMessage` for unpacking the object variant type for `name` and calling a handler proc with the unpacked value. 208 | ## The variantTypeName is inferred from `name`, see the proc `variantName`_. 209 | ## The name of the killKind is inferred from `name`, see the proc `killKindName`_. 210 | ## The name of msgField is inferred from `type`, see the proc `fieldName`_. 211 | ## The proc is generated based on the registered routes according to this pattern: 212 | ## ``` 213 | ## proc routeMessage\*(msg: , hub: ChannelHub) = 214 | ## case msg.kind: 215 | ## --- Repeat per route - start --- 216 | ## of : 217 | ## (msg., hub) # if sync handlerProc 218 | ## asyncSpawn (msg., hub) # if async handlerProc 219 | ## --- Repeat per route - end --- 220 | ## of : shutDownServer() 221 | ## ``` 222 | ## This proc should only be used by macros in this and other integration modules. 223 | result = newProc(name = postfix(ident("routeMessage"), "*")) 224 | let msgParamName = "msg" 225 | let msgParam = newIdentDefs( 226 | ident(msgParamName), 227 | nnkCommand.newTree( 228 | ident("sink"), 229 | ident(name.variantName) 230 | ) 231 | ) 232 | result.params.add(msgParam) 233 | 234 | let hubParam = newIdentDefs( 235 | ident("hub"), 236 | ident("ChannelHub") 237 | ) 238 | result.params.add(hubParam) 239 | 240 | let hasEmptyMessageVariant = not types.len() == 0 241 | if hasEmptyMessageVariant: 242 | result.body = nnkDiscardStmt.newTree(newEmptyNode()) 243 | return 244 | 245 | let caseStmt = nnkCaseStmt.newTree( 246 | newDotExpr(ident(msgParamName), ident("kind")) 247 | ) 248 | 249 | for handlerProc in routes: 250 | # Generates proc call `(., hub)` 251 | let firstParamType = handlerProc.firstParamType 252 | var handlerCall = nnkCall.newTree( 253 | handlerProc.name, 254 | newDotExpr(ident(msgParamName), ident(firstParamType.fieldName)), 255 | ident("hub") 256 | ) 257 | 258 | # Generates `of : ` 259 | let branchStatements = if handlerProc.isAsyncProc(): 260 | newStmtList(newCall("asyncSpawn".ident, handlerCall)) 261 | else: 262 | newStmtList(handlerCall) 263 | let branchNode = nnkOfBranch.newTree( 264 | ident(firstParamType.kindName), 265 | branchStatements 266 | ) 267 | 268 | caseStmt.add(branchNode) 269 | 270 | # Generates `of : shutdownServer()`: 271 | let killBranchNode = nnkOfBranch.newTree( 272 | ident(name.killKindName), 273 | nnkCall.newTree(ident("shutdownServer")) 274 | ) 275 | caseStmt.add(killBranchNode) 276 | 277 | result.body.add(caseStmt) 278 | 279 | proc genSenderProc(name: ThreadName, typ: NimNode): NimNode = 280 | ## Generates a generic proc `sendMessage`. 281 | ## 282 | ## These procs can be used by any thread to send messages to thread `name`. 283 | ## They "wrap" the message of type `typ` in the object-variant generated by 284 | ## `asVariant` before sending that message through the corresponding `Channel`. 285 | typ.assertKind(nnkIdent) 286 | 287 | let procName = newIdentNode("sendMessage") 288 | let msgType = newIdentNode(typ.typeName) 289 | let variantType = newIdentNode(name.variantName) 290 | let msgKind = newIdentNode(typ.kindName) 291 | let variantField = newIdentNode(typ.fieldName) 292 | let senderProcName = newIdentNode(channelHub.SEND_PROC_NAME) # This string depends on the name 293 | let serverSignal = newIdentNode(name.signalName()) 294 | let threadName = newStrLitNode(name.string) 295 | 296 | genAst(procName, msgType, senderProcName, variantType, msgKind, variantField, serverSignal, threadName): 297 | proc procName*(hub: ChannelHub, msg: sink msgType): bool = 298 | let hasSentMessage: bool = hub.senderProcName(variantType(kind: msgKind, `variantField`: move(msg))) 299 | 300 | if hasSentMessage: 301 | let response = serverSignal.fireSync() 302 | let hasSentSignal = response.isOk() 303 | if not hasSentSignal: 304 | notice "Failed to wake up threadServer " & threadName 305 | 306 | return hasSentMessage 307 | 308 | proc genNewChannelHubProc*(): NimNode = 309 | ## Generates a proc `new` for instantiating a ChannelHub 310 | ## with a channel to send messages to each thread. 311 | ## 312 | ## Uses `addChannel`_ to instantiate, open and add a channel. 313 | ## Uses `variantName`_ to infer the name Message-object-variant-type. 314 | ## The proc is generated based on the registered threadnames according to this pattern: 315 | ## ``` 316 | ## proc new\*(t: typedesc[ChannelHub], capacity: int = 500): ChannelHub = 317 | ## result = ChannelHub(channels: initTable[pointer, pointer]()) 318 | ## --- Repeat per threadname - start --- 319 | ## result.addChannel() 320 | ## --- Repeat per threadname - end --- 321 | ## ``` 322 | let capacityParam = "capacity".ident() 323 | result = quote do: 324 | proc new*(t: typedesc[ChannelHub], `capacityParam`: int = 500): ChannelHub = 325 | result = ChannelHub(channels: initTable[pointer, pointer]()) 326 | 327 | for threadName in getRegisteredThreadnames(): 328 | let variantType = newIdentNode(threadName.variantName) 329 | let addChannelLine = quote do: 330 | result.addChannel(`variantType`, `capacityParam`) 331 | result.body.add(addChannelLine) 332 | 333 | proc genDestroyChannelHubProc*(): NimNode = 334 | ## Generates a proc `destroy` for destroying a ChannelHub. 335 | ## 336 | ## Closes each channel stored in the hub as part of that. 337 | ## Uses `variantName`_ to infer the name Message-object-variant-type. 338 | ## The proc is generated based on the registered threadnames according to this pattern: 339 | ## ``` 340 | ## proc destroy\*(hub: ChannelHub) = 341 | ## --- Repeat per threadname - start --- 342 | ## hub.getChannel().close() 343 | ## --- Repeat per threadname - end --- 344 | ## ``` 345 | let hubParam = newIdentNode("hub") 346 | result = quote do: 347 | proc destroy*(`hubParam`: ChannelHub) = 348 | notice "Destroying Channelhub" 349 | 350 | for threadName in getRegisteredThreadnames(): 351 | let variantType = newIdentNode(threadName.variantName) 352 | let closeChannelLine = genAst(hubParam, variantType): 353 | hubParam.clearServerChannel(variantType) 354 | hubParam.getChannel(variantType).destroyChannel() 355 | let channelPtr = hubParam.getChannel(variantType).addr 356 | freeShared(channelPtr) 357 | notice "Destroyed Channel ", typ = $variantType, channelInt = cast[uint64](channelPtr) 358 | 359 | result.body.add(closeChannelLine) 360 | 361 | proc genSendKillMessageProc*(name: ThreadName): NimNode = 362 | ## Generates a proc `sendKillMessage`. 363 | ## 364 | ## These procs send a message that triggers the graceful shutdown of a thread. 365 | ## The thread to send the message to is inferred based on the object-variant for messages to that thread. 366 | ## The name of the object-variant is inferred from `name` via `variantName`_. 367 | let variantType = newIdentNode(name.variantName) 368 | let killKind = newIdentNode(name.killKindName) 369 | let senderProcName = newIdentNode(channelHub.SEND_PROC_NAME) # This string depends on the name 370 | let serverSignal = name.signalName().ident() 371 | let threadName = newStrLitNode(name.string) 372 | 373 | result = quote do: 374 | proc sendKillMessage*(hub: ChannelHub, msg: typedesc[`variantType`]) = 375 | let killMsg = `variantType`(kind: `killKind`) 376 | discard hub.`senderProcName`(killMsg) 377 | 378 | let response = `serverSignal`.fireSync() 379 | while not `serverSignal`.fireSync().isOk(): 380 | notice "Failed to wake up threadServer for kill message: " & `threadName` 381 | 382 | proc genInitServerProc*(name: ThreadName): NimNode = 383 | ## Generates a proc `initServer(hub: ChannelHub, typ: typedesc[Message]): Server[Message]`. 384 | ## 385 | ## These procs instantiate a threadServer object which can then be run, which starts the server. 386 | let variantType = newIdentNode(name.variantName) 387 | let signalVariable = newIdentNode(name.signalName) 388 | 389 | result = quote do: 390 | proc initServer*(hub: ChannelHub, typ: typedesc[`variantType`]): Server[`variantType`] = 391 | result = Server[`variantType`]() 392 | result.msgType = default(`variantType`) 393 | result.hub = hub 394 | result.signalReceiver = `signalVariable` 395 | 396 | for property in name.getProperties(): 397 | property[0].assertKind(nnkIdent) 398 | let propertyName = $property[0] 399 | let propertyValue = property[1] 400 | 401 | let assignment = nnkAsgn.newTree( 402 | nnkDotExpr.newTree( 403 | newIdentNode("result"), 404 | newIdentNode(propertyName) 405 | ), 406 | propertyValue 407 | ) 408 | result.body.add(assignment) 409 | 410 | proc generateCode*(name: ThreadName): NimNode = 411 | ## Generates all types and procs needed for message-passing for `name`. 412 | ## 413 | ## This proc should only be used by macros in this and other integration modules. 414 | 415 | result = newStmtList() 416 | 417 | let types = name.getTypes() 418 | 419 | let messageEnum = name.asEnum(types) 420 | result.add(messageEnum) 421 | 422 | let messageVariant = name.asVariant(types) 423 | result.add(messageVariant) 424 | 425 | result.add(name.asSignalVar()) 426 | 427 | for typ in name.getTypes(): 428 | result.add(genSenderProc(name, typ)) 429 | 430 | let killServerProc = name.genSendKillMessageProc() 431 | result.add(killServerProc) 432 | 433 | let genInitServerProc = name.genInitServerProc() 434 | result.add(genInitServerProc) 435 | 436 | result.add(name.asThreadVar()) 437 | 438 | proc getSections*(body: NimNode): Table[Section, NimNode] = 439 | let sectionParents: seq[NimNode] = body.getNodesOfKind(nnkCall) 440 | for parentNode in sectionParents: 441 | let sectionName = parseEnum[Section]($parentNode[0]) 442 | 443 | let sectionNode = parentNode[1] 444 | sectionNode.assertKind(nnkStmtList) 445 | 446 | result[sectionName] = sectionNode 447 | 448 | macro threadServer*(name: static string, body: untyped) = 449 | ## Defines a threadServer called `name` and registers it and 450 | ## its contents in `body` with threadButler. 451 | ## 452 | ## The `body` may declare any of these 3 sections: 453 | ## - properties 454 | ## - messageTypes 455 | ## - handlers 456 | ## 457 | ## procs in the handler sections must have the shape: 458 | ## ``` 459 | ## proc (msg: , hub: ChannelHub) 460 | ## ``` 461 | ## 462 | ## Generates all types and procs needed for message-passing for `name`: 463 | ## 1) An enum based representing all different types of messages that can be sent to the thread `name`. 464 | ## 2) An object variant that wraps any message to be sent through a channel to the thread `name`. 465 | ## 3) Generic `sendMessage` procs for sending messages to `name` by: 466 | ## - receiving a message-type 467 | ## - wrapping it in the object variant from 2) 468 | ## - sending that to a channel to the thread `name`. 469 | ## 4) Specific `sendKillMessage` procs for sending a "kill" message to `name` 470 | ## 5) An (internal) `initServer` proc to create a Server 471 | ## 6) A global variable that contains the thread that `name` runs on 472 | ## 473 | ## Note, this does not include all generated code. 474 | ## See `prepareServers`_ for the remaining code that should be called 475 | ## after all threadServers have been declared. 476 | body.expectKind(nnkStmtList) 477 | let name = name.ThreadName 478 | body.validateSectionNames() 479 | name.registerThread() 480 | let sections = body.getSections() 481 | 482 | let hasTypes = sections.hasKey(MessageTypes) 483 | if hasTypes: 484 | let typeSection = sections[MessageTypes] 485 | name.validateTypeSection(typeSection) 486 | for typ in typeSection: 487 | name.addType(typ) 488 | 489 | let hasHandlers = sections.hasKey(Handlers) 490 | if hasHandlers: 491 | let handlerSection = sections[Handlers] 492 | name.validateHandlerSection(handlerSection) 493 | for handler in handlerSection: 494 | name.addRoute(handler) 495 | 496 | name.validateAllTypesHaveHandlers() 497 | 498 | let hasProperties = sections.hasKey(Properties) 499 | if hasProperties: 500 | let propertiesSection = sections[Properties] 501 | name.validatePropertiesSection(propertiesSection) 502 | for property in propertiesSection: 503 | name.addProperty(property) 504 | 505 | result = name.generateCode() 506 | 507 | when defined(butlerDebug): 508 | echo fmt"=== Actor: {name.string} ===", "\n", result.repr 509 | 510 | macro prepareServers*() = 511 | ## Generates the remaining code that can only be provided once all 512 | ## threadServers have been defined and are known by threadButler. 513 | ## 514 | ## The generated procs are: 515 | ## 1) A routing proc for every registered thread. See `genMessageRouter`_ for specifics. 516 | ## 2) A `new(ChannelHub)` proc to instantiate a ChannelHub 517 | ## 3) A `destroy` proc to destroy a ChannelHub 518 | result = newStmtList() 519 | 520 | result.add(genNewChannelHubProc()) 521 | result.add(genDestroyChannelHubProc()) 522 | 523 | for name in getRegisteredThreadnames(): 524 | let handlers = name.getRoutes() 525 | for handler in handlers: 526 | result.add(handler) 527 | 528 | let routingProc = name.genMessageRouter(handlers, name.getTypes()) 529 | result.add(routingProc) 530 | 531 | when defined(butlerDebug): 532 | echo "=== Overall ===\n", result.repr 533 | --------------------------------------------------------------------------------