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_use
  • gitea.pockle.world/john/lib::render -> pit_gitea_pockle_world_john_lib_render_use
  • pitlib::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)

  1. Count the Pit_New*, Pit_SetProperty*, and pit_new_blob* calls in the function
  2. If there are 2 or more, the function MUST use PIT_FRAME / PIT_ROOT / PIT_RETURN
  3. Every PitValue held 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:

ContainerScanned by
actor->letters[]actor_gc_scan
actor->timersactor_gc_scan
actor->parks_headactor_gc_scan (callback fields on each park)
actor->signals_headactor_gc_scan (callback fields on each signal)
module-owned queuestheir 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

MacroPurpose
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_FRAME can use plain return Pit_ThrowTypeError(...) etc.
  • Error returns after PIT_FRAME must use PIT_RETURN_EX() or PIT_RETURN_NULL() — never plain return, 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 == 0 waits 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_joinpit_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 completionretained pit_signal
A synchronous host callback needing a Pit return valueretained pit_call_gate
The callback thread owning the actor’s named lanepit_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 handlerretained 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:

  1. C exposes two functions: a start returning an opaque handle, and a cancel taking that handle.
  2. 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:

  1. The JSON is valid, and every flag value is a single quoted string
  2. The key is exactly compilation (not compile)
  3. Target-specific keys inside it match a name a recipe declares — macos_arm64, macos_x86_64, linux, linux_arm64, windows for the desktop recipes

Common API mismatches

If C modules fail with errors about function signatures:

  • Pit_IsArray takes one argument (the value), not two — remove the context argument
  • Use Pit_GetPropertyNumber / Pit_SetPropertyNumber instead of Pit_GetPropertyUint32 / Pit_SetPropertyUint32
  • Use Pit_NewString instead of Pit_NewAtomString
  • Absence is PIT_NULL; test for it with Pit_IsNull