Files
scriptc/tests/library-mode/callbacks/probe_threads.c
Chris Tate 81b70b0ee6 feat: add host-callback channels to library mode (#148)
* feat: add host-callback channels to library mode

- the profile's `callbacks` array declares named channels and
  `abi.callback_register_symbol` names the one registration entry point:
  `int32_t <sym>(const char *name, void (*fn)(void), void *ctx)` — 0 on
  success, -1 for an unknown or NULL name, latest registration wins, a
  NULL fn clears, registrations persist across init/reset. The stored
  pointer's typed shape is the channel's with the opaque context first,
  the panic sink's layout
- channel signatures ride the existing marshalling classes: params are
  f64/bool/string/bytes plus the u8/u32/i32 plumbing classes (outbound
  plumbing keeps JS's ToUint32/ToInt32, the executable FFI rule);
  returns are scalar only (f64/bool/u8/u32/i32/void — a buffer return
  needs an ownership contract the mode does not define). string/bytes
  parameters arrive as (ptr, len) pairs borrowed for the call's duration
- compiled code reaches a channel as a signature-only ambient function
  declaration whose direct calls deliver synchronously on the calling
  thread; the recognition rides the FFI import machinery under a library
  flavor: SC4024 refuses a call the channels cannot serve (undeclared
  name or off-class signature), unused channels are legal capacity, and
  callback-free profiles keep the ambient ReferenceError semantics
- calling an unregistered channel is the SC4025 runtime trap: a detected
  trap through the library funnel, so the structured sink message names
  the channel in its text and the entry the host called in its symbol
  field, and profile teachings/remediations overlay it like the rest of
  the runtime family
- registration slots are per copy of the runtime state, the sink's story:
  per-archive under abi.localize_runtime (the register symbol joins the
  localization keep-list), per-thread instance under
  abi.instance_per_thread. The host callback must not reenter any library
  entry or unwind across library frames
- both emissions produce identical behavior by construction: the slot
  store/fetch lives in scr_library.c (scr_library_cb_set/_require/_ctx),
  the generated TU emits the registration dispatch and typed indirect
  calls, and the trap text is assembled once at export resolution
- callback-free profiles emit byte-identical program TUs and serialized
  IR, and executable builds are byte-identical end to end
- conformance: the CB suite (tests/harness/library-callbacks.test.ts)
  covers the acceptance stream, unregistered and pre-registration traps,
  symbol exactness, teaching overlays, SC4024 refusals, capacity and
  callback-free postures, the localized+thread-instanced composition
  probe, and ASan reruns; profile-shape refusals join
  library-profile.test.ts

* fix: preserve callback binding identity

* fix: harden library callback contracts

* fix: recognize project callback declarations

* fix: enforce host callback declarations
2026-08-13 21:44:55 -05:00

194 lines
6.2 KiB
C

/* Composition probe: ONE runtime-localized (abi.localize_runtime),
* thread-instanced (abi.instance_per_thread) archive with host-callback
* channels, driven from two embedder threads — the documented contract:
* the calling thread IS the instance selector, and channel registrations
* are per-instance state exactly like the sink's. Checks:
* - each thread registers its OWN emitChunk context and sink, inits its
* own instance, and streams concurrently (a barrier forces overlap);
* chunks route to the registering thread's log with exact contents,
* order, and thread identity — nothing crosses instances;
* - thread A then reaches the channel it never registered: the SC4025
* structured trap arrives at A's sink exactly once (A's ctx, symbol
* cbt_poke_orphan) and poisons only A's instance;
* - thread B keeps streaming through and after A's trap window with
* exact values; B's sink never fires.
* Workers record into per-thread state; main prints after both joins, so
* stdout is deterministic under any interleaving. */
#include <pthread.h>
#include <setjmp.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
extern void cbt_init(void);
extern void cbt_set_panic_sink(void (*fn)(void *, const uint8_t *, size_t, uint64_t), void *ctx);
extern int32_t cbt_set_callback(const char *name, void (*fn)(void), void *ctx);
extern double cbt_stream(double n, double base);
extern double cbt_ask_host(double x);
extern double cbt_poke_orphan(void);
typedef void (*cb_fn)(void);
/* Stages: 0 start; 1 both instances initialized (concurrent streams run);
* 2 both first streams done (A traps); 3 A's trap delivered (B streams
* once more and exits). */
static pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t cv = PTHREAD_COND_INITIALIZER;
static int stage = 0;
static int inited = 0, streamed = 0;
static void stage_set(int s) {
pthread_mutex_lock(&mu);
if (stage < s) stage = s;
pthread_cond_broadcast(&cv);
pthread_mutex_unlock(&mu);
}
static void stage_wait(int s) {
pthread_mutex_lock(&mu);
while (stage < s) pthread_cond_wait(&cv, &mu);
pthread_mutex_unlock(&mu);
}
static void arrive(int *counter, int total, int next_stage) {
pthread_mutex_lock(&mu);
(*counter)++;
if (*counter == total && stage < next_stage) {
stage = next_stage;
pthread_cond_broadcast(&cv);
}
pthread_mutex_unlock(&mu);
}
typedef struct {
char bytes[8];
size_t len;
uint32_t seq;
} ChunkRec;
typedef struct {
pthread_t self;
ChunkRec chunks[16];
int count;
int thread_ok;
int sink_calls;
int sink_ctx_ok;
char sink_code[16];
char sink_symbol[64];
double r1, r2;
jmp_buf trap_jmp;
} Worker;
static void on_chunk(void *ctx, const uint8_t *p, size_t len, uint32_t seq) {
Worker *w = (Worker *)ctx;
ChunkRec *rec = &w->chunks[w->count];
memcpy(rec->bytes, p, len < sizeof rec->bytes ? len : sizeof rec->bytes);
rec->len = len;
rec->seq = seq;
w->count++;
if (!pthread_equal(pthread_self(), w->self)) w->thread_ok = 0;
}
static int32_t on_progress(void *ctx, double done, double total) {
(void)ctx;
return (int32_t)(total - done);
}
static void on_note(void *ctx, const uint8_t *p, size_t len, uint8_t last) {
(void)ctx; (void)p; (void)len; (void)last;
}
static uint32_t on_mix(void *ctx, uint8_t a, int32_t b) {
(void)ctx;
return (uint32_t)a + (uint32_t)(-b);
}
static Worker workers[2];
static void sink(void *ctx, const uint8_t *msg, size_t len, uint64_t addr) {
(void)addr;
Worker *w = (Worker *)ctx;
w->sink_calls++;
w->sink_ctx_ok = pthread_equal(pthread_self(), w->self) ? 1 : 0;
/* Structured parse: fields 1 (code) and 2 (symbol). */
if (len > 0 && msg[0] == 0x01) {
const uint8_t *p = msg + 1, *end = msg + len;
int field = 0;
for (;;) {
const uint8_t *sep = memchr(p, 0x1f, (size_t)(end - p));
const uint8_t *stop = sep != NULL ? sep : end;
if (field == 1) snprintf(w->sink_code, sizeof w->sink_code, "%.*s", (int)(stop - p), (const char *)p);
if (field == 2) snprintf(w->sink_symbol, sizeof w->sink_symbol, "%.*s", (int)(stop - p), (const char *)p);
field++;
if (sep == NULL) break;
p = sep + 1;
}
}
longjmp(w->trap_jmp, 1);
}
static void *worker_a(void *arg) {
Worker *w = (Worker *)arg;
w->self = pthread_self();
w->thread_ok = 1;
cbt_set_panic_sink(sink, w);
cbt_set_callback("emitChunk", (cb_fn)on_chunk, w);
cbt_set_callback("progress", (cb_fn)on_progress, NULL);
cbt_set_callback("note", (cb_fn)on_note, NULL);
cbt_set_callback("mix", (cb_fn)on_mix, NULL);
cbt_init();
arrive(&inited, 2, 1);
stage_wait(1);
w->r1 = cbt_stream(3, 2); /* concurrent with B's stream */
arrive(&streamed, 2, 2);
stage_wait(2);
if (setjmp(w->trap_jmp) == 0) {
cbt_poke_orphan(); /* A never registered 'orphan': SC4025 to A's sink */
printf("UNREACHABLE A\n");
}
stage_set(3);
return NULL;
}
static void *worker_b(void *arg) {
Worker *w = (Worker *)arg;
w->self = pthread_self();
w->thread_ok = 1;
cbt_set_panic_sink(sink, w);
cbt_set_callback("emitChunk", (cb_fn)on_chunk, w);
cbt_set_callback("progress", (cb_fn)on_progress, NULL);
cbt_set_callback("note", (cb_fn)on_note, NULL);
cbt_set_callback("mix", (cb_fn)on_mix, NULL);
cbt_init();
arrive(&inited, 2, 1);
stage_wait(1);
w->r1 = cbt_stream(3, 5); /* concurrent with A's stream */
arrive(&streamed, 2, 2);
stage_wait(3); /* A's instance is poisoned now; B's keeps answering */
w->r2 = cbt_stream(1, 4);
return NULL;
}
static void dump(const char *tag, const Worker *w) {
printf("%s: r1=%g r2=%g chunks=%d thread_ok=%d sink_calls=%d\n",
tag, w->r1, w->r2, w->count, w->thread_ok, w->sink_calls);
for (int i = 0; i < w->count; i++) {
printf(" seq=%u len=%zu bytes=%.*s\n", w->chunks[i].seq, w->chunks[i].len,
(int)w->chunks[i].len, w->chunks[i].bytes);
}
if (w->sink_calls > 0) {
printf(" sink code=%s symbol=%s ctx_ok=%d\n", w->sink_code, w->sink_symbol, w->sink_ctx_ok);
}
}
int main(void) {
pthread_t ta, tb;
pthread_create(&ta, NULL, worker_a, &workers[0]);
pthread_create(&tb, NULL, worker_b, &workers[1]);
pthread_join(ta, NULL);
pthread_join(tb, NULL);
dump("A", &workers[0]);
dump("B", &workers[1]);
return 0;
}