Skip to content

Repository files navigation

GPTPS — General Purpose Task Processing System

An embeddable, in-process C99 task processor (Control Plane) aimed for modularity, portability, and scalability — the "General Purpose Task Processing System" Link one library, register a task, submit work. GPTPS runs it on a worker pool under declared resource budgets, with retries / timeouts / dead-letter, and gives you the result back — plus an optional live terminal dashboard to watch and steer it. No server, no broker, no mandatory dependency. Runs on Linux, macOS, and Windows, and can even run single-threaded with no libc heap for embedded / bare-metal targets.

Long-running services are supervised (restart-on-exit, stopped cleanly at shutdown), and scale is opt-in and by composition — shard across engines, route across worker processes, or swap the scheduler — never baked into the mechanism-only core.


Contents

Quick start

#include "gptps.h"
#include <stdio.h>
#include <string.h>

/* a task: sum the payload bytes, return the sum */
static gptps_status sum(gptps_ctx *ctx, void *ud) {
    size_t n, i; const unsigned char *p = gptps_payload(ctx, &n);
    unsigned long s = 0; (void)ud;
    for (i = 0; i < n; ++i) s += p[i];
    return gptps_result_set(ctx, &s, sizeof s);
}

static void on_event(const gptps_event *ev, void *ud) {
    (void)ud;   /* ev->result may be NULL: a task need not set one */
    if (ev->kind == GPTPS_EV_FINISHED && ev->result_len == sizeof(unsigned long)) {
        unsigned long s;
        memcpy(&s, ev->result, sizeof s);   /* copy: valid only for this call */
        printf("task %s done: %lu\n", ev->task_name, s);
    }
}

int main(void) {
    gptps *e;
    gptps_task_def d = {0};
    gptps_handle h;

    gptps_open(NULL, &e);                       /* auto-tunes to the machine  */
    gptps_set_event_cb(e, on_event, NULL);

    d.struct_size = sizeof d; d.name = "sum"; d.run = sum; d.exec = GPTPS_EXEC_INPROC;
    d.default_cost.struct_size = sizeof d.default_cost; d.default_cost.mem_bytes = 4096;
    d.default_policy.struct_size = sizeof d.default_policy; d.default_policy.timeout_seconds = 5;
    gptps_register_task(e, &d);

    gptps_submit(e, "sum", "hello", 5, &h);     /* runs on the pool           */
    gptps_shutdown(e);                          /* drains (bounded), returns  */
    return 0;
}

Getting started

Build → run → embed — the whole path from zero to your own program. You need a C99 compiler (gcc/clang) and CMake ≥ 3.13 — nothing else on Linux/macOS (Windows uses MSVC or mingw-w64; the build auto-selects the Win32 backend).

1. Build.

cmake -S . -B build          # configure (once)
cmake --build build -j       # libgptps.a + the add-on libraries + examples + the test suite

Scaling is never in the core — shard across engines, route across worker processes, or swap the scheduler, all as add-ons you link (see Scaling). Build knobs, all optional:

Knob Default What it does
-DGPTPS_ADDONS= all which add-ons to build: all, none, or a ;-list. A typo is a hard error, not an empty selection
-DGPTPS_BUILD_ADDONS= ON at top level build the add-on libraries at all
-DGPTPS_BUILD_TESTS= / _EXAMPLES= ON at top level the suite and the examples
-DGPTPS_HAL_FAST=ON OFF platform-optimized HAL (adaptive mutexes on glibc); OFF keeps the portable pthread path

All four default OFF when GPTPS is add_subdirectory'd or FetchContent'd, so a consumer gets libgptps.a and nothing else — no CTest targets, no add-on builds.

Building on Windows with mingw-w64. The core is pure C99 and the Win32 backend (hal_win.c + exec_win.c) is selected automatically; mingw-w64 gcc is a first-class, CI-tested toolchain (native PE binaries, full <windows.h>, __atomic support). Using MSYS2 with the UCRT64 environment:

# in the MSYS2 UCRT64 shell
pacman -S --needed mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-cmake mingw-w64-ucrt-x86_64-ninja
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug   # gcc + ninja is auto-detected
cmake --build build
ctest --test-dir build --output-on-failure              # full suite (should be 100%)

The single-file amalgamation builds the same way (gnu99 for the <windows.h> GNU extensions): sh tools/amalgamate.sh && cc -std=gnu99 -I amalgamation amalgamation/gptps.c myapp.c -o myapp.exe — no -lpthread/-ldl needed (the Win32 HAL uses the OS threads and LoadLibrary directly). The native MSVC (cl.exe) toolchain is also supported — open the folder in Visual Studio or cmake -S . -B build with the default VS generator.

2. See it run — the live dashboard. Run it in a real terminal (it needs an interactive TTY; with no TTY it just prints a line and exits, which is how CI runs it headless):

./build/example_dashboard    # keys: w/f submit · t tasks · l dead-letter · s settings · m KPI · p pause · ? help · q quit

See Live terminal dashboard for what it shows and how to drive it.

3. Run the other examples + the tests.

./build/gptps_demo           # in-process tasks + events (prints results)
./build/example_config       # tune from a TOML file
./build/example_embedded     # no threads + a static memory pool (manual mode)
ctest --test-dir build --output-on-failure   # full suite (should be 100%)

More in examples/: external_program (run any binary as a task) and wasm_program (run a .wasm module via a wasm runtime CLI — see WebAssembly below).

4. Embed it in your own program. Generate the amalgamation — two files for the core, plus a self-contained .c/.h pair for each add-on you ask for, and no build system — drop the Quick start program into myapp.c, and link:

sh tools/amalgamate.sh out   # writes out/gptps.c and out/gptps.h
cc -std=c99 myapp.c out/gptps.c -Iout -lpthread -ldl   # Linux
cc -std=c99 myapp.c out/gptps.c -Iout -lpthread        # macOS (dlopen is in libSystem)
./myapp

To embed the dashboard too, ask for it when you amalgamate — no repo checkout needed:

sh tools/amalgamate.sh out --addons tui     # also writes out/gptps_tui.c + .h
sh tools/amalgamate.sh --list               # everything available
cc -std=c99 myapp.c out/gptps.c out/gptps_tui.c -Iout -lpthread -ldl

(examples/demo.c is a fuller starting template.)

Install / consume (optional). cmake --install build --prefix <dir> installs the header, the static library, a CMake package config and a pkg-config file — plus each add-on as its own library (gptps::pool, …) with its header under include/gptps/ and its own .pc, and the gptps_conformance tool in bin/. Downstream projects use either

find_package(gptps 1.0 REQUIRED COMPONENTS durable_queue pool)   # COMPONENTS is optional
target_link_libraries(myapp PRIVATE gptps::durable_queue gptps::pool)

or pkg-config --cflags --libs gptps (add --static if you link statically — the core calls dlopen, so it needs -ldl). Pick which add-ons get built with -DGPTPS_ADDONS="pool;durable_queue" (default all, or none). Full detail in docs/PACKAGING.md.

API at a glance

Call Purpose
gptps_open(path, &e) / gptps_open_ex(cfg, &e) create an engine (auto-tunes workers + memory budget)
gptps_register_task(e, &def) register a task type (in-process fn or external program)
gptps_set_task_priority(e, name, prio) set a task type's scheduling priority (higher runs first)
gptps_define_resource(e, name, budget) · gptps_set_task_resource_cost(e, name, res, n) declare a named admission budget (GPU/IO/seats/quota) and a task's per-item cost against it
gptps_submit(e, name, payload, len, &handle) / gptps_submit_ex(…, &opts, …) enqueue work (_ex: per-submit priority / policy / deadline)
gptps_cancel(e, handle) cancel one queued / in-flight item
gptps_task_count/get_info/exists(e, …) enumerate / introspect registered task types
gptps_set_task_enabled(e, name, on) pause / resume a task type (reversible)
gptps_clone_task(e, src, dst) duplicate a task type under a new name
gptps_unregister_task(e, name, flags) remove a task type (reject-if-busy / drain / cancel)
gptps_define_global/define_task_setting(e, …) declare a custom typed global / per-task setting
gptps_task_setting_int/str(ctx, key, …) read this task's per-task setting from inside run()
gptps_set_event_cb(e, cb, ud) observe lifecycle events (results arrive on FINISHED)
gptps_register_constraint(e, fn, ud) gate admission (rate limit, quota, time window)
gptps_set_scheduler(e, fn, ud) swap the admission ordering (deadline-first, fair-share, …) over the fixed mechanism
gptps_register_observer(e, cb, ud) extra event sink (e.g. analytics)
gptps_dead_letter_count(e) / gptps_dead_letter_drain(e, cb, ud) inspect / reprocess retained failures
gptps_settings_get/set(e, key, …) · gptps_settings_count/get_info(e, …) read / change / introspect any setting at runtime
gptps_settings_save/reload(e, path) persist settings to / from a TOML file
gptps_register_setting(e, &def) add a custom setting (also a host-table routine for add-ons)
gptps_load_addon(e, path) load a binary plug-in over the stable ABI
gptps_addon_count/get_info(e, …) list what is loaded: name, namespace, path, enabled
gptps_addon_disable(e, ns_or_name) ask a plug-in to stop participating (it stays mapped — there is no unload)
gptps_set_scheduler_ex(e, fn, ud, owner, flags) · gptps_scheduler_owner(e) take the ordering seam only if free, and see who holds it
gptps_step(e, &ran) MANUAL mode: pump the engine on the calling thread (no worker threads)
gptps_set_allocator(&a) redirect all core allocation to a custom malloc/realloc/free
gptps_shutdown(e) drain in-flight + queued work (bounded by limits.shutdown_grace_ms), then free

Inside a task you get a gptps_ctx *: gptps_payload(), gptps_is_cancelled() (poll it for cooperative timeout), gptps_result_set() / gptps_result_set_nocopy().

Common tasks

Short recipes for the things you'll actually do — each links to the full details below.

1. Run work and get the result back. Register a task with a run function, set an event callback, and read the result on the FINISHED event — that's the Quick start above.

2. Give a task a timeout, retries, and a failure policy. Set the policy on the task def before registering it:

d.default_policy.timeout_seconds       = 5;
d.default_policy.max_retries           = 3;
d.default_policy.retry_backoff_seconds = 1;
d.default_policy.on_failure            = GPTPS_ON_FAILURE_DEAD_LETTER;  /* or _DROP / _REQUEUE */

In-process tasks must poll gptps_is_cancelled() to honor the timeout. Tasks that exhaust their retries are kept in the dead-letter list — reprocess them with gptps_dead_letter_drain().

3. Cap how much runs at once (the budget). Open with explicit limits (or a config file, so you can re-tune without recompiling):

gptps_config cfg = { .struct_size = sizeof cfg };
cfg.limits.struct_size = sizeof cfg.limits;
cfg.limits.max_concurrent_tasks = 4;          /* 0 => auto-detect cores      */
cfg.limits.max_memory_bytes     = 512u << 20; /* admission budget (declared) */
gptps_open_ex(&cfg, &e);

4. Run heavy or crash-prone work isolated and killable. Set the executor kind on the task: d.exec = GPTPS_EXEC_OOP (forked child, memory-capped, hard-killed on timeout) or GPTPS_EXEC_PROGRAM to run any external binary — see Executor kinds.

5. Watch it live. Install the dashboard add-on and run your program in a terminal — see Live terminal dashboard.

6. Change a setting at runtime and persist it.

gptps_settings_set(e, "tasks.resize.timeout_seconds", "60");  /* validated + applied live */
gptps_settings_save(e, "gptps.toml");                          /* atomic round-trip       */

See Settings.

7. Run with no background threads (embedded / bare-metal). Open with cfg.mode = GPTPS_RUN_MANUAL and drive it yourself with gptps_step() — see Embedded / single-threaded mode.

8. Run a supervised background service. Flag the task GPTPS_TASK_SERVICE: its run() loops until told to stop and is restarted if it exits — see Long-running services.

9. Scale beyond one engine. Shard across engines behind a router, ship work to worker processes, or swap the scheduler — all opt-in, all on the public API, no core change — see Scaling.

Configuration file (optional)

gptps_open("gptps.toml", &e) tunes the engine from a config file — no recompile to re-tune for a new machine or change a task's failure policy. Pass NULL to skip it and auto-tune. A subset of TOML is supported (tables, int/float/bool/"string" and single-line string arrays, # comments):

# top level: binary plug-ins to dlopen at open, by explicit path. There is no search
# path, deliberately - see docs/SECURITY.md: whoever can write this file can run code
# in your process, so GPTPS makes you name the file rather than scanning a directory.
# A plug-in that declares a namespace puts its own settings under it (below).
addons = ["./libmytasks.so", "/usr/local/lib/gptps/gpu_quota_plugin.so"]

[tasks.render]
"gpuq.units" = 2               # a namespaced plug-in's per-task knob

[limits]
max_concurrent_tasks = 8       # 0 / omitted => detected cores
max_memory_gb        = 4.0     # or max_memory_bytes = 4294967296

[scheduler]
reserve_after_skips = 8        # starvation guard (0 => strict priority, no backfill)

[task_defaults]                # applied to every task...
max_retries = 2
on_failure  = "dead_letter"    # dead_letter | drop | requeue
priority    = 0                # higher => admitted first

[tasks.resize]                 # ...then overridden per task name
timeout_seconds = 60
max_retries     = 1
on_failure      = "drop"
mem_bytes       = 268435456
priority        = 10

Precedence for a task's policy: compiled-in def defaults → [task_defaults][tasks.<name>] (most specific wins). Explicit [limits] values win over auto-tune. See gptps.example.toml.

Settings (runtime, introspectable, persistable)

The same knobs are also a live, typed settings registry — one API over core, per-task, and add-on settings (dotted keys like scheduler.reserve_after_skips, tasks.resize.timeout_seconds, tui.kpi, gpu_quota.total_units):

char v[256];
gptps_settings_get(e, "scheduler.reserve_after_skips", v, sizeof v);  /* read current */
gptps_settings_set(e, "tasks.resize.timeout_seconds", "60");          /* validated + applied live */
gptps_settings_save(e, "gptps.toml");                                 /* persist (atomic) */
size_t n = gptps_settings_count(e);                                   /* enumerate for a UI */
  • Typed + validated: set() parses and range/enum-checks before applying (so a bad on_failure or out-of-range value is rejected with GPTPS_E_CONFIG, not silently dropped).
  • Hot vs restart: most settings apply immediately; a few (e.g. the worker-pool size) are flagged effective-on-restart. gptps_settings_get_info exposes type, default, range, and the hot flag for building a UI.
  • Round-trip: gptps_settings_save regenerates a grouped TOML file (atomically; comments not preserved); gptps_settings_reload re-applies it.
  • Extensible: add-ons register their own settings (via gptps_register_setting or the host-table routine), so they show up in the registry, TOML, and editor uniformly.
  • Generic, no glue: declare your own typed knobs at runtime — gptps_define_global(e, "app.max_upload_mb", GPTPS_SETTING_UINT, "10", "0..4096", 0) for a global, or gptps_define_task_setting(e, "quality", GPTPS_SETTING_UINT, "75", "0..100", 0) to materialize tasks.<name>.quality on every task. A run() reads its own value with gptps_task_setting_int(ctx, "quality", &q). The engine stores and validates them; both round-trip through TOML and appear in the editor.
  • Editor: the tui add-on includes a live Settings pane (s) to browse/edit/save.

Manage tasks at runtime (control plane)

The registry is itself live and mutable — enumerate, pause, clone, and remove task types without recompiling or restarting:

size_t n = gptps_task_count(e);                       /* enumerate for a UI ... */
gptps_task_info ti = { .struct_size = sizeof ti };
gptps_task_get_info(e, 0, &ti);                       /* name, exec, prio, queued/running/dead */

gptps_set_task_enabled(e, "resize", 0);               /* pause: reject new submits, reversibly */
gptps_clone_task(e, "resize", "resize_hi");           /* duplicate, then retune the copy */
gptps_unregister_task(e, "resize", GPTPS_REMOVE_DRAIN);   /* finish in-flight work, then remove */
  • Removal policy (the flags): GPTPS_REMOVE_REJECT_IF_BUSY (default — refuse with GPTPS_E_BUSY while work is outstanding), GPTPS_REMOVE_DRAIN (stop new submits, let queued + in-flight finish, then free), or GPTPS_REMOVE_CANCEL (drop queued, cancel in-flight, then free). A removed name is free to re-register; its tasks.<name>.* settings are torn down; retained dead-letter items survive and stay drainable.
  • Behavior still arrives in code. These calls own configuration and lifecycle. New in-process logic comes from a run fn (code or an add-on); a GPTPS_EXEC_PROGRAM task, though, is fully creatable at runtime (and from the TUI) since its behavior is an external argv. Add-ons get the same control plane via the host-table ABI.

Long-running services

Some work isn't a one-shot task — it's a resident loop (a poller, a listener, a metrics collector) that should run until the process stops and come back if it crashes. Flag the task type GPTPS_TASK_SERVICE and submit it like any task; the engine supervises it:

static gptps_status poller(gptps_ctx *ctx, void *ud) {
    (void)ud;
    while (!gptps_is_cancelled(ctx)) { poll_once(); }   /* loop until told to stop */
    return GPTPS_OK;
}

gptps_task_def d = { .struct_size = sizeof d, .name = "metrics",
                     .run = poller, .exec = GPTPS_EXEC_INPROC,
                     .flags = GPTPS_TASK_SERVICE };
d.default_cost.struct_size = sizeof d.default_cost;
d.default_policy.struct_size = sizeof d.default_policy;
d.default_policy.retry_backoff_seconds = 2;             /* restart delay after a crash */
gptps_register_task(e, &d);

gptps_handle h;
gptps_submit(e, "metrics", NULL, 0, &h);   /* start one instance (submit N for a pool) */
  • Supervised restart. When the loop exits, the engine restarts the instance after retry_backoff_seconds — crash-restart supervision, for free. The failure policy is normalized (restart-on-exit, no timeout) so a config or settings edit can't un-service it.
  • Stop it. The submit handle stays valid across restarts, so gptps_cancel(e, h) stops that one instance for good; gptps_unregister_task stops the whole type; and gptps_shutdown stops running services (raising their cooperative cancel flag) so a resident service never hangs teardown. Non-service in-flight work still drains gracefully.
  • Restart-always vs. on-failure. By default a service is "always up" (even a clean GPTPS_OK return restarts it). Add GPTPS_TASK_RETIRE_ON_OK for the Restart=on-failure semantic: a clean return retires the instance; only a failure restarts it.
  • v1 services are GPTPS_EXEC_INPROC, GPTPS_RUN_THREADED, and have no timeout.

Scaling (opt-in, by composition)

The core is a single-writer engine — one lock, one dispatcher — which is simple, correct, and the per-node throughput ceiling. Rather than complicate the core, GPTPS scales by composing modules on top of it: you pay for scale only when you reach for it, and the default stays small and portable. None of the below touches the mechanism-only core.

Scale up — shard across engines (gptps_pool). Run N independent engines (each its own lock + dispatcher + worker pool) behind a router:

gptps_pool *pool = gptps_pool_open(4, &cfg);          /* 4 independent shards */
gptps_pool_register_task(pool, &def);                 /* same task type on every shard */
gptps_pool_submit(pool, "resize", buf, len, &h);      /* round-robin */
gptps_pool_submit_keyed(pool, tenant_id, "resize", buf, len, &h);  /* per-key affinity */
gptps_pool_cancel(pool, h);
gptps_pool_close(pool);

The router's only shared state is a round-robin cursor (keyed routing is lock-free); each shard is a full engine. examples/bench_pool measures it: on a 32-core box at 400k items, aggregate tiny-task throughput rose from ~15k/s at 1 shard to ~290k/s at 8 (≈19×) — the single-writer ceiling, then composition breaking past it. Use a large item count if you rerun it: the CI-quick default of 40k finishes in ~20ms and is noise-dominated.

Scale out — worker processes (gptps_xport). Fork N persistent worker processes, each running its own engine, and ship each request to one over a multiplexed IPC link. Work runs in a separate address space (crash-isolated), and the worker's pool, budgets, retries, timeouts, dead-letter and seams all apply there — scaling out keeps everything the engine does. POSIX only, like EXEC_OOP (Windows has no fork):

gptps_xport_config xc = { .struct_size = sizeof xc, .nworkers = 4,
                          .engine_cfg = &per_worker_limits, .tasks = table, .ntasks = n };
gptps_xport *xp = gptps_xport_open_ex(&xc);                 /* 4 workers, an engine in each */
gptps_xport_submit(xp, "resize", buf, len, &res, &rlen, &task_status);        /* blocks */
gptps_xport_submit_async(xp, "resize", buf, len, on_reply, ud, NULL);         /* callback */
gptps_xport_close(xp);                                      /* graceful drain */

gptps_xport_open(n, handler, ud) still gives you the bare-handler transport (no engine in the worker) for plain crash-isolated RPC. Watch either with gptps_stats: install it from the child_init hook and each worker keeps its own counters.

Going cross-machine is not a socket swap, and addons/gptps_remote says why. It is the wire codec — versioned header, fixed big-endian byte order, a request id, stable status codes, a 1 MiB cap — written down because xport's framing is native-endian (silent corruption between a little- and a big-endian host), its gptps_status values are positional (so they would become wire-visible), and its 256 MiB cap is a one-packet DoS from a peer you did not fork. There is deliberately no transport yet and the codec is not published as a release artifact: shipping it is what would create a permanent version-1 peer. Build a transport on it when you have a reason to, with docs/SECURITY.md read first — the task field of a request is a dispatch key chosen by the peer.

Swap the scheduling discipline (gptps_set_scheduler). The admission mechanism (skip-to-fit, budget, starvation guard) is fixed; the ordering is a hook. Return a score per item and the dispatcher admits the highest that fits — so deadline-first, per-tenant fair-share, cost-aware, or aging disciplines compose without a core fork (default is priority/FIFO):

static int64_t earliest_deadline_first(const gptps_sched_input *in, void *ud) {
    (void)ud; return -(int64_t)in->enqueue_ms;   /* older enqueue = higher score */
}
gptps_set_scheduler(e, earliest_deadline_first, NULL);

Faster locks (-DGPTPS_HAL_FAST). An opt-in build knob swaps in adaptive (spin-then-block) mutexes on glibc — a latency knob for the engine's contended critical sections; OFF by default keeps the portable pthread HAL. The HAL is a module boundary, so a downstream can drop in its own platform-optimized backend.

Balance batches of mixed-size work (gptps_balance). Round-robin is the wrong router for a heavy-tailed batch: one shard draws three long items while its neighbours idle, and nothing can move them once they are inside an engine's queue. gptps_balance keeps the queue in the router and hands each shard work only as it frees up (join-shortest-queue with late binding — work-stealing in effect), adapting to any task size without being told sizes. Measured on 4 shards: 27–31% shorter makespan for 200–1,000-item batches, and no difference on a 20,000-item stream, where round-robin is already balanced by the law of large numbers. examples/bench_balance.c reproduces both.

gptps_pool, gptps_balance and gptps_xport are add-ons (addons/) built entirely on the public API — the proof that scaling here needs no core change.

Live terminal dashboard

GPTPS ships an optional, dependency-free terminal dashboard (addons/gptps_tui.c) — link it, point it at your engine, and watch tasks flow in real time. Pure ANSI/VT (no ncurses), auto-enabled on a TTY, with the Windows console put into VT mode automatically.

GPTPS · live demo   up 0.1s   28.6 done/s
queued 18  started 8  in-flight 4  [##########################] peak 4
finished 4  failed 0  retried 0  dead 0
kpi:full mode:realtime refresh:250ms

TASKS
  label              run    ok  fail  dead   ok%   avg ms  key
  Resize               8     4     0     0  100%     81.0  [r]
  Thumbnail            0     0     0     0    --       --  [t]

RECENT
     0.1 FINISHED resize         #4
     0.1 FINISHED resize         #1
     0.1 STARTED  resize         #7
     0.1 STARTED  resize         #8

keys: [r] Resize  [t] Thumbnail   ·  ? help  s settings  t tasks  l dead-letter  m kpi  p pause  q quit
  • Live metrics: throughput, an in-flight gauge, cumulative counts, and a per-task table (runs / ok / fail / dead / success-rate / average latency).
  • Interactive: hotkeys submit tasks; k/j scroll the event log; m dials the dashboard's own CPU/RAM cost (minimal/normal/full) live; p pauses; s opens the live settings editor; ? shows a help overlay of every key.
  • Task control plane: t opens a task manager — list every type with live queued/running/dead counts, inspect one to edit its settings inline, pause/resume (a), clone (c), create a GPTPS_EXEC_PROGRAM task from a typed name + argv (n), or delete with a confirm dialog (d) that shows the outstanding count and offers drain or cancel-force. l opens a dead-letter view to bulk re-submit or discard retained failures.
  • Friendly: adapts to the terminal size, redraws flicker-free, confirms actions with a toast, and on a real terminal adds a framed title bar, a Unicode block gauge, and color (with an ASCII fallback shown above). Built on a pure render-to-string core, so it is fully testable headlessly.

Run it live in a terminal: ./build/example_dashboard (source: examples/dashboard.c).

Executor kinds (per task, via def.exec)

Kind Runs as Enforcement If the task crashes Platforms
GPTPS_EXEC_INPROC your C function, in-process cooperative cancel (advisory) kills your whole process — the engine, the dispatcher, and every other in-flight item all
GPTPS_EXEC_OOP the same C function in a forked child memory cap + hard-kill on timeout or cancel kills only the child; reported as GPTPS_E_TASK (E_NOMEM if it blew the memory cap) POSIX only (needs fork)
GPTPS_EXEC_PROGRAM an external program (def.argv); payload→stdin, stdout→result memory cap + hard-kill on timeout or cancel kills only the child; reported as GPTPS_E_TASK (E_NOMEM if it blew the memory cap) all (POSIX fork+exec; Windows CreateProcess + Job Object)

Read the blast-radius column before you pick. GPTPS_EXEC_INPROC is 0, so it is what a memset-zeroed gptps_task_def gives you — the fast path, and the right default for code you trust. For anything that can segfault, leak, or run away, OOP/PROGRAM buy you a real fault boundary: a memory cap the OS enforces, a guaranteed kill, and a child_setup hook to drop privileges or install seccomp before the work starts.

The out-of-process executors are fully cancellable: gptps_cancel (and gptps_unregister_task(…, CANCEL)) hard-kill a running child in bounded time — even one with no timeout, which used to run unstoppably. The POSIX program executor pumps the payload to stdin and reads stdout concurrently (a single poll loop), so a large payload through a streaming child never deadlocks.

On POSIX the out-of-process executors enforce the per-task memory cap accurately with cgroup v2 (memory.max, exceeding it ⇒ GPTPS_E_NOMEM) when GPTPS_CGROUP_PARENT points at a memory-delegated cgroup (e.g. a systemd Delegate=yes scope), else a coarse RLIMIT_AS cap; on Windows the program executor uses a Job Object (memory limit + kill-on-close). Either way it's real, killable enforcement the in-process path can't give.

WebAssembly. A .wasm module is portable, sandboxed task code — and a wasm runtime CLI is just a program, so you can run one through GPTPS_EXEC_PROGRAM with no new code: def.argv = {"wasmtime", "run", "module.wasm", NULL} (argv[0] is PATH-resolved). The payload flows to the module's stdin and its stdout comes back as the result, under the usual budget / timeout / retry. See examples/wasm_program.c. For a tighter, in-process binding, the wasm_exec add-on takes a pluggable runtime hook instead.

Embedded and single-threaded mode

GPTPS runs in two execution modes, chosen at gptps_open_ex via cfg.mode:

  • GPTPS_RUN_THREADED (default) — a dispatcher + worker pool; the engine runs itself. The headline hosted path.
  • GPTPS_RUN_MANUALno threads. You drive the engine cooperatively on your own thread with gptps_step(e, &ran): one call completes finished work, promotes backoff-ready retries, admits within budget, and runs the admitted tasks to completion inline. Drain with while (gptps_step(e,&n)==GPTPS_OK && n);, or call it from your existing main loop / RTOS tick. Same admission, priority, retry, and dead-letter semantics as threaded mode (one shared engine_pass).
gptps_config cfg = { .struct_size = sizeof cfg, .mode = GPTPS_RUN_MANUAL };
cfg.limits.struct_size = sizeof cfg.limits;
gptps_open_ex(&cfg, &e);
/* ... register + submit ... */
size_t n; do { gptps_step(e, &n); } while (n);   /* runs on THIS thread */

Pair it with gptps_set_allocator() to take GPTPS off the libc heap entirely — point it at a static pool (SQLite-style; covers all core allocation). Together, MANUAL mode + a custom allocator are the bare-metal shape: zero threads, fixed RAM. The only thing a real MCU/RTOS port adds is a HAL backend (hal_<target>.c) for the mutex/clock/flag primitives — MANUAL mode never calls gptps_thread_start or cond_wait. Worked end-to-end in examples/embedded.c.

Caveat: a MANUAL task runs to completion on your thread, so a wall-clock timeout can't preempt it — cooperative tasks should poll gptps_is_cancelled() / gptps_deadline_ms(). For hard kill/timeout, use an out-of-process executor.

Resource budgets, failures, add-ons

  • Admission: each task type declares a rough cost (mem_bytes; gpu_units and est_duration_ms were removed in ABI 2.0, and named budgets replaced them — see gptps_define_resource). The core starts a task only if it fits the live budget — not an all-or-nothing cap. max_concurrent_tasks=1 is strictly sequential; >1 is concurrent.
  • Scheduling: the dispatcher admits the highest-priority pending task that fits the live budget. A too-large task does not head-of-line-block — smaller work behind it backfills (skip-to-fit), while a bounded reservation keeps the skipped task from starving (it's admitted once enough budget frees). Set priority via gptps_set_task_priority() or config; tune the reservation with [scheduler] reserve_after_skips.
  • Failure policy (per task, overridable): timeout_seconds, max_retries, retry_backoff_seconds, on_failure = dead_letter (default) / drop / requeue.
  • Dead letter: tasks that exhaust retries (or that a constraint denies) are retained. gptps_dead_letter_drain() hands each back to a callback — with the engine lock released, so the callback may re-submit to retry — and empties the list (gptps_shutdown() frees the rest). The list is capped at limits.max_dead_letters (default 1024, oldest evicted, 0 = unbounded); stats.dead_letters_evicted counts anything the cap dropped, so a host that never drains gets bounded memory instead of silent growth.
  • Durability (optional): addons/gptps_durable_queue.c journals submissions to disk (fsync before enqueue) and replays survivors after a crash — at-least-once delivery. See addons/README.md.
  • Runtime task management: enumerate, pause/resume, clone, and unregister task types live (gptps_task_*, gptps_unregister_task with reject-if-busy / drain / cancel) — the control plane behind the dashboard's task manager. See Manage tasks at runtime.
  • Live dashboard (optional): a portable real-time terminal UI with live metrics, a per-task table, a scrollable event log, a settings editor, a task manager + dead-letter panes, and hotkeys — see Live terminal dashboard above.
  • Add-ons keep the core small. Task logic, transports, GPU quotas, rate limits, priority, time-of-day windows, analytics sinks — all live in add-ons. See below.

Add-ons and plug-ins

Everything domain-specific lives outside the core. One question decides how you build it:

Does the host have to call into your module? Yes → a compiled-in module. It needs a header, so it needs your build. Links core symbols directly; can own threads, own a whole engine, ship platform code. No → a binary plug-in. It needs only the versioned host table, so it needs only your process: named in a config file, dlopen'd at runtime, configured by an operator through settings. It links no core symbols — which is what lets one .so work against a static, shared or amalgamated host alike.

A module is not the lesser thing: most of the bundled add-ons are modules by necessity, because their whole point is an API you call (gptps_dq_submit(), gptps_orch_after(), gptps_tui_run()). gptps_gpu_quota ships in both tiers, and the diff between addons/gptps_gpu_quota.c and addons/gptps_gpu_quota_plugin.c is the shortest honest description of the difference.

A plug-in declares a namespace and gets a guarantee. With GPTPS_ADDON_INIT_NS(…, "myns", …) the loader claims that token — a second plug-in wanting it is refused — and in exchange everything you register during setup() must be "myns."-prefixed. That is what stops two unrelated plug-ins colliding on a task name, a settings key or a resource budget. An operator can list what is loaded (gptps_addon_count / gptps_addon_get_info) and turn one off without unloading it (gptps_addon_disable).

Prove it before you ship it:

cmake -S templates/plugin -B build -DCMAKE_PREFIX_PATH=$(pkg-config --variable=prefix gptps)
cmake --build build
gptps_conformance build/myplugin.so        # installed to bin/

The interesting check is the degradation ladder: the harness synthesises the host table as each released core actually had it and runs your plug-in against every rung, so a routine you called without a struct_size guard is reported by name, with the guard to add — rather than segfaulting in a user's process a year from now. The engine cannot test this itself, because it always hands a plug-in the full table.

Start from templates/plugin/ and read docs/PLUGINS.md for the full contract — the tier decision, the struct_size guard, threading per seam, seam ownership, and the security posture.

Getting one

Each add-on is an installable library, so you can take a subset without cloning (docs/PACKAGING.md):

find_package(gptps 1.0 REQUIRED COMPONENTS durable_queue pool)
target_link_libraries(myapp PRIVATE gptps::durable_queue gptps::pool)
cc -std=c99 myapp.c $(pkg-config --cflags --libs --static gptps-durable_queue gptps-pool) -o myapp

sh tools/amalgamate.sh out --addons durable_queue,pool   # no build system, no clone
cc -std=c99 myapp.c out/gptps.c out/gptps_durable_queue.c out/gptps_pool.c -Iout -lpthread -ldl

gptps.c is byte-identical whichever add-ons you select, so its SHA256 stays pinnable. See addons/README.md for what each one does.

Project layout

gptps/
├── include/
│   ├── gptps.h          ← the public API (start here)
│   └── gptps_hal.h      ← internal platform-abstraction interface
├── src/                 ← the library
│   ├── engine.c         core: dispatcher, queue, admission, scheduler, failure engine, loader
│   ├── settings.c       typed settings registry;  alloc.c  custom-allocator seam
│   ├── config.c         config model + hardware auto-tune;  config_toml.c  TOML parser
│   ├── hal_posix.c      POSIX backend (threads, clock, dynload, detection);  hal_win.c  Win32 backend
│   └── exec_oop_posix.c out-of-process + external-program executors;  exec_win.c  Win32 executor
├── addons/              ← optional modules, one installable library each (gptps::pool, …)
│   ├── gptps_pool.c     scale-UP: N engine shards + a router;  gptps_xport.c  scale-OUT: worker processes, an engine in each
│   ├── gptps_balance.c  late-binding load balancer above pool (join-shortest-queue; any task size)
│   ├── gptps_stats.c    counters / gauges / latency on the observer seam (per engine, per task, mergeable)
│   ├── gptps_await.c    blocking wait(handle);  gptps_orch.c  run-after / fan-in dependencies
│   ├── gptps_durable_queue.c  crash-durable journal;  gptps_gpu_quota.c  named-resource quota
│   ├── gptps_wasm_exec.c  module-as-task;  gptps_tui.c  live terminal dashboard
│   ├── gptps_remote.c   cross-host wire CODEC (built + tested; deliberately not distributed)
│   ├── gptps_gpu_quota_plugin.c  the same quota policy as a dlopen BINARY plug-in
│   └── CMakeLists.txt   one library + header + .pc per add-on
├── templates/plugin/    ← a complete, copyable binary plug-in (built out-of-tree by CI)
├── examples/            ← runnable examples (demo, config_file, task_control, external_program, dashboard,
│                          embedded, wasm_program, bench_pool)
├── gptps.example.toml   ← annotated sample config file
├── docs/
│   ├── ARCHITECTURE.md  how it works inside
│   ├── PLUGINS.md       writing an add-on: which tier, the ABI contract, proving it
│   ├── PACKAGING.md     getting GPTPS + a subset of its add-ons
│   └── SECURITY.md      trust boundary and non-guarantees
├── tests/               ← CTest suite (51 tests) + consumer/ (an out-of-tree find_package consumer)
├── tools/
│   ├── amalgamate.sh    single-file gptps.c + gptps.h, and one .c/.h pair per add-on
│   ├── gptps_conformance.c  prove a binary plug-in before you ship it (installs to bin/)
│   └── check_addon_coverage.sh  every add-on is built, obtainable and documented
├── CMakeLists.txt
└── .github/workflows/ci.yml   Linux/macOS/Windows (mingw + MSVC), i386 + s390x + freestanding,
                               ASan/UBSan + TSan, and a `package` job that installs and consumes

Status

Working today (tested + ThreadSanitizer-clean): the engine, all three executors, result delivery, retries/timeout/dead-letter + dead-letter drain, priority scheduling with skip-to-fit + reservation, accurate cgroup v2 memory enforcement (with RLIMIT_AS fallback), the add-on loader + ABI, constraints + observers, TOML config-file loading (limits + scheduler + per-task overrides + add-on auto-load), the unified settings registry (typed get/set + validation + round-trip persistence + add-on-extensible), single-threaded MANUAL mode (gptps_step) and a custom-allocator hook for embedded / bare-metal hosts, supervised long-running services (GPTPS_TASK_SERVICE), the pluggable scheduler seam (gptps_set_scheduler, with declared ownership so two add-ons cannot silently fight over it), scale-up (gptps_pool shards) and scale-out (gptps_xport worker processes, each with its own engine), the optional platform-optimized HAL (-DGPTPS_HAL_FAST), the live terminal dashboard (with the settings editor), the crash-durable queue, a blocking wait(handle), run-after/fan-in dependencies, GPU-quota and WASM-executor add-ons, observer-seam stats (gptps_stats: totals, gauges, latency, per task, mergeable across shards), the examples + benchmark, CMake + CI + single-file amalgamation.

Binary plug-ins work as of ABI 2.1 — see Add-ons and plug-ins. Each add-on is its own installable library (gptps::pool, …) with a header, a .pc file and an amalgamation pair, so you can take a subset without cloning.

At a glance: 55 public functions · ABI 2.1 (append-only; 2.0 was the first and, by design, the last breaking change) · 11 add-on modules + 1 example binary plug-in · 59 tests · 12 CI runs (11 job definitions; build-test is a 2-way matrix), every one required to pass.

Liveness guarantees. Because GPTPS runs inside your process, anything that can hang it hangs your host's exit path — so these are contractual, and tests/test_hang.c enforces them with a hard test timeout:

  • gptps_shutdown always returns. In-flight work drains for at most limits.shutdown_grace_ms (default 30s; 0 opts back into waiting forever), then gets cancelled — an external child with no timeout of its own cannot wedge teardown.
  • gptps_shutdown / gptps_step return GPTPS_E_BUSY rather than deadlocking when called from a task body or an event callback.
  • The engine's growable state is bounded, with one deliberate exception. The dead-letter list (limits.max_dead_letters, default 1024) and the bytes an out-of-process child can make the parent buffer (16 MiB) are capped, and truncation is always counted rather than silent. The intake queue (limits.max_intake_depth) is unbounded by default — right for a host that submits its own work, wrong for one that accepts work from elsewhere; set it and handle GPTPS_E_FULL if a submitter can outrun your workers. Admission is O(1) in queue depth either way, so leaving it unbounded costs memory, never throughput (tests/test_admission_perf.c gates that). docs/SECURITY.md has the full table.
  • Every submitted handle reaches exactly one terminal event — the invariant the observer seam, and every add-on built on it, depends on (tests/test_reconcile.c).

Platforms (all CI-verified): Linux and macOS are full. Windows (Win32 HAL via src/hal_win.c) runs the engine, scheduler, config, the in-process and external-program executors (CreateProcess + Job Object), and the add-on loader; only GPTPS_EXEC_OOP is POSIX-only, since it forks an in-process function (no fork() on Windows — use GPTPS_EXEC_PROGRAM there for isolated, killable, memory-capped work).

Running WebAssembly works today two ways: via GPTPS_EXEC_PROGRAM + a wasm runtime CLI (examples/wasm_program.c), or the wasm_exec add-on with a pluggable runtime. Optional future work: a bundled default wasm runtime so neither a CLI nor an adapter is needed.

Design notes

The core is deliberately small and general: a mechanism-only engine with four called seams (task / constraint / observer / scheduler) — called because the core invokes them — and one composed pattern (GPTPS_SEAM_COMPOSITION — spelled GPTPS_SEAM_TRANSPORT before ABI 2.1 and still aliased), which is a shape a module takes rather than an interface the core offers. Anything specific — GPU quotas, rate limits, priority, time-of-day windows — is a constraint add-on; the admission order is a scheduler hook; scale-up (gptps_pool) and scale-out (gptps_xport) are composition libraries that sit above the engine and consume no seam at all. So the core stays minimal while the variety, and the scaling, live outside it. The novel piece is single-process self-throttling admission: "can my own process afford to start this task right now, given my own remaining budget?"

That last distinction is load-bearing rather than pedantic. gptps_pool and gptps_xport needed zero core changes because a transport calls into the engine rather than being called by it — which is precisely why the core gives it no interface, and the strongest evidence for the "compose, don't extend" thesis this project is built on.

For the full internals — concurrency model, dispatch loop, scheduler, executors, HAL, and the add-on ABI — see docs/ARCHITECTURE.md.

Non-goals

"Mechanism-only" is not a constraint unless it can reject something, so here is what GPTPS will not grow into. These are not judgements about whether the ideas are good — several are good, and some belong in add-ons. They are statements about what does not go in the core, so that the answer is decided once instead of re-argued per feature.

Not in the core Why, and where it belongs instead
Distributed scheduling (which node runs what, work stealing, membership, failure detection, global fair-share, rebalancing) Note the boundary, because the neighbouring thing IS permitted. Transport — route work to a named remote, marshal it, bring the result back, exclude a dead endpoint, retry elsewhere — is an add-on, and a welcome one: that is exactly the step gptps_xport gestures at — and addons/gptps_remote has already written down the wire format it would need, precisely so nobody mistakes it for a socket swap. Scheduling is where it stops. The moment a module needs the global state of other nodes it needs consensus, and the failure model changes completely: the novel thing here is single-process self-throttling admission, and a cluster scheduler is a different product. Node selection is a router's business (gptps_pool already picks a shard); admission ordering is gptps_set_scheduler's; neither is a cluster scheduler.
Persistence of the queue An engine that survives a crash needs a storage format, a fsync policy, and a recovery protocol — three commitments the core cannot make portably. addons/gptps_durable_queue already does it on the public API.
A metrics format (Prometheus, statsd, OTel) The core emits events and never aggregates. Binding a wire format into it dates the library to whatever was fashionable. Aggregate in an observer add-on — addons/gptps_stats is that add-on (totals, gauges, latency; no format) — and export from its snapshot in your host. If something genuinely cannot be observed from the seam, that is an argument for a specific accessor, not a format.
Futures / promises / async in the engine Result delivery is an event. A blocking wait(handle) does not need to be in the mechanism — and this row no longer asks you to take that on faith: addons/gptps_await is those lines, on the observer seam, with no core change. The core already supplies the one guarantee such a wait needs — every submitted handle reaches exactly one terminal event (tests/test_reconcile) — so nothing was missing. Chaining and dependencies are addons/gptps_orch's job, not a future's.
Task graphs / DAG semantics Dependencies are policy over submission order. addons/gptps_orch holds this; a DAG belongs in its handle space, not the dispatcher's. (Note what "terminal" means there: GPTPS_EV_FAILED is emitted per attempt, so a dependency that merely retries must not release a gate.)
A logging framework gptps_set_log_sink is one function pointer. Anything more is your host's job.
More executor kinds Three (in-process, forked, external program) span the trust and isolation axes. A fourth is nearly always "an existing one plus a runtime" — which is what addons/gptps_wasm_exec is. The enum is also now closed in code - gptps_register_task rejects a kind it does not know, so an older core meeting a newer add-on refuses the work rather than silently running it as something else.
Convenience wrappers over the C API Bindings and sugar belong in their own repos where they can move at their own pace.

The tie-break, when nothing above decides it: does a user with a name want this? Not "would this be useful" — every proposal is useful to someone hypothetical. This project reached 55 public functions and 11 add-ons before it had a single user, which is the failure mode the rule exists to prevent — and those numbers have only gone up since the rule was written, so it applies to the next proposal harder than it did to the last one.

What would legitimately change the core: a capability that cannot be built on the four called seams at all (task / constraint / observer / scheduler — see Design notes). That is a short list, and the honest way to discover an item on it is to try building the add-on first and report which accessor was missing. ABI 2.1 is what that looks like when it happens: a binary plug-in could not poll for cancellation, so it could not honour a timeout or a cancel — not a preference, an impossibility.

License

MIT — see LICENSE. No third-party code is vendored; the TOML parser, journal, and all add-ons are first-party.

About

A modularity and portability aimed General Purpose Task Processing System in C99

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages