Files
scriptc/tests/library-mode/callbacks/probe.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

220 lines
8.1 KiB
C

/* Host-callback acceptance probe, mode-selected by argv[1]:
* run — the full happy path: registration return codes (0 known,
* -1 unknown/NULL name), a service-shaped export streaming
* chunks through the bytes+u32 channel interleaved with
* computation (contents, order, and thread identity all
* recorded by the callback and asserted after the entry
* returns — the calls are synchronous, on the calling
* thread), string/bool and scalar channels, host RETURNS
* riding i32/u32 back into compiled code, re-registration
* routing to the new context, and finally a NULL-fn clear
* followed by a call: the SC4025 structured trap through
* the sink, exactly once, naming the entry the host called
* orphan — a channel the host never registered: the first call
* traps SC4025 (symbol cb_poke_orphan), the sink longjmps
* out (the conforming survival pattern), and the poisoned
* library aborts the next entry deterministically
* preregister — an unregistered-channel call BEFORE sink registration
* aborts (the funnel's last resort)
*/
#include <pthread.h>
#include <setjmp.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
extern void cb_init(void);
extern void cb_set_panic_sink(void (*fn)(void *, const uint8_t *, size_t, uint64_t), void *ctx);
extern void cb_collect(void);
extern int32_t cb_set_callback(const char *name, void (*fn)(void), void *ctx);
extern double cb_stream(double n, double base);
extern double cb_ask_host(double x);
extern double cb_poke_orphan(void);
typedef void (*cb_fn)(void);
/* ── chunk recording (the bytes+u32 channel) ─────────────────────────── */
typedef struct {
char bytes[8];
size_t len;
uint32_t seq;
} ChunkRec;
typedef struct {
const char *tag;
ChunkRec chunks[16];
int count;
int thread_ok; /* every delivery arrived on the registering thread */
} ChunkLog;
static void on_chunk(void *ctx, const uint8_t *p, size_t len, uint32_t seq) {
ChunkLog *log = (ChunkLog *)ctx;
ChunkRec *rec = &log->chunks[log->count];
/* Borrowed for the duration of the call only: copy out. */
memcpy(rec->bytes, p, len < sizeof rec->bytes ? len : sizeof rec->bytes);
rec->len = len;
rec->seq = seq;
log->count++;
}
static pthread_t main_thread;
static void on_chunk_thread_check(void *ctx, const uint8_t *p, size_t len, uint32_t seq) {
ChunkLog *log = (ChunkLog *)ctx;
on_chunk(ctx, p, len, seq);
if (!pthread_equal(pthread_self(), main_thread)) log->thread_ok = 0;
}
static void dump_chunks(const ChunkLog *log) {
printf("%s: %d chunk(s), thread_ok=%d\n", log->tag, log->count, log->thread_ok);
for (int i = 0; i < log->count; i++) {
printf(" seq=%u len=%zu bytes=%.*s\n", log->chunks[i].seq, log->chunks[i].len,
(int)log->chunks[i].len, log->chunks[i].bytes);
}
}
/* ── the other channels ──────────────────────────────────────────────── */
static char note_log[512];
static void on_note(void *ctx, const uint8_t *p, size_t len, uint8_t last) {
(void)ctx;
size_t used = strlen(note_log);
snprintf(note_log + used, sizeof note_log - used, "[%.*s last=%d]", (int)len, (const char *)p, last != 0);
}
static int32_t on_progress(void *ctx, double done, double total) {
(void)ctx;
return (int32_t)(total - done);
}
static uint32_t on_mix(void *ctx, uint8_t a, int32_t b) {
(void)ctx;
return (uint32_t)a + (uint32_t)(-b);
}
/* ── the panic sink (the traps probe's parse rule) ───────────────────── */
static jmp_buf trap_jmp;
static int sink_calls = 0;
static void show(const uint8_t *msg, size_t len) {
if (len == 0 || msg[0] != 0x01) {
printf("baseline printable=%d text=%.*s", len > 0 && msg[0] >= 0x20, (int)len, (const char *)msg);
return;
}
static const char *names[4] = {"text", "code", "symbol", "remediation"};
const uint8_t *p = msg + 1, *end = msg + len;
int fields = 0;
for (;;) {
const uint8_t *sep = memchr(p, 0x1f, (size_t)(end - p));
const uint8_t *stop = sep != NULL ? sep : end;
if (fields < 4) printf("%s=[%.*s]\n", names[fields], (int)(stop - p), (const char *)p);
fields++;
if (sep == NULL) break;
p = sep + 1;
}
printf("fields=%d text_printable=%d\n", fields, len > 1 && msg[1] >= 0x20);
}
static void sink(void *ctx, const uint8_t *msg, size_t len, uint64_t addr) {
(void)ctx;
sink_calls++;
printf("sink[%d]:\n", sink_calls);
show(msg, len);
printf("addr: %s\n", addr != 0 ? "nonzero" : "zero");
longjmp(trap_jmp, 1);
}
int main(int argc, char **argv) {
const char *mode = argc > 1 ? argv[1] : "run";
main_thread = pthread_self();
static ChunkLog log_a = {.tag = "log_a", .thread_ok = 1};
static ChunkLog log_b = {.tag = "log_b", .thread_ok = 1};
if (strcmp(mode, "preregister") == 0) {
/* No sink registered: the unregistered-channel trap must abort.
* Nothing after the call may print. */
cb_init();
cb_poke_orphan();
printf("UNREACHABLE\n");
return 0;
}
if (strcmp(mode, "orphan") == 0) {
cb_set_panic_sink(sink, NULL);
/* Everything BUT orphan registered: reaching the one unregistered
* channel is the defined trap, whatever else is wired. */
cb_set_callback("emitChunk", (cb_fn)on_chunk_thread_check, &log_a);
cb_set_callback("progress", (cb_fn)on_progress, NULL);
cb_set_callback("note", (cb_fn)on_note, NULL);
cb_set_callback("mix", (cb_fn)on_mix, NULL);
cb_init();
if (setjmp(trap_jmp) == 0) {
cb_poke_orphan();
printf("UNREACHABLE\n");
} else {
printf("survived, sink_calls=%d\n", sink_calls);
fflush(stdout);
cb_stream(1, 1); /* must abort — the library is poisoned */
printf("UNREACHABLE\n");
}
return 0;
}
/* mode "run" */
/* Registration is a pure store, legal before init; return codes are the
* defined refusal surface (-1 unknown or NULL name, 0 stored). */
printf("reg unknown: %d\n", (int)cb_set_callback("nope", (cb_fn)on_chunk, &log_a));
printf("reg null-name: %d\n", (int)cb_set_callback(NULL, (cb_fn)on_chunk, &log_a));
printf("reg emitChunk: %d\n", (int)cb_set_callback("emitChunk", (cb_fn)on_chunk_thread_check, &log_a));
printf("reg progress: %d\n", (int)cb_set_callback("progress", (cb_fn)on_progress, NULL));
printf("reg note: %d\n", (int)cb_set_callback("note", (cb_fn)on_note, NULL));
printf("reg mix: %d\n", (int)cb_set_callback("mix", (cb_fn)on_mix, NULL));
cb_set_panic_sink(sink, NULL);
cb_init();
/* The service-shaped stream: chunks arrive synchronously, in order, on
* this thread, fully delivered by the time the entry returns. */
double r1 = cb_stream(4, 3);
printf("stream(4,3) = %g\n", r1);
dump_chunks(&log_a);
printf("notes: %s\n", note_log);
log_a.count = 0;
note_log[0] = '\0';
double r2 = cb_stream(2, 7);
printf("stream(2,7) = %g\n", r2);
dump_chunks(&log_a);
printf("notes: %s\n", note_log);
/* Host returns ride i32/u32 back into compiled code. */
printf("askHost(5) = %g\n", cb_ask_host(5));
/* Re-registration: latest wins — chunks route to the NEW context. */
printf("reg emitChunk again: %d\n", (int)cb_set_callback("emitChunk", (cb_fn)on_chunk_thread_check, &log_b));
log_a.count = 0;
double r3 = cb_stream(1, 9);
printf("stream(1,9) = %g\n", r3);
printf("log_a after reroute: %d chunk(s)\n", log_a.count);
dump_chunks(&log_b);
/* Buffer results and callback channels coexist: collect between calls. */
cb_collect();
/* NULL clears the channel; the next call through it is the SC4025
* structured trap — exactly once, naming the entry the host called. */
printf("reg emitChunk clear: %d\n", (int)cb_set_callback("emitChunk", NULL, NULL));
if (setjmp(trap_jmp) == 0) {
cb_stream(1, 1);
printf("UNREACHABLE\n");
} else {
printf("survived, sink_calls=%d\n", sink_calls);
}
return 0;
}