Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/eigenscript.c
Original file line number Diff line number Diff line change
Expand Up @@ -1644,6 +1644,14 @@ static inline void env_shared_unlock(const Env *e) {
if (env_mt_shared(e)) pthread_mutex_unlock(&g_module_env_lock);
}

/* #660: exported for the SIGUSR1 observer dump (vm.c) — the dump's
* module-scope walk holds the existing #607 module-env lock under MT
* (no new locking scheme; a no-op single-threaded via the env_mt_shared
* gate). Only the module env is structurally mutated cross-thread, and
* only by the main thread; the dump's frame/loop envs are thread-local. */
void env_dump_lock(Env *e) { env_shared_lock(e); }
void env_dump_unlock(Env *e) { env_shared_unlock(e); }

/* Park a grown-out block on the env; freed at park/destroy time. */
static void env_retire_block(Env *env, void *block) {
if (!block) return;
Expand Down
11 changes: 11 additions & 0 deletions src/eigenscript.h
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,17 @@ void observer_slot_reset(struct Env *e);
* run the same halting as the interpreter/JIT. (vm.c) */
int eigs_loop_stall_step(struct Env *e);
int eigs_loop_cap_step(struct Env *e);
/* #660 SIGUSR1 live observer dump: `kill -USR1 <pid>` prints one row per
* live binding (module scope + the dumping thread's live frame) to stderr at
* the next loop safepoint. The handler (installed by the CLI, SA_RESTART)
* only sets the flag — no allocation, no I/O; the VM's loop-cap safepoints
* test-and-clear it and dump from normal thread context. (vm.c) */
extern volatile sig_atomic_t g_eigs_sigusr1_pending;
void eigs_sigusr1_handler(int sig);
/* #660: hold/release the existing #607 module-env lock for the dump's
* module-scope walk under MT; no-op single-threaded. (eigenscript.c) */
void env_dump_lock(struct Env *e);
void env_dump_unlock(struct Env *e);
/* Implemented in vm.c: drops the last-observed-slot tracker if it points at e
* (called from observer_slot_reset so a torn-down env can't be read stale). */
void vm_obs_slot_dropped(struct Env *e);
Expand Down
13 changes: 13 additions & 0 deletions src/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,19 @@ int main(int argc, char **argv) {
eigs_thread_attach(eigs_st);
set_exe_dir(argc > 0 ? argv[0] : NULL);

/* #660: SIGUSR1 live observer dump — an outside process can ask a
* long-running program whether it is progressing. SA_RESTART so an
* interrupted blocking syscall (channel recv, proc read) resumes; the
* handler only raises a flag consumed at the next loop safepoint. */
{
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = eigs_sigusr1_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGUSR1, &sa, NULL);
}

/* --lint flag (optionally --lint --json for machine-readable output;
* --json may appear before or after the path). */
if (argc >= 2 && strcmp(argv[1], "--lint") == 0) {
Expand Down
176 changes: 176 additions & 0 deletions src/vm.c
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,178 @@ void vm_obs_slot_dropped(Env *e) {
if (g_last_obs_slot_env == e) { g_last_obs_slot_env = NULL; g_last_obs_slot_idx = -1; }
}

/* ---- #660: SIGUSR1 live observer dump ----
* An outside process asks a long-running program whether it is progressing:
* `kill -USR1 <pid>` prints one row per live binding to stderr:
* name | value | when=<assign count> | entropy=<H> | dH=<dH> | trajectory
* The signal handler (installed by the CLI in main.c, SA_RESTART) ONLY sets
* g_eigs_sigusr1_pending — no allocation, no I/O. The dump runs at the next
* loop safepoint (the OP_LOOP_STALL_CHECK / OP_LOOP_CAP_CHECK per-iteration
* check and the shared eigs_loop_*_step cores the JIT/AOT call), in normal
* thread context, on whichever thread sees the flag first. Cost when idle is
* one flag test per loop iteration, mirroring eigs_loop_cap_step's profile. */
volatile sig_atomic_t g_eigs_sigusr1_pending = 0;

void eigs_sigusr1_handler(int sig) {
(void)sig;
g_eigs_sigusr1_pending = 1;
}

/* Number formatting mirrors value_to_string's VAL_NUM case: exact integers
* print bare, anything else shortest round-trip %.15g..%.17g. The range
* check runs BEFORE the long long cast — casting an out-of-range double is
* UB and this path sees arbitrary user values under UBSan. */
static void obs_dump_num(double n, char *buf, size_t nbuf) {
if (fabs(n) < 9007199254740992.0 && n == (long long)n) {
snprintf(buf, nbuf, "%lld", (long long)n);
} else {
for (int prec = 15; prec <= 17; prec++) {
snprintf(buf, nbuf, "%.*g", prec, n);
double back = strtod(buf, NULL);
if (memcmp(&back, &n, sizeof back) == 0) break;
}
}
}

/* Compact, bounded rendering of a slot's value: numbers inline, strings
* truncated to 100 chars with control characters blanked (one row per
* binding must stay one line), containers as <type:count> summaries — no
* deep walks (a 65k-element list must not dump a megabyte, and walking a
* shared container's items while another thread mutates them is the #607
* out-of-scope slot-value race class; a summary reads only the header
* words). No allocation. */
static void obs_dump_value(EigsSlot s, char *buf, size_t nbuf) {
if (slot_is_num(s)) { obs_dump_num(s.d, buf, nbuf); return; }
if (slot_is_null(s)) { snprintf(buf, nbuf, "null"); return; }
if (slot_is_bool(s)) { snprintf(buf, nbuf, "%s", slot_as_bool(s) ? "true" : "false"); return; }
if (slot_is_ptr(s)) {
Value *v = slot_as_ptr(s);
switch (v->type) {
case VAL_NUM: obs_dump_num(v->data.num, buf, nbuf); break;
case VAL_STR: {
size_t o = 0, i;
buf[o++] = '"';
for (i = 0; i < 100 && v->data.str[i] && o + 2 < nbuf; i++) {
unsigned char c = (unsigned char)v->data.str[i];
buf[o++] = (c >= 32 && c < 127) ? (char)c : ' ';
}
if (v->data.str[i] && o + 5 <= nbuf) { memcpy(buf + o, "...", 3); o += 3; }
if (o + 2 > nbuf) o = nbuf - 2;
buf[o++] = '"'; buf[o] = 0;
break;
}
case VAL_LIST: snprintf(buf, nbuf, "<list:%d>", v->data.list.count); break;
case VAL_DICT: snprintf(buf, nbuf, "<dict:%d>", v->data.dict.count); break;
case VAL_FN: snprintf(buf, nbuf, "<fn %s>", v->data.fn.name); break;
case VAL_BUILTIN: snprintf(buf, nbuf, "<builtin>"); break;
case VAL_BUFFER: snprintf(buf, nbuf, "<buffer:%d>", v->data.buffer.count); break;
case VAL_TEXT_BUILDER:
snprintf(buf, nbuf, "<text-builder:%zu>", v->data.text_builder.len); break;
case VAL_JSON_RAW: snprintf(buf, nbuf, "<json-raw>"); break;
default: snprintf(buf, nbuf, "null"); break;
}
return;
}
snprintf(buf, nbuf, "?"); /* sentinel / reserved tag — not a binding value */
}

/* One scope's rows. `shared` holds the existing #607 module-env lock for the
* walk (a no-op single-threaded): only the module env is structurally
* mutated cross-thread, and only by the main thread; frame/loop envs are
* thread-local and need no lock. Skips runtime-internal `__*` bindings and
* builtins (stdlib noise — every registered builtin lives in the module env).
* `name_chunk` resolves call-env slots reserved by env_reserve_slots, which
* are addressed purely by slot index and carry NULL names in the env — their
* names live in the frame chunk's local_names (OP_SET_LOCAL's own name
* source, e.g. the trace hook). Every row carries its assign count: when=1
* (a fresh per-call binding) must read differently from a settled long-lived
* one, and a bare trajectory word with no sample count behind it is the
* silent-tolerance failure this feature exists to avoid. An observed slot's
* word comes from the same observer_slot_report `report of` uses; a slot
* with no observation yet says "unobserved" rather than borrowing a
* window-less verdict. */
static void obs_dump_scope(const char *scope, Env *e, int shared,
const EigsChunk *name_chunk) {
if (shared) env_dump_lock(e);
fprintf(stderr, "# scope=%s\n", scope);
char vbuf[160];
for (int i = 0; i < e->count; i++) {
const char *name = e->names[i];
if (!name && name_chunk && name_chunk->local_names &&
i < name_chunk->local_count)
name = name_chunk->local_names[i];
if (!name) continue;
if (name[0] == '_' && name[1] == '_') continue;
EigsSlot s = e->values[i];
if (slot_is_ptr(s)) {
Value *v = slot_as_ptr(s);
if (v && v->type == VAL_BUILTIN) continue;
}
obs_dump_value(s, vbuf, sizeof(vbuf));
/* `when` merges the two assignment counters, because neither alone
* is complete. env->assign_counts is bumped only by the env write
* paths (env_set*, param binding) — the compiler keeps it consistent
* for `when is x` by forcing interrogated names onto those paths
* (compiler.c "interrogated"), but a plain fn-local's SET_LOCAL fast
* path never touches it (reads 0 after thousands of assignments).
* The observer slot's obs_age IS bumped once per compiled assignment
* (OBSERVE_ASSIGN_LOCAL / OBSERVE_NAME_POST), and recycled call envs
* reset both counters (vm_park_call_env), preserving per-call
* semantics: max() of the two is the true assignment count. */
const ObserverSlot *os = (e->obs && i < e->obs_cap) ? &e->obs[i] : NULL;
long when = e->assign_counts ? e->assign_counts[i] : -1;
if (os && os->used && os->obs_age > when) when = os->obs_age;
char whenbuf[16];
if (when >= 0) snprintf(whenbuf, sizeof(whenbuf), "%ld", when);
else snprintf(whenbuf, sizeof(whenbuf), "?");
if (os && os->used) {
fprintf(stderr, "%s | %s | when=%s | entropy=%.6g | dH=%.6g | %s\n",
name, vbuf, whenbuf, os->entropy, os->dH, observer_slot_report(os));
} else {
fprintf(stderr, "%s | %s | when=%s | entropy=- | dH=- | unobserved\n",
name, vbuf, whenbuf);
}
}
if (shared) env_dump_unlock(e);
}

/* Dump module-scope bindings plus the current live call frame's bindings.
* The module env is the root of the current env chain (post-#373 a worker
* fn's env still chains back to it); the live frame is the dumping thread's
* innermost VM frame (fn_env), plus its current loop-child env when that
* differs. Under MT the module walk holds the existing #607 lock — no new
* locking scheme; frame/loop envs are this thread's own. */
static void eigs_observer_dump(Env *leaf) {
if (!leaf) return;
Env *root = leaf;
while (root->parent) root = root->parent;
fprintf(stderr, "# eigenscript observer dump (SIGUSR1) — module scope + dumping thread's live frame\n");
obs_dump_scope("module", root, 1, NULL);
Env *fn_env = NULL;
const EigsChunk *fn_chunk = NULL;
if (eigs_current && eigs_current->vm && g_vm.frame_count > 0) {
fn_env = g_vm.frames[g_vm.frame_count - 1].fn_env;
fn_chunk = g_vm.frames[g_vm.frame_count - 1].chunk;
}
if (fn_env && fn_env != root)
obs_dump_scope("frame", fn_env, 0, fn_chunk);
/* The leaf differs from fn_env when the safepoint caught a loop-child
* env (LOOP_ENV_FRESH); its bindings are hash-named, no chunk needed. */
if (leaf != root && leaf != fn_env)
obs_dump_scope("loop", leaf, 0, NULL);
fprintf(stderr, "# end dump\n");
fflush(stderr);
}

/* Safepoint, tested once per loop iteration alongside the loop-cap check.
* The first thread to see the flag set performs the dump (atomic
* test-and-clear, so MT dumps exactly once per signal). */
static inline void eigs_observe_safepoint(Env *e) {
if (__builtin_expect(g_eigs_sigusr1_pending != 0, 0) &&
__sync_bool_compare_and_swap(&g_eigs_sigusr1_pending, 1, 0))
eigs_observer_dump(e);
}

/* Classify an observer slot's trajectory by predicate kind (0..5), matching the
* bare OP_PREDICATE dispatch. Shared by the named OP_PREDICATE_SLOT/NAME ops,
* which read a SPECIFIC binding's slot instead of the global last-observed one. */
Expand Down Expand Up @@ -1417,6 +1589,7 @@ static void loop_iter_store(Env *env, double n) {
* eigenscript.h. The jit_helper_* wrappers below feed them the current frame's
* env, so interpreter, JIT, and AOT share one implementation (no drift). */
int eigs_loop_stall_step(Env *e) {
eigs_observe_safepoint(e); /* #660 */
g_loop_iterations++;
int should_exit = 0;
if (g_unobserved_depth == 0) {
Expand Down Expand Up @@ -1450,6 +1623,7 @@ int eigs_loop_stall_step(Env *e) {
}

int eigs_loop_cap_step(Env *e) {
eigs_observe_safepoint(e); /* #660 */
g_loop_iterations++;
int should_exit = 0;
if (g_loop_iterations >= (g_sandbox_loop_max ? g_sandbox_loop_max : 100000000)) {
Expand Down Expand Up @@ -4725,6 +4899,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) {

CASE(LOOP_STALL_CHECK): {
uint16_t exit_offset = read_u16(ip); ip += 2;
eigs_observe_safepoint(frame->env); /* #660: SIGUSR1 dump at the per-iteration safepoint */
g_loop_iterations++;
int should_exit = 0;
if (g_unobserved_depth == 0) {
Expand Down Expand Up @@ -4767,6 +4942,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) {
* block; both engines key off the opcode, set at compile time, so the
* interpreter and JIT can never disagree on the classification. */
uint16_t exit_offset = read_u16(ip); ip += 2;
eigs_observe_safepoint(frame->env); /* #660: SIGUSR1 dump at the per-iteration safepoint */
g_loop_iterations++;
int should_exit = 0;
if (g_loop_iterations >= (g_sandbox_loop_max ? g_sandbox_loop_max : 100000000)) {
Expand Down
23 changes: 23 additions & 0 deletions tests/run_all_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3456,6 +3456,29 @@ else
fi
echo ""

# [99d] SIGUSR1 live observer dump (#660) — the only suite check that drives
# a signal against a live process from OUTSIDE: spawn a long-running
# program, kill -USR1, assert the stderr dump's row shape (incl. `when=`
# assign counts distinguishing a fresh when=1 per-call binding from a
# settled one) and that the program completes normally afterward — once
# single-threaded, once with a spawned task live. Synchronization inside is
# on observable state (READY/DONE markers), never sleeps — see
# tests/test_sigusr1_dump.sh.
echo "[99d] SIGUSR1 observer dump (#660)"
OD_OUTPUT=$(bash "$TESTS_DIR/test_sigusr1_dump.sh" 2>&1)
OD_PASS=$(echo "$OD_OUTPUT" | grep -c "PASS:" || true)
OD_FAIL=$(echo "$OD_OUTPUT" | grep -c "FAIL:" || true)
TOTAL=$((TOTAL + OD_PASS + OD_FAIL))
PASS=$((PASS + OD_PASS))
FAIL=$((FAIL + OD_FAIL))
if [ "$OD_FAIL" -gt 0 ]; then
echo " FAIL: $OD_FAIL SIGUSR1 dump check(s) failed"
echo "$OD_OUTPUT" | grep "FAIL:" | head -5
else
echo " PASS: all $OD_PASS SIGUSR1 dump checks"
fi
echo ""

echo "============================================"
echo " RESULTS: $PASS/$TOTAL passed, $FAIL failed"
if [ "$LEAKED" -gt 0 ]; then
Expand Down
Loading
Loading