Writing C Modules
The canonical shop can extend ƿit programs with native C or C++ code. Native files are extensions: modules that are requested by compiled executables and supplied by the shop.
During development a shop may build and load extensions as dynamic libraries.
For static targets such as web, iOS, tvOS, watchOS, or sealed desktop builds,
the builder can compile selected extensions directly into the pit binary and
expose them through a generated static extension table.
Executables record logical extension requirements. They do not care whether the shop satisfies a requirement from a dylib, a static binary symbol, or another provider.
Basic Structure
A C module exports a single function that returns a PitValue:
// mymodule.c
#include "pit.h"
// The builder normally supplies this with -DPIT_USE_NAME=...
#define PIT_USE_NAME pit_mypackage_mymodule_use
static PitValue pit_add(PitContext *ctx, PitValue *arg0, PitValue *arg1) {
double a = pit2number(ctx, *arg0);
double b = pit2number(ctx, *arg1);
return number2pit(ctx, a + b);
}
static PitValue pit_multiply(PitContext *ctx, PitValue *arg0,
PitValue *arg1) {
double a = pit2number(ctx, *arg0);
double b = pit2number(ctx, *arg1);
return number2pit(ctx, a * b);
}
static const PitCFunctionListEntry pit_funcs[] = {
PIT_FUNC_DEF(mymodule, add, 2),
PIT_FUNC_DEF(mymodule, multiply, 2),
};
PIT_USE_FUNCS(pit_funcs)
Symbol Naming
One authority names symbols: shoplib::symbols. Your module’s public name is its
locator — <package>::<path>, which is just where the file sits — that module derives the
exported C symbol from it, and the builder passes the result as PIT_USE_NAME:
pit_<package>_<module_path>_use
Package locators and module paths are converted to C-safe identifiers. Targets with symbol length limits take a deterministic shortened name from the same place.
Every rendered manifest carries the full symbol as data, and nothing downstream builds
one. A static extension table, a link list, a dylib lookup — each reads the symbol it was
given, because a second place assembling a symbol from parts would be a second scheme. A
package cake.ce actor can select an implementation file, but it names no module and
chooses no symbol.
Examples:
mypackage::math->pit_mypackage_math_usegitea.pockle.world/john/lib::render->pit_gitea_pockle_world_john_lib_render_usepitlib::internal/time->pit_pitlib_internal_time_use
Modules under internal/ follow the same convention — the internal directory name becomes
part of the path and so part of the symbol. Nothing about them is spelled differently; what
makes them private is that no other package can name them.
Note: Having both a .cm and .c file with the same stem at the same scope is a build error.
Required Headers
Include pit.h for all ƿit integration:
#include "pit.h"
This provides:
- Pit runtime types and functions
- Conversion helpers
- Module definition macros
Conversion Functions
Pit <-> C
// Numbers
double pit2number(PitContext *ctx, PitValue v);
PitValue number2pit(PitContext *ctx, double g);
// Booleans
int pit2bool(PitContext *ctx, PitValue v);
PitValue bool2pit(PitContext *ctx, int b);
// Strings (must free with Pit_FreeCString)
const char *Pit_ToCString(PitContext *ctx, PitValue v);
void Pit_FreeCString(PitContext *ctx, const char *str);
PitValue Pit_NewString(PitContext *ctx, const char *str);
Blobs
// Get blob data (returns pointer, sets size in bytes)
void *pit_get_blob_data(PitContext *ctx, size_t *size, PitValue v);
// Get blob data in bits
void *pit_get_blob_data_bits(PitContext *ctx, size_t *bits, PitValue v);
// Create new stone blob from data
PitValue pit_new_blob_stoned_copy(PitContext *ctx, void *data, size_t bytes);
// Check if value is a blob
int pit_is_blob(PitContext *ctx, PitValue v);
Both readers signal failure by returning NULL — never (void *)-1 — and both
write the out-parameter on every path, refusals included. Test the pointer:
size_t bits = 0;
void *data = pit_get_blob_data_bits(ctx, &bits, *arg0);
if (!data) return PIT_EXCEPTION; /* refused; a disrupt is already raised */
A zero-length blob succeeds: it yields zero bytes at a valid, non-NULL
address, so !data means a refusal and nothing else. Emptiness is not one — the
empty blob has a digest, a signature and canonical bytes like any other blob, and
the object store cannot name empty content without them. The refusals these two
raise are about shape: not a blob, not stone, and (for the bit reader) not
byte-aligned. If your entry genuinely cannot act on zero bytes — it is reading a
fixed-width key, or a trailing MAC — refuse on the length you got back, with a
message that says what was missing.
Function Definition Macros
C functions
Define functions with a PitContext *ctx followed by one pointer per
argument:
C entry points take a fixed arity, from zero through four arguments. When a native
operation needs more than four inputs, pass one record that names them. For example, a native drawing
surface should expose draw_line({x1, y1, x2, y2, width, color}) rather than
adding a fifth positional argument.
static PitValue pit_mymodule_greet(PitContext *ctx, PitValue *arg0) {
const char *name = Pit_ToCString(ctx, *arg0);
char buf[256];
snprintf(buf, sizeof(buf), "Hello, %s!", name);
PitValue ret = Pit_NewString(ctx, buf);
Pit_FreeCString(ctx, name);
return ret;
}
PIT_FUNC_DEF
Register a function in the function list:
PIT_FUNC_DEF(prefix, function_name, arg_count)
Module Export Macros
PIT_USE_FUNCS
Export an object with functions:
static const PitCFunctionListEntry pit_funcs[] = {
PIT_FUNC_DEF(mymod, func1, 1),
PIT_FUNC_DEF(mymod, func2, 2),
};
PIT_USE_FUNCS(pit_funcs)
PIT_USE_INIT
For custom initialization:
PIT_USE_INIT(
PitValue obj = Pit_NewRecord(ctx);
// Custom setup...
return obj;
)
PIT_USE_PROBE
Declare that this module needs an optional host library. The build reads the declaration and compiles the module only where it detects that library, so the requirement travels with the file that has it:
#include <git2.h>
PIT_USE_PROBE(libgit2)
It expands to nothing; its only compile-time job is to fail if pit.h was not
included.
PIT_USE_LANE and PIT_USE_LEASE
Declare that this module’s C must run on a particular execution lane, and that
it holds an exclusive lease while it does. A module with no PIT_USE_LANE runs
on the normal lane.
PIT_USE_LANE(main) // must run on the platform main thread
PIT_USE_LEASE(watchkit.interface) // and holds this exclusive slot
Both expand to nothing and are read by the same scanner as PIT_USE_PROBE and
PIT_USE_BOOT. The reason they are macros is the reason the probe is: “this
shim needs pinning to a thread” is a fact about the C that has the requirement,
so it is stated in that file and nowhere else. A manifest column saying the same
thing would be a second place to keep in step by hand.
Complete Example
// vector.c - Simple 2D vector operations
#include "pit.h"
#include <math.h>
#define PIT_USE_NAME pit_mypackage_vector_use
static PitValue pit_vector_length(PitContext *ctx, PitValue *arg0, PitValue *arg1) {
double x = pit2number(ctx, *arg0);
double y = pit2number(ctx, *arg1);
return number2pit(ctx, sqrt(x*x + y*y));
}
static PitValue pit_vector_normalize(PitContext *ctx, PitValue *arg0, PitValue *arg1) {
double x = pit2number(ctx, *arg0);
double y = pit2number(ctx, *arg1);
double len = sqrt(x*x + y*y);
if (len > 0) {
PIT_FRAME(ctx);
PIT_ROOT(result, Pit_NewRecord(ctx));
Pit_SetPropertyStr(ctx, result.val, "x", number2pit(ctx, x/len));
Pit_SetPropertyStr(ctx, result.val, "y", number2pit(ctx, y/len));
PIT_RETURN(result.val);
}
return PIT_NULL;
}
static PitValue pit_vector_dot(PitContext *ctx, PitValue *arg0, PitValue *arg1,
PitValue *arg2, PitValue *arg3) {
double x1 = pit2number(ctx, *arg0);
double y1 = pit2number(ctx, *arg1);
double x2 = pit2number(ctx, *arg2);
double y2 = pit2number(ctx, *arg3);
return number2pit(ctx, x1*x2 + y1*y2);
}
static const PitCFunctionListEntry pit_funcs[] = {
PIT_FUNC_DEF(vector, length, 2),
PIT_FUNC_DEF(vector, normalize, 2),
PIT_FUNC_DEF(vector, dot, 4),
};
PIT_USE_FUNCS(pit_funcs)
Usage in ƿit:
def vector = use('vector')
var len = vector.length(3, 4) // 5
var n = vector.normalize(3, 4) // {x: 0.6, y: 0.8}
var d = vector.dot(1, 0, 0, 1) // 0
Build Process
C extensions are built by the shop build actor. To build a package’s native modules:
pit build <path>
Each build output is stored under the shop’s content-addressed build cache. Dynamic builds produce dylibs and record the full symbol they were given for each module. Static builds compile the selected objects into a binary and generate a table of the locators it can answer.
Useful development commands include:
pit build dylib <extension>
pit build explain <extension>
pit build internals
pit build targets
pit build pack <package> --output=<path>
Compilation Flags
Use the compilation object in package.json to pass compiler and linker flags:
{
"compilation": {
"CFLAGS": "-Isdk/include",
"LDFLAGS": "-lz -lm"
}
}
Each value is a single string, split on whitespace.
Include paths
Relative -I paths are resolved from the package root:
{ "compilation": { "CFLAGS": "-Isdk/public" } }
If your package is at /path/to/mypkg, this becomes -I/path/to/mypkg/sdk/public.
Absolute paths are passed through unchanged.
The build system also auto-discovers include/ directories — if your package has an include/ directory, it is automatically added to the include path.
Library paths
Relative -L paths work the same way:
{ "compilation": { "LDFLAGS": "-Lsdk/lib -lmylib" } }
Target-specific flags
Name a target directly inside compilation. Its CFLAGS and LDFLAGS are appended to
the top-level ones when building for that target:
{
"compilation": {
"CFLAGS": "-Isdk/public",
"macos_arm64": { "LDFLAGS": "-Lsdk/lib/osx -lmylib" },
"linux": { "LDFLAGS": "-Lsdk/lib/linux64 -lmylib" },
"windows": { "LDFLAGS": "-Lsdk/lib/win64 -lmylib64" }
}
}
A target name here is matched against the name the recipe
declares — this is your package saying how it varies, which is the one place a target name
appears outside a recipe. macos_arm64, macos_x86_64, linux, linux_arm64 and
windows are the desktop recipes that exist today; nothing in the build derives meaning
from those strings, so a new recipe needs no change here.
Sigils
Use sigils in flags to refer to standard directories:
$LOCAL— absolute path to.pit/local(for prebuilt libraries)$PACKAGE— absolute path to the package root
{
"compilation": {
"CFLAGS": "-I$PACKAGE/vendor/include",
"LDFLAGS": "-L$LOCAL -lmyprebuilt"
}
}
Example: vendored SDK
A package wrapping an external SDK with platform-specific shared libraries:
mypkg/
├── package.json
├── wrapper.cpp
└── sdk/
├── public/
│ └── mylib/
│ └── api.h
└── lib/
├── osx/
│ └── libmylib.dylib
└── linux64/
└── libmylib.so
{
"compilation": {
"CFLAGS": "-Isdk/public",
"macos_arm64": { "LDFLAGS": "-Lsdk/lib/osx -lmylib" },
"linux": { "LDFLAGS": "-Lsdk/lib/linux64 -lmylib" }
}
}
// wrapper.cpp
#include "pit.h"
#include <mylib/api.h>
// ...
Platform-Specific Code
Platform-specific implementation selection is explicit. A modules arm in package.json,
or a package’s top-level cake.ce, chooses the concrete file for a target; the module’s
name stays what it always was.
audio.c # default implementation
audio_playdate.c # selected by cake for Playdate
audio_emscripten.c # selected by cake for Web/Emscripten
Without cake.ce, each C/C++ file outside source/ is a module of the package, named by
its path. Pit does not infer target variants from broad filename suffixes such as _posix,
_darwin, or _arm64 — a target arm in the manifest’s modules is how one module gets a
per-target implementation.
cake.ce
If a package contains top-level cake.ce, the builder can ask it how to build
extensions for a target. Cake runs before the package’s own extensions are
built, so it must not require native modules from that same package.
Cake can:
- accept the file the directory walk already found
- point a module at a different implementation file for this target
- add include paths, defines, C flags, linker flags, libraries, frameworks, rpaths, and cache inputs
- report that a requested module is unavailable for the package or target
Cake controls package build intent. It names no module and no C symbol.
Multi-File C Modules
If your module wraps a C library, place support files in a source/ directory. Everything
in source/ is compiled and linked in whenever the package compiles any C module at all;
nothing there is a module of its own. The folder is the whole rule, so there is no support
list to maintain and no way to forget a file.
mypackage/
rtree.c # module (exports pit_mypackage_rtree_use)
source/
rtree.c # support file (linked into rtree.dylib)
rtree.h # header
The module file (rtree.c) includes the library header and uses pit.h as usual. The support files are plain C — they don’t need any pit macros.
GC Safety
ƿit uses a Cheney copying garbage collector. Any Pit allocation —
Pit_NewRecord, Pit_NewString, Pit_NewInt32, Pit_SetPropertyStr,
pit_new_blob_stoned_copy, etc. — can trigger GC, which moves heap objects
to new addresses. Bare C locals holding PitValue become dangling pointers
after any allocating call. This is not a theoretical concern — it causes real
crashes that are difficult to reproduce because they depend on heap pressure.
Checklist (apply to EVERY C function you write or modify)
- Count the
Pit_New*,Pit_SetProperty*, andpit_new_blob*calls in the function - If there are 2 or more, the function MUST use
PIT_FRAME/PIT_ROOT/PIT_RETURN - Every
PitValueheld in a C local across an allocating call must be rooted
When you need rooting
If a function creates one heap object and returns it immediately, no rooting is needed:
static PitValue pit_mymod_name(PitContext *ctx) {
return Pit_NewString(ctx, "hello");
}
If a function creates an object and then sets properties on it, you must root it — each Pit_SetPropertyStr call is an allocating call that can trigger GC:
// UNSAFE — will crash under GC pressure:
PitValue obj = Pit_NewRecord(ctx);
Pit_SetPropertyStr(ctx, obj, "x", Pit_NewInt32(ctx, 1)); // can GC → obj is stale
Pit_SetPropertyStr(ctx, obj, "y", Pit_NewInt32(ctx, 2)); // obj may be garbage
return obj;
// SAFE:
PIT_FRAME(ctx);
PIT_ROOT(obj, Pit_NewRecord(ctx));
Pit_SetPropertyStr(ctx, obj.val, "x", Pit_NewInt32(ctx, 1));
Pit_SetPropertyStr(ctx, obj.val, "y", Pit_NewInt32(ctx, 2));
PIT_RETURN(obj.val);
Patterns
Object with properties — the most common pattern in this codebase:
PIT_FRAME(ctx);
PIT_ROOT(result, Pit_NewRecord(ctx));
Pit_SetPropertyStr(ctx, result.val, "width", Pit_NewInt32(ctx, w));
Pit_SetPropertyStr(ctx, result.val, "height", Pit_NewInt32(ctx, h));
Pit_SetPropertyStr(ctx, result.val, "pixels", pit_new_blob_stoned_copy(ctx, data, len));
PIT_RETURN(result.val);
Array with loop — root the element variable before the loop, then reassign .val each iteration:
PIT_FRAME(ctx);
PIT_ROOT(arr, Pit_NewArray(ctx));
PIT_ROOT(item, PIT_NULL);
for (int i = 0; i < count; i++) {
item.val = Pit_NewRecord(ctx);
Pit_SetPropertyStr(ctx, item.val, "index", Pit_NewInt32(ctx, i));
Pit_SetPropertyStr(ctx, item.val, "data", pit_new_blob_stoned_copy(ctx, ptr, sz));
Pit_SetPropertyNumber(ctx, arr.val, i, item.val);
}
PIT_RETURN(arr.val);
WARNING — NEVER put PIT_ROOT inside a loop. PIT_ROOT declares a PitGCRef local and calls Pit_PushGCRef(&name), which pushes its address onto a linked list. Inside a loop the compiler reuses the same stack address, so on iteration 2+ the list becomes self-referential (ref->prev == ref). When GC triggers it walks the chain and hangs forever. This bug is intermittent — it only manifests when GC happens to run during the loop — making it very hard to reproduce.
Nested objects — root every object that persists across an allocating call:
PIT_FRAME(ctx);
PIT_ROOT(outer, Pit_NewRecord(ctx));
PIT_ROOT(inner, Pit_NewArray(ctx));
// ... populate inner ...
Pit_SetPropertyStr(ctx, outer.val, "items", inner.val);
PIT_RETURN(outer.val);
C Argument Evaluation Order (critical)
In C, the order of evaluation of function arguments is unspecified. This interacts with the copying GC to create intermittent crashes that are extremely difficult to diagnose.
// UNSAFE — crashes intermittently:
PIT_FRAME(ctx);
PIT_ROOT(obj, Pit_NewRecord(ctx));
Pit_SetPropertyStr(ctx, obj.val, "format", Pit_NewString(ctx, "rgba32"));
// ^^^^^^^ may be evaluated BEFORE Pit_NewString runs
// If Pit_NewString triggers GC, the already-read obj.val is a dangling pointer.
The compiler is free to evaluate obj.val into a register, then call Pit_NewString. If Pit_NewString triggers GC, the object moves to a new address. The rooted obj is updated by GC, but the register copy is not — it still holds the old address. Pit_SetPropertyStr then writes to freed memory.
Fix: always separate the allocating call into a local variable:
// SAFE:
PIT_FRAME(ctx);
PIT_ROOT(obj, Pit_NewRecord(ctx));
PitValue fmt = Pit_NewString(ctx, "rgba32");
Pit_SetPropertyStr(ctx, obj.val, "format", fmt);
// obj.val is read AFTER Pit_NewString completes — guaranteed correct.
This applies to any allocating function used as an argument when another argument references a rooted .val:
// ALL of these are UNSAFE:
Pit_SetPropertyStr(ctx, obj.val, "pixels", pit_new_blob_stoned_copy(ctx, data, len));
Pit_SetPropertyStr(ctx, obj.val, "x", Pit_NewFloat64(ctx, 3.14));
Pit_SetPropertyStr(ctx, obj.val, "name", Pit_NewString(ctx, name));
// SAFE versions — separate the allocation:
PitValue pixels = pit_new_blob_stoned_copy(ctx, data, len);
Pit_SetPropertyStr(ctx, obj.val, "pixels", pixels);
PitValue x = Pit_NewFloat64(ctx, 3.14);
Pit_SetPropertyStr(ctx, obj.val, "x", x);
PitValue s = Pit_NewString(ctx, name);
Pit_SetPropertyStr(ctx, obj.val, "name", s);
Functions that allocate (must be separated): Pit_NewString, Pit_NewFloat64, Pit_NewInt64, Pit_NewRecord, Pit_NewArray, Pit_NewCFunction, pit_new_blob_stoned_copy
Functions that do NOT allocate (safe inline): Pit_NewInt32, Pit_NewUint32, Pit_NewBool, PIT_NULL, PIT_TRUE, PIT_FALSE
Extracting PitValues from non-GC-managed C structs (critical)
The runtime has C structs that hold PitValue fields but live in malloc()’d
memory, outside the GC heap: letters, timer entries, and module-registered
request structs. The PitValue inside is only GC-tracked while the struct is
in a known scanned container:
| Container | Scanned by |
|---|---|
actor->letters[] | actor_gc_scan |
actor->timers | actor_gc_scan |
actor->parks_head | actor_gc_scan (callback fields on each park) |
actor->signals_head | actor_gc_scan (callback fields on each signal) |
| module-owned queues | their registered pit_run_gc_scans scanner |
Module C code with an actor-bound callback should normally hold it through
pit_signal; callback-mode signals are rooted through signals_head.
pit_park is the lower-overhead one-shot adapter reserved for a completion
source whose ownership and shutdown the module completely controls.
The instant the struct is lifted into a stack-local — popped from a queue, removed from a map, copied into a letter l local on the C stack — and you allocate before reading the PitValue, that PitValue is unrooted. A copying GC will collect what it points to.
// UNSAFE — req leaves the module's scanned queue before this allocation.
native_request *req = pending[0];
arrdel(pending, 0);
args[1] = Pit_NewString(actor, strerror(req->error)); // GC here
PitValue cb = req->callback; // STALE
Pit_Call(actor, cb, 2, args);
// SAFE — push req->callback into a PitGCRef before removing it,
// read it back from .val after.
PitGCRef cb_ref;
Pit_PushGCRef(actor, &cb_ref);
cb_ref.val = req->callback;
arrdel(pending, 0);
args[1] = Pit_NewString(actor, strerror(req->error)); // safe
PitValue cb = cb_ref.val;
Pit_PopGCRef(actor, &cb_ref);
Pit_Call(actor, cb, 2, args);
The same pattern shows up in args[]-style buffers when the buffer is built across allocations:
// UNSAFE — args[0] is unrooted between Pit_PopGCRef and Pit_NewString;
// the array can move during the alloc and args[0] becomes stale.
args[0] = arr.val;
Pit_PopGCRef(actor, &arr);
if (need_eof) args[1] = Pit_NewString(actor, "eof");
// SAFE — do the alloc while arr is still rooted, then read .val.
if (need_eof) args[1] = Pit_NewString(actor, "eof");
args[0] = arr.val;
Pit_PopGCRef(actor, &arr);
Shape to flag: PitValue x = struct->field; (or x = ref.val after Pop) followed by any Pit_New* / Pit_SetProperty* / pit_new_blob* / Pit_Call before x is read or used. Either reorder so the alloc happens while the original holder is still rooted, or push x into a PitGCRef for the duration.
The bug is invisible until the GC pressure is high enough — bootstrap-style heavy compile, or any loop that allocates rapidly across async I/O completions. Diagnosing it after the fact is hard (the symptom is a stale heap pointer through a closure’s outer_frame chain at GETUP time, far from the actual write site), so the audit at write time is the cheapest place to catch it.
Macros
| Macro | Purpose |
|---|---|
PIT_FRAME(ctx) | Save the GC frame. Required before any PIT_ROOT. |
PIT_ROOT(name, init) | Declare a PitGCRef and root its value. Access via name.val. |
PIT_LOCAL(name, init) | Declare a rooted PitValue (GC updates it through its address). |
PIT_RETURN(val) | Restore the frame and return a value. |
PIT_RETURN_NULL() | Restore the frame and return PIT_NULL. |
PIT_RETURN_EX() | Restore the frame and return PIT_EXCEPTION. |
Error return rules
- Error returns before
PIT_FRAMEcan use plainreturn Pit_ThrowTypeError(...)etc. - Error returns after
PIT_FRAMEmust usePIT_RETURN_EX()orPIT_RETURN_NULL()— never plainreturn, which would leak the GC frame.
Long-Lived PitValues in C Structs
The copying GC needs to know the address of every PitValue pointer so it
can update it when objects move.
If your C struct holds a PitValue that must survive across GC points, root it for the duration it’s alive:
typedef struct {
PitValue callback;
PitLocalRef callback_lr;
} MyWidget;
// When storing:
widget->callback = value;
widget->callback_lr.ptr = &widget->callback;
Pit_PushLocalRef(ctx, &widget->callback_lr);
// When done, before freeing the struct, remove or restore the local ref.
In practice, most C wrappers hold only opaque C pointers (like SDL_Window*)
and never store PitValue fields.
Async callbacks (pit_signal, controlled pit_park)
When a C function takes a pit callback and yields a value asynchronously — the standard requestor shape function(callback, value) — you can’t just stash the callback PitValue and call it later. The callback’s underlying value can move during GC; calling a freed value is a UAF. The actor that owns the callback can also halt before your async work finishes; firing the callback after halt is a UAF.
The normal public mechanism is the refcounted pit_signal. pit_park remains
an internal/controlled-source optimization.
pit_signal — foreign callbacks and queues
Callback-mode pit_signal roots the callback, holds the actor alive, and
delivers the format hook in an actor turn. It can be one-shot or multi-shot.
Creation atomically publishes the actor-list reference and returns a distinct
caller/producer reference. Transfer that returned reference to one external
callback source, which releases it after its final fire attempt; retain only
for additional producers. This is what makes a callback racing actor halt
safe: halt consumes the actor-owned reference, while the producer reference
keeps the C handle alive until the callback is no longer using it.
typedef struct work {
pit_signal_t *signal; /* weak owner pointer, guarded by your mutex */
int result;
} work_t;
static int format_result(PitContext *actor, void *ud,
PitValue *args, int max) {
work_t *w = ud;
args[0] = Pit_NewInt32(actor, w->result);
return 1;
}
static void release_work(void *ud) {
work_t *w = ud;
/* Clear any cancel-side weak pointer under the module's mutex, then
release/free module state according to its own refcount. */
work_lock(w);
w->signal = NULL;
work_unlock(w);
work_release(w);
}
static PitValue pit_my_async(PitContext *ctx, PitValue *cb, PitValue *opts) {
work_t *w = work_new(); /* refs = 1 for this start function */
work_retain(w); /* signal userdata */
work_retain(w); /* prospective foreign callback state */
w->signal = pit_signal_create(&(pit_signal_opts){
.actor = ctx,
.multi = false,
.callback = *cb,
.format = format_result,
.destroy = release_work,
.userdata = w,
});
if (!w->signal) {
work_release(w); /* create failure does not run release_work */
work_release(w);
work_release(w);
return PIT_NULL;
}
/* The foreign callback consumes create()'s returned producer reference.
Both its signal and work-state ownership existed before signal
publication, so actor halt cannot win an unlock-then-retain race. */
pit_signal_t *published = w->signal;
foreign_start(w, published, ^{
pit_signal_fire(published);
pit_signal_release(published);
work_release(w);
});
work_release(w); /* start-function ref */
return PIT_NULL;
}
For one-shot signals, the first successful fire/send makes the signal terminal. For multi-shot signals, explicit cancel or actor halt does. The release hook runs exactly once. Repeated fire/cancel/send is legal only while the caller still owns a reference; a terminal operation may consume the actor-owned reference. Every successful create therefore needs a matching release or an explicit transfer of the returned reference.
Message-mode signals carry Wota data rather than a PitValue. A one-shot
message signal becomes terminal as soon as its first letter is queued;
multi-shot message signals remain active until cancel/halt.
pit_park — one controlled owner only
pit_park has no external reference count. Exactly one controlled owner may
call pit_park_fire or pit_park_cancel, once, and it must never read or
call through the pointer afterward. Actor halt may cancel first, so the park’s
release hook must synchronously stop and join the controlled producer before
returning; park_destroy frees the handle only after that hook returns.
static void release_owned_worker(void *ud) {
owned_work *w = ud;
atomic_store(&w->stop, 1);
wake_worker(w);
sys_thread_join(w->thread); /* no producer can still touch w->park */
free(w);
}
The owner rule is stronger than “the functions use atomics.” Fire/cancel races on an unretained park pointer are a use-after-free: whichever path destroys the park can free it while the other path is arriving. Do not use a park for libdispatch, libuv-owned queues, driver/audio threads, WebGPU callbacks, or library watcher threads.
pit_call_gate — synchronous host callbacks
Use an actor call gate when a host API invokes C synchronously and needs an
immediate value back from one Pit callback. Create the gate on the owning actor
with pit_call_gate_create. The returned pointer is the provider’s reference;
retain it before publishing it to any external callback source, close it when
the registration is withdrawn, and release each reference only after its owner
can no longer call through it. Actor halt closes the gate and wakes waiters.
Arguments and results cross the boundary as exactly one Wota value. A
PitValue never crosses threads. pit_call_gate_call copies and validates the
bounded request before publishing it, then follows one of two paths:
- From the owning actor’s current turn, it calls directly. The callback must not suspend.
- From a foreign thread, it queues a normal actor turn and waits. The callback
may suspend and resume;
timeout_ns == 0waits indefinitely.
A call from a different actor turn returns PIT_CALL_BAD_ARGUMENT; actors do
not synchronously block on one another. A direct callback exception returns
PIT_CALL_CALLBACK_FAILED to the surrounding provider call. A queued callback
has its own actor turn, so the same status also follows normal uncaught
exception/halt semantics.
Results use plain Wota serialization. Serialization never calls toJSON or
other Pit code after the callback returns, function properties are omitted,
cycles return PIT_CALL_NOT_SERIALIZABLE, and allocation failure returns
PIT_CALL_OUT_OF_MEMORY. Release a successful result with
pit_call_result_free.
An externally driven actor has one extra composition case. When a host callback
is already on that actor’s named lane thread but outside a Pit turn, use
pit_call_gate_call_on_lane: it queues the call and drives the provider-owned
lane capability synchronously. Its positive turn bound limits older work; zero
is unbounded. Suspension returns PIT_CALL_CALLBACK_SUSPENDED and disrupts the
actor because the host callback must return immediately. Do not use either gate
entry from a hard-realtime audio callback: move bytes through pit_ring and
notify the actor with a signal instead.
Choosing the primitive
| Your firing thread is… | Use |
|---|---|
A pthread you spawn and can pthread_join | pit_park |
A loop you control (e.g. you uv_run a libuv loop on a worker you own) | pit_park |
| A libdispatch queue / GCD block / Network.framework completion | retained pit_signal |
| A synchronous host callback needing a Pit return value | retained pit_call_gate |
| The callback thread owning the actor’s named lane | pit_call_gate_call_on_lane |
| A hard-realtime driver thread (CoreAudio, MIDI, SDL audio device, etc.) | pit_ring plus retained pit_signal |
| A library’s own watcher thread (dmon, libfswatch) | retained multi-shot pit_signal |
| A WebGPU map callback / FFI completion handler | retained pit_signal |
If in doubt, use pit_signal. Use pit_park only when one owner and its
synchronous shutdown are explicit in the design.
Returning a cancel function to the caller
The pit requestor convention is function(callback, value) → cancel. C functions can’t easily build a Pit closure that captures C state, so target adapters such as the fetch package use this pattern:
- C exposes two functions: a
startreturning an opaque handle, and acanceltaking that handle. - The pit-side wrapper calls
start, builds the cancel closure in pit:
function my_send(ctx, data) {
return function(cb, _v) {
var handle = native.send_start(cb, ctx, data)
return function(_reason, _abandon) { native.send_cancel(handle) }
}
}
The handle normally owns module state containing a mutex-guarded weak
pit_signal_t *. send_cancel retains that signal while holding the mutex,
drops the mutex, calls pit_signal_cancel, then releases its temporary retain.
The signal release hook clears the weak pointer under the same mutex.
Static Declarations
Keep internal functions and variables static:
static int helper_function(int x) {
return x * 2;
}
static int module_state = 0;
This prevents symbol conflicts between packages.
Troubleshooting
Missing header / SDK not installed
If a package wraps a third-party SDK that isn’t installed on your system, the build will show:
module.c: fatal error: 'sdk/header.h' file not found (SDK not installed?)
Install the required SDK or skip that package. These warnings are harmless — other packages continue building normally.
CFLAGS not applied
If your package.json has a compilation object but flags aren’t being picked up, check:
- The JSON is valid, and every flag value is a single quoted string
- The key is exactly
compilation(notcompile) - Target-specific keys inside it match a name a recipe declares —
macos_arm64,macos_x86_64,linux,linux_arm64,windowsfor the desktop recipes
Common API mismatches
If C modules fail with errors about function signatures:
Pit_IsArraytakes one argument (the value), not two — remove the context argument- Use
Pit_GetPropertyNumber/Pit_SetPropertyNumberinstead ofPit_GetPropertyUint32/Pit_SetPropertyUint32 - Use
Pit_NewStringinstead ofPit_NewAtomString - Absence is
PIT_NULL; test for it withPit_IsNull