From f94f73b36fec9832b3d1a96d399a07c25d44009a Mon Sep 17 00:00:00 2001 From: "Zed A. Shaw" Date: Tue, 17 Sep 2024 09:52:00 -0400 Subject: [PATCH] Setup the C++ starter project with everything I've made so far to get going. --- .gitignore | 16 +++ cpp/Makefile | 18 +++ cpp/dbc.cpp | 40 +++++++ cpp/dbc.hpp | 29 +++++ cpp/fsm.hpp | 28 +++++ cpp/meson.build | 14 +++ cpp/scripts/reset_build.ps1 | 11 ++ cpp/scripts/reset_build.sh | 12 ++ cpp/tests/dbc.cpp | 39 +++++++ cpp/tests/fsm.cpp | 67 +++++++++++ js/fsm.js | 221 ++++++++++++++++++++++++++++++++++++ 11 files changed, 495 insertions(+) create mode 100644 .gitignore create mode 100644 cpp/Makefile create mode 100644 cpp/dbc.cpp create mode 100644 cpp/dbc.hpp create mode 100644 cpp/fsm.hpp create mode 100644 cpp/meson.build create mode 100644 cpp/scripts/reset_build.ps1 create mode 100644 cpp/scripts/reset_build.sh create mode 100644 cpp/tests/dbc.cpp create mode 100644 cpp/tests/fsm.cpp create mode 100644 js/fsm.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bafeaa5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.*.sw* +.DS_Store +*.sqlite3 +*.sqlite3-wal +*.sqlite3-shm +debug +imgui.ini +coverage/ +.coverage +builddir +subprojects +*.csv +*.exe +*.dll +*~ +[0-9]* diff --git a/cpp/Makefile b/cpp/Makefile new file mode 100644 index 0000000..71748e0 --- /dev/null +++ b/cpp/Makefile @@ -0,0 +1,18 @@ +all: build test + +reset: + powershell -executionpolicy bypass .\scripts\reset_build.ps1 + +build: + meson compile -j 4 -C builddir + +test: build + ./builddir/runtests + +install: build test + powershell "cp ./builddir/subprojects/libgit2-1.8.1/liblibgit2package.dll ." + powershell "cp ./builddir/subprojects/efsw/libefsw.dll ." + powershell "cp builddir/escape_turings_tarpit.exe ." + +clean: + meson compile --clean -C builddir diff --git a/cpp/dbc.cpp b/cpp/dbc.cpp new file mode 100644 index 0000000..c25d32a --- /dev/null +++ b/cpp/dbc.cpp @@ -0,0 +1,40 @@ +#include "dbc.hpp" + +void dbc::log(const string &message) { + fmt::print("{}\n", message); +} + +void dbc::sentinel(const string &message) { + string err = fmt::format("[SENTINEL!] {}\n", message); + throw dbc::SentinelError{err}; +} + +void dbc::pre(const string &message, bool test) { + if(!test) { + string err = fmt::format("[PRE!] {}\n", message); + throw dbc::PreCondError{err}; + } +} + +void dbc::pre(const string &message, std::function tester) { + dbc::pre(message, tester()); +} + +void dbc::post(const string &message, bool test) { + if(!test) { + string err = fmt::format("[POST!] {}\n", message); + throw dbc::PostCondError{err}; + } +} + +void dbc::post(const string &message, std::function tester) { + dbc::post(message, tester()); +} + +void dbc::check(bool test, const string &message) { + if(!test) { + string err = fmt::format("[CHECK!] {}\n", message); + fmt::println("{}", err); + throw dbc::CheckError{err}; + } +} diff --git a/cpp/dbc.hpp b/cpp/dbc.hpp new file mode 100644 index 0000000..919d729 --- /dev/null +++ b/cpp/dbc.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include + +using std::string; + +namespace dbc { + class Error { + public: + const string message; + Error(string m) : message{m} {} + Error(const char *m) : message{m} {} + }; + + class CheckError : public Error {}; + class SentinelError : public Error {}; + class PreCondError : public Error {}; + class PostCondError : public Error {}; + + void log(const string &message); + void sentinel(const string &message); + void pre(const string &message, bool test); + void pre(const string &message, std::function tester); + void post(const string &message, bool test); + void post(const string &message, std::function tester); + void check(bool test, const string &message); +} diff --git a/cpp/fsm.hpp b/cpp/fsm.hpp new file mode 100644 index 0000000..5898ea4 --- /dev/null +++ b/cpp/fsm.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include + +#ifndef FSM_DEBUG +#define FSM_STATE(C, S, E, ...) case C::S: S(E, ##__VA_ARGS__); break +#else +#define FSM_STATE(C, S, E, ...) case C::S: fmt::println(">> " #C " " #S " event={}, state={}", int(E), int(_state)); S(E, ##__VA_ARGS__); fmt::println("<< " #C " state={}", int(_state)); break +#endif + +template +class DeadSimpleFSM { +protected: + // BUG: don't put this in your class because state() won't work + S _state = S::START; + +public: + template + void event(E event, Types... args); + + void state(S next_state) { + _state = next_state; + } + + bool in_state(S state) { + return _state == state; + } +}; diff --git a/cpp/meson.build b/cpp/meson.build new file mode 100644 index 0000000..43a2c9c --- /dev/null +++ b/cpp/meson.build @@ -0,0 +1,14 @@ +project('lcthw-utilities', 'cpp', + default_options: ['cpp_std=c++20']) + +catch2 = dependency('catch2-with-main') +fmt = dependency('fmt') + +runtests = executable('runtests', [ + 'dbc.cpp', + 'tests/fsm.cpp', + 'tests/dbc.cpp', + ], + dependencies: [catch2, fmt]) + +test('tests', runtests) diff --git a/cpp/scripts/reset_build.ps1 b/cpp/scripts/reset_build.ps1 new file mode 100644 index 0000000..03ccae4 --- /dev/null +++ b/cpp/scripts/reset_build.ps1 @@ -0,0 +1,11 @@ +mv .\subprojects\packagecache . +rm -recurse -force .\subprojects\,.\builddir\ +mkdir subprojects +mv .\packagecache .\subprojects\ +cp *.wrap subprojects +mkdir builddir +meson wrap install fmt +meson wrap install catch2 +# $env:CC="clang" +# $env:CXX="clang++" +meson setup --default-library=static --prefer-static builddir diff --git a/cpp/scripts/reset_build.sh b/cpp/scripts/reset_build.sh new file mode 100644 index 0000000..a419b6c --- /dev/null +++ b/cpp/scripts/reset_build.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -e + +mv -f ./subprojects/packagecache . +rm -rf subprojects builddir +mkdir subprojects +mv packagecache ./subprojects/ +mkdir builddir +cp *.wrap subprojects +meson wrap install fmt +meson wrap install catch2 +meson setup builddir diff --git a/cpp/tests/dbc.cpp b/cpp/tests/dbc.cpp new file mode 100644 index 0000000..fa45b86 --- /dev/null +++ b/cpp/tests/dbc.cpp @@ -0,0 +1,39 @@ +#include +#include "dbc.hpp" + +using namespace dbc; + +TEST_CASE("basic feature tests", "[utils]") { + log("Logging a message."); + + try { + sentinel("This shouldn't happen."); + } catch(SentinelError) { + log("Sentinel happened."); + } + + pre("confirm positive cases work", 1 == 1); + pre("confirm positive lambda", [&]{ return 1 == 1;}); + post("confirm positive post", 1 == 1); + post("confirm postitive post with lamdba", [&]{ return 1 == 1;}); + + check(1 == 1, "one equals 1"); + + try { + check(1 == 2, "this should fail"); + } catch(CheckError err) { + log("check fail worked"); + } + + try { + pre("failing pre", 1 == 3); + } catch(PreCondError err) { + log("pre fail worked"); + } + + try { + post("failing post", 1 == 4); + } catch(PostCondError err) { + log("post faile worked"); + } +} diff --git a/cpp/tests/fsm.cpp b/cpp/tests/fsm.cpp new file mode 100644 index 0000000..bd43bf6 --- /dev/null +++ b/cpp/tests/fsm.cpp @@ -0,0 +1,67 @@ +#include +#include +#include +#include "../fsm.hpp" + +using namespace fmt; +using std::string; + +enum class MyState { + START, RUNNING, END +}; + +enum class MyEvent { + STARTED, PUSH, QUIT +}; + +class MyFSM : public DeadSimpleFSM { +public: + void event(MyEvent ev, string data="") { + switch(_state) { + FSM_STATE(MyState, START, ev); + FSM_STATE(MyState, RUNNING, ev, data); + FSM_STATE(MyState, END, ev); + } + } + + void START(MyEvent ev) { + println("<<< START"); + state(MyState::RUNNING); + } + + void RUNNING(MyEvent ev, string &data) { + if(ev == MyEvent::QUIT) { + println("<<< QUITTING {}", data); + state(MyState::END); + } else { + println("<<< RUN: {}", data); + state(MyState::RUNNING); + } + } + + void END(MyEvent ev) { + println("<<< STOP"); + state(MyState::END); + } +}; + +TEST_CASE("confirm fsm works with optional data", "[utils]") { + MyFSM fsm; + + REQUIRE(fsm.in_state(MyState::START)); + + fsm.event(MyEvent::STARTED); + REQUIRE(fsm.in_state(MyState::RUNNING)); + + fsm.event(MyEvent::PUSH); + REQUIRE(fsm.in_state(MyState::RUNNING)); + + fsm.event(MyEvent::PUSH); + REQUIRE(fsm.in_state(MyState::RUNNING)); + + fsm.event(MyEvent::PUSH); + REQUIRE(fsm.in_state(MyState::RUNNING)); + + fsm.event(MyEvent::QUIT, "DONE!"); + REQUIRE(fsm.in_state(MyState::END)); +} diff --git a/js/fsm.js b/js/fsm.js new file mode 100644 index 0000000..e264ba7 --- /dev/null +++ b/js/fsm.js @@ -0,0 +1,221 @@ +/* + A very simple Finite State Machine system. If you're struggling with managing + a mixture of JavaScript API styles (Promise, callback, events) then this will + help get things organized. The best example is in the `client/components/HLSVideo.svelte` file. + + The main advantage an FSM has is it's ability to constrain and organize random events, and it's + ability to fully log what's happening in a stream of random events. The `HLSVideo.svelte` example + is a great demonstration of this because it has to juggle events from two different styles of APIs, + network events, user UI events, and __video__ loading events. Since all of these can come in at + random the FSM will help order them and do the right thing at the right time. + + ### Theory + + One way to look at a Finite State Machine is an ___explicit if-while-loop___. + If you have a `while-loop` that is doing a lot of processing based on + variables, and you're using a lot of `if-statements` or `switch-case` + statements, then the `FSM` simply formalizes exactly __what__ states and when + they can run. + + For example, if you have a mess of nested `if-statements`, and each one might + not cover every possible variable, then you'll have bugs as different + branches of the `if-statements` run or don't run. The `FSM` simply says, "The only + allowed states at this point in time are X, Y, and Z. The only events I expect are + event1, event2, and event3. If I get anything else that's an error." + + It's a different way to think about data processing, but it is very reliable _and_ + easy to debug because you can log every event, state, and all the data while processing + random events. + + ### FSMs in User Interfaces + + Most UIs are simple enough that you can get away with the basic Svelte variables updating + the state. When UIs get more complex--and especially when they trigger network events--you + run into problems with random events requiring different updates to the UI. For example, if + a user clicks on a button that loads a video, but they're already loading a different video, + then you have to deal with the current click, the old video loading state, and the new video + playing state. An FSM would be able to "serialize" the click, old video load, and new video + load and play better than the usual Svelte variables. + + In short, if you're struggling with properly updating a user interface as random events come + in from the user and the network, then switch to an FSM. + + ### Usage + + The `class FSM` takes two things: data and a event transition object. The `data` is only + for logging and debugging the FSM, as it gets printed out with each log message. The core + of the FSM is your event handler, which has functions named after each event: + + ```javascript + class VideoEvents { + async mount(state) { + switch(state) { + case "START": + return "COUNTING_DOWN"; + default: + return "ERROR"; + } + } + } + ``` + + In this snippet the `class VideoEvents` has a `async mount` event that works like this: + + + This `mount()` event takes a single `state` which is just a string for the state the FSM is currently in. + + You then figure out what to do based on this `state`, typically using a `switch/case`. + + Based on what you've done, you return the next state as another string. + + The `FSM` then waits for a new event, and calls that function with this new state. + + To trigger this `mount` event you would then write: + + ```javascript + await fsm.do("mount"); + ``` + + You can pass additional arguments to the `do` function and they will be passed to the + event as additional arguments: + + ```javascript + // if fsm is in "START" state + await fsm.do("mount", 1, 2, 3); + + // then becomes + ev.mount("START", 1, 2, 3); + ``` + + ### Logging Events + + The `FSM` class will already call `log.debug` on many things, but if you want to do your own logging + you can use the `onTransition` callback: + + ```javascript + fsm = new FSM(video_config, new VideoEvents()); + fsm.onTransition(fsm => { + video_config.state = fsm.state + log.info("I'm logging something else too", the_other_thing); + }); + ``` + + This is a slight modification of how the FSM is used in `HLSVideo.svelte`. Remember that the `video_config` is only passed to `FSM()` so that it gets logged while the `FSM` is running. This is _incredibly_ useful when debugging why your `FSM` is doing something. + + In the `onTransition` callback I'm simply updating the `state` in `video_config` and then I added a little `log.info` to show you could log anything else you wanted there. + + ### Internal Transitions + + If I'm remembering my graduate courses right, this is called an "epsilon transition", but it's + basically triggering another event inside the current event, effectively changing state without + waiting for a new event. I could be wrong on the terminology, but you do this by returning an + `Array[state, cb()]` pair. The `FSM` will then set state to `state` and call your callback so + you can do further processing. + + ___BUG___: This could be improved but for now it works. Look at `HLSVideo.svelte:detect_video` for an example of doing this. + + ### State Names + + The first state is "START" by default, but after that you can use any string. I prefer to make + the states UPPERCASE so they aren't confused with events, which are `lowercase()` function + names in the event handler class (`VideoEvents` above). + + */ +import assert from "./assert.js"; +import { log } from "./logging.js"; + +/* + A very simple Finite State Machine class. Nothing fancy, just a state + and some transition callbacks based on the function names found in `this.events`. + */ +export class FSM { + + /* + Sets up this FSM to use the `events` callback as the transitions, and then + "START" as the first state. It does ___not___ call any initial transitions + so you have to do the first call to `FSM.do()`. The `data` isn't used + internally and only passed to logging functions for debugging. + + ### Event Class Signature + + The `events` can be anything, and you construct it so it will have your own data. + The only requirement is that the functions have a `name(state)` signature and that + `state` is going to be a string. It should then return the next state string for + the next event to receive. + + + `data Anything` -- Whatever you want to log while debugging. + + `events Event Object` -- An object/class that can handle event calls sent to it. + */ + constructor(data, events) { + // data is really only for logging/debugging + this.data = data; + this.events = events; + this.state = "START"; + this.on_state = undefined; + } + + /* + A callback that runs whenever an event handler returns a state. The `cb` callback + receives this `FSM` so it can debug it or do more to it. + + ___WARNING___: This is called called even if the state didn't change, which + might confuse people if they think it's only when a "transition" happens. + + + `cb(FSM)` -- Callback after the transition. Receives this `FSM`. + */ + onTransition(cb) { + this.transition_cb = cb; + } + + /* + Mostly used internally to perform the transition then call the `onTransition` callback, + but sometimes you might need to force a new state externally. + + + `next_state string` -- The new state name. + */ + transition(next_state) { + this.state = next_state; + + if(this.transition_cb) { + this.transition_cb(this); + } + } + + /* + All event names available in the `this.events` class. + */ + event_names() { + return Object.getOwnPropertyNames(Object.getPrototypeOf(this.events)).filter(k => k !== "constructor"); + } + + /* + The core of this `FSM` is the `do()` function. It handles finding the next + event by it's name, loading it, and calling it with the given additional + arguments. + + + `event string` -- The __name__ of the target event, not the actual function. It's a string. + + `...args` -- Additional arguments to pass to the event. + + ___return___ `this.state string` -- The current state of this `FSM`. + */ + async do(event, ...args) { + const evhandler = this.events[event]; + assert(evhandler !== undefined, `Invalid event ${event}. Available ones are '${this.event_names()}'.`); + + // NOTE: you have to use .call to pass in the this.events object or else this === undefined in the call + const next_state = await evhandler.call(this.events, this.state, ...args); + assert(next_state, `The event "${event}" returned "${next_state}" but must be a truthy true state.`); + + if(Array.isArray(next_state)) { + assert(next_state.length == 2, `Returning an array only allows 2 elements (state, func) but you returned ${next_state}`); + let [state, func] = next_state; + + log.debug(`FSM ${this.events.constructor.name}: (${event}) = ${this.state} -> ${state} (${args})`, "DATA:", this.data, func ? `THEN ${func.name}()` : undefined); + + this.transition(state); + await func(); + } else { + log.debug(`FSM ${this.events.constructor.name}: (${event}) = ${this.state} -> ${next_state} (${args})`, "DATA:", this.data); + this.transition(next_state); + } + + return this.state; + } +} + +export default FSM;