Skip to content
Open
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"scripts": {
"build": "make app",
"build:dev": "make dev",
"test": "node test.mjs && node test-contract.mjs",
"test": "node test.mjs && node test-contract.mjs && node test-bugs.mjs",
"test:examples": "node test-examples.mjs",
"prepublishOnly": "npm run build && npm test && npm run test:examples",
"serve": "python3 -m http.server 8080"
Expand Down
164 changes: 125 additions & 39 deletions src/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

#include <emscripten.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
Expand Down Expand Up @@ -368,6 +369,52 @@ EMSCRIPTEN_KEEPALIVE int64_t get_data_byte_size(ray_t* obj) {
* Atom constructors
* ============================================================================ */

/* Index, length and symbol-id parameters that cross the JS boundary are
* declared `ray_jsidx_t` (double), never int64_t.
*
* The build links with `-s WASM_BIGINT=0`, which legalizes every i64 parameter
* into two i32 words (lo, hi). The SDK's cwrap arg lists declare one 'number'
* per C parameter, so an i64 parameter that is not last shifts every parameter
* after it: `vec_set_idx(obj, idx, val)` received `val` as idx's high word and
* 0 as `val`, which tripped the `if (!obj || !val)` guard and returned
* RAY_NULL_OBJ. A trailing i64 appeared to work only because the missing high
* word defaults to 0 -- it still truncated at 2^32.
*
* A double is passed as one f64, needs no legalization, matches the JS Number
* domain exactly, and represents every integer up to 2^53 -- far beyond any
* index reachable in a 4 GiB wasm32 heap. Call sites in JS stay unchanged. */
typedef double ray_jsidx_t;

/* Values arriving as f64 are untrusted: JS (or a raw `_fill_i32_vec(p, d, -1)`
* probe) can hand us NaN, Infinity, a fraction or a negative. Casting those
* straight to int64_t is undefined for NaN/Inf and, for a negative, yields a
* negative count whose byte size wraps to nearly UINT32_MAX once memcpy's
* 32-bit size_t truncates it on wasm32. Narrow only after proving the value
* is finite, integral, non-negative and inside the exact-integer range of a
* double. */
#define RAY_JSIDX_MAX 9007199254740991.0 /* 2^53 - 1 */

static bool jsidx_to_i64(ray_jsidx_t v, int64_t* out) {
if (!isfinite(v) || v < 0.0 || v > RAY_JSIDX_MAX || v != floor(v)) return false;
*out = (int64_t)v;
return true;
}

/* String-length arguments cross the boundary as f64 as well. Every JS call
* site marshals its argument through cwrap's 'string', which hands us a
* NUL-terminated copy, so we can do better than trusting the number: validate
* it, then clamp to the buffer's actual extent. A bogus length then neither
* wraps the size_t cast nor reads past the copy. */
static bool jsidx_to_strlen(const char* s, ray_jsidx_t len_f, size_t* out) {
int64_t n;
if (!s || !jsidx_to_i64(len_f, &n) || (uint64_t)n > (uint64_t)SIZE_MAX) return false;
/* Open-coded strnlen: that is POSIX, and this TU builds with -std=c17. */
size_t max = (size_t)n, i = 0;
while (i < max && s[i]) i++;
*out = i;
return true;
}

EMSCRIPTEN_KEEPALIVE ray_t* init_b8(bool val) { return ray_bool(val); }
EMSCRIPTEN_KEEPALIVE ray_t* init_u8(uint8_t val) { return ray_u8(val); }
EMSCRIPTEN_KEEPALIVE ray_t* init_i16(int16_t val) { return ray_i16(val); }
Expand All @@ -379,12 +426,16 @@ EMSCRIPTEN_KEEPALIVE ray_t* init_date(int64_t days) { return ray_date(days
EMSCRIPTEN_KEEPALIVE ray_t* init_time(int64_t ms) { return ray_time(ms); }
EMSCRIPTEN_KEEPALIVE ray_t* init_timestamp(int64_t ns) { return ray_timestamp(ns); }

EMSCRIPTEN_KEEPALIVE ray_t* init_symbol_str(const char* s, int64_t len) {
return ray_sym(ray_sym_intern(s, (size_t)len));
EMSCRIPTEN_KEEPALIVE ray_t* init_symbol_str(const char* s, ray_jsidx_t len) {
size_t n;
if (!jsidx_to_strlen(s, len, &n)) return ray_error("length", "init_symbol_str: invalid length");
return ray_sym(ray_sym_intern(s, n));
}

EMSCRIPTEN_KEEPALIVE ray_t* init_string_str(const char* s, int64_t len) {
return ray_str(s, (size_t)len);
EMSCRIPTEN_KEEPALIVE ray_t* init_string_str(const char* s, ray_jsidx_t len) {
size_t n;
if (!jsidx_to_strlen(s, len, &n)) return ray_error("length", "init_string_str: invalid length");
return ray_str(s, n);
}

/* ============================================================================
Expand Down Expand Up @@ -419,7 +470,12 @@ EMSCRIPTEN_KEEPALIVE int64_t read_symbol_id(ray_t* obj) {
* thread-local scratch buffer so the returned pointer survives until the
* next call (Emscripten's UTF8ToString already copies on the JS side
* before the next call lands). */
EMSCRIPTEN_KEEPALIVE const char* symbol_to_str(int64_t id) {
EMSCRIPTEN_KEEPALIVE const char* symbol_to_str(ray_jsidx_t id_f) {
int64_t id;
if (!jsidx_to_i64(id_f, &id)) {
g_sym_to_str_buf[0] = '\0';
return g_sym_to_str_buf;
}
ray_t* s = ray_sym_str(id);
if (!s) {
g_sym_to_str_buf[0] = '\0';
Expand All @@ -436,12 +492,13 @@ EMSCRIPTEN_KEEPALIVE const char* symbol_to_str(int64_t id) {
/* Resolve a symbol vector cell through the vector's own domain. CSV/splayed
* columns may use a file-local dictionary whose positions are not runtime
* symbol IDs. */
EMSCRIPTEN_KEEPALIVE const char* symbol_vec_get(ray_t* vec, int64_t idx) {
if (!vec || ray_type(vec) != RAY_SYM || idx < 0 || idx >= ray_len(vec)) {
EMSCRIPTEN_KEEPALIVE const char* symbol_vec_get(ray_t* vec, ray_jsidx_t idx) {
int64_t i;
if (!vec || ray_type(vec) != RAY_SYM || !jsidx_to_i64(idx, &i) || i >= ray_len(vec)) {
g_sym_to_str_buf[0] = '\0';
return g_sym_to_str_buf;
}
ray_t* s = ray_sym_vec_cell(vec, idx); /* borrowed from the domain */
ray_t* s = ray_sym_vec_cell(vec, i); /* borrowed from the domain */
if (!s) {
g_sym_to_str_buf[0] = '\0';
return g_sym_to_str_buf;
Expand All @@ -467,13 +524,14 @@ EMSCRIPTEN_KEEPALIVE int64_t str_atom_len(ray_t* s) {

/* Per-cell read of a RAY_STR vector. Copies into a thread-local scratch
* buffer; lifetime as for symbol_to_str. */
EMSCRIPTEN_KEEPALIVE const char* str_vec_get(ray_t* vec, int64_t idx) {
if (!vec) {
EMSCRIPTEN_KEEPALIVE const char* str_vec_get(ray_t* vec, ray_jsidx_t idx) {
int64_t i;
if (!vec || !jsidx_to_i64(idx, &i)) {
g_str_vec_buf[0] = '\0';
return g_str_vec_buf;
}
size_t n = 0;
const char* p = ray_str_vec_get(vec, idx, &n);
const char* p = ray_str_vec_get(vec, i, &n);
if (!p) {
g_str_vec_buf[0] = '\0';
return g_str_vec_buf;
Expand All @@ -494,13 +552,17 @@ EMSCRIPTEN_KEEPALIVE const char* str_vec_get(ray_t* vec, int64_t idx) {
* typed-array view and have the engine see the elements. We bridge that
* by setting v->len = capacity after allocation; data starts zero-filled
* because mmap'd pages are zero-init. */
EMSCRIPTEN_KEEPALIVE ray_t* init_vector(int8_t type, int64_t len) {
EMSCRIPTEN_KEEPALIVE ray_t* init_vector(int8_t type, ray_jsidx_t len_f) {
int64_t len;
if (!jsidx_to_i64(len_f, &len)) return ray_error("length", "init_vector: invalid length");
ray_t* v = (type == RAY_SYM) ? ray_sym_vec_new(RAY_SYM_W64, len) : ray_vec_new(type, len);
if (v && !RAY_IS_ERR(v)) v->len = len;
return v;
}

EMSCRIPTEN_KEEPALIVE ray_t* init_list(int64_t len) {
EMSCRIPTEN_KEEPALIVE ray_t* init_list(ray_jsidx_t len_f) {
int64_t len;
if (!jsidx_to_i64(len_f, &len)) return ray_error("length", "init_list: invalid length");
ray_t* l = ray_list_new(len);
if (l && !RAY_IS_ERR(l)) {
/* Slots default to RAY_NULL_OBJ so iteration / drop is safe before
Expand Down Expand Up @@ -564,8 +626,10 @@ static ray_t* box_vec_element(int8_t vec_type, const void* p) {
}
}

EMSCRIPTEN_KEEPALIVE ray_t* vec_at_idx(ray_t* obj, int64_t idx) {
if (!obj) return RAY_NULL_OBJ;
EMSCRIPTEN_KEEPALIVE ray_t* vec_at_idx(ray_t* obj, ray_jsidx_t idx_f) {
int64_t idx;
/* An unusable index reads like an out-of-range one: RAY_NULL_OBJ. */
if (!obj || !jsidx_to_i64(idx_f, &idx)) return RAY_NULL_OBJ;
int8_t t = ray_type(obj);
if (t == RAY_LIST) {
ray_t* item = ray_list_get(obj, idx);
Expand All @@ -581,11 +645,14 @@ EMSCRIPTEN_KEEPALIVE ray_t* vec_at_idx(ray_t* obj, int64_t idx) {
return box_vec_element(t, p);
}

EMSCRIPTEN_KEEPALIVE ray_t* vec_set_idx(ray_t* obj, int64_t idx, ray_t* val) {
EMSCRIPTEN_KEEPALIVE ray_t* vec_set_idx(ray_t* obj, ray_jsidx_t idx_f, ray_t* val) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] This now makes the rf.list()/rf.dict() set path reachable, but the list branch below still calls ray_retain(val) before ray_list_set, which retains the item itself. The same double retain exists in vec_push and vec_insert. I reproduced rc 1 -> 3; after dropping both the list and the caller's atom, one reference remained permanently. Please remove the redundant wrapper retains (and release SDK-created temporary wrappers after transferring ownership), otherwise normal list/dict construction leaks every element.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed all of them; details below.

The root cause

The engine's list API is borrow semantics — ray_list_append / _set / _insert_at retain the item themselves (src/vec/list.c:107,147), leaving the caller's ref alone. main.c retained as well, so an element went 1 → 3 exactly as you measured: caller's ref, our retain, the list's retain. Dropping the list and the caller's handle returned two, stranding one permanently.

It was dormant before this PR: the mangled i64 index made val arrive as 0, so the list branch was unreachable from rf.list()/rf.dict(). Legalizing the parameter is what exposed it.

Worth noting this isn't a blanket rule — init_dict is correct to retain, because ray_dict_new documents "consumes one ref each." The contract is per-function, so I audited every ray_retain in main.c against its callee rather than removing them by pattern.

C fixes (src/main.c)

The three you flagged:

  • vec_set_idx (:604), vec_push (:622), vec_insert (:638) — redundant retain removed from the RAY_LIST branch.

Two more with the identical bug, found by that audit:

  • init_table (:727)ray_table_add_col documents "Retains col_vec internally so the caller keeps its own ref," so the retain stranded one ref per column on every rf.table(). The header comment above it asserted the opposite of the real contract, which is likely how this got in; corrected.
  • table_vals (:761) — retained a borrowed column and then passed it to ray_list_append, which retains again: one stranded ref per column on every .values() call, including the ones toJS()/toRows() make internally.

Checked and left alone as already correct: vec_at_idx, dict_keys, dict_vals, dict_get (ray_dict_get is documented as returning an owned ref), table_col, init_dict.

SDK fixes (src/rayforce.sdk.js)

Temporary wrappers, per your second point. List.set/push now drop the wrapper when the SDK minted it for a raw JS value, and leave caller-supplied RayObjects alone. Call sites that pre-converted (dict(), _arrayToVector's mixed path, Table.insert) now pass raw values so List.set owns the temporaries; dict(), table(), and Table.insert drop the intermediates once ownership has transferred.

rf.set() (:618) leaked both its symbol wrapper and, for non-RayObject values, its value wrapper. ray_env_set retains into the binding (env.c:356,377), so both were ours to drop.

Discarded owned handles. These readers each hand back an owned ref; the calling code read one field off it and dropped it on the floor:

Site Leaked per call
Dict.get() (:1354) the symbol minted for a string key
Dict.has() (:1372) the value ray_dict_get returns
Dict.toJS() (:1385) / iterator (:1414) keys + vals + one per value
List.toJS() (:1221) one per element
Table.columnNames() (:1445) the cols vector
Table.toJS() (:1535) vals + one per column
Table.toRows() (:1560) one column ref per cell

toRows() was calling this.col(name) inside the row loop; hoisting it out fixes the leak and stops re-resolving each name rows × columns times. Iterators drop their containers in a finally, so abandoning the loop early still releases. Yielded elements remain the consumer's to drop — existing contract, now documented rather than changed.

Vector, StrVector, and RayString are clean — they read through typed arrays or return strings without minting wrappers.

One of these wasn't a leak — it was breaking queries

SelectQuery.execute() bound the table to a fresh __rfq_N global per call and never unbound it. The old comment called this "leave the binding for the caller to clean up," but nothing ever did. The global env is a fixed 1024-slot table (env.c:90), so this pinned every queried table in memory and then failed outright:

query #0 ok, rows=3
query #300 ok, rows=3
query #600 ok, rows=3
query #695 FAILED: name: '__rfq_696' undefined

About 700 queries into a session and the builder stops working. The misleading error is the second half: ray_env_set returned OOM, but rf.set() discarded global_set's return — which on failure is an error block — so a failed bind surfaced later as a nonsense "name undefined" from whatever read it next.

Fixed by adding rf.unset() (global_set with a NULL value hits ray_env_set's documented delete path) and unbinding in a finally; set() now throws on a failed bind instead of swallowing it. execute()'s body moved to _run() so the finally wraps the whole thing.

Two behavior changes worth your attention

  1. __rfq_N bindings no longer linger after a query. They were never documented, so nothing should depend on inspecting them — flagging it since it's observable.
  2. rf.set() now throws on a failed bind where it previously returned silently.

rf.unset() is a new public method, so it also needed a declaration in rayforce.sdk.d.tstest-contract.mjs caught that omission on its own.

Tests

Added bug3 (double retain) and bug4 (discarded handles) sections to test-bugs.mjs38/38 passing.

I checked every new test is actually diagnostic by reverting the fix in the built output and re-running: all of them fail pre-fix and pass post-fix.

That verification mattered, because my first attempt at the bug4 tests passed against the unfixed SDK. A heap probe is the wrong instrument for those: the readers hand back refs to objects that already exist, so a leaked handle strands a refcount without allocating anything. Those tests now probe the refcount of the underlying object (take a handle, read refCount, drop it), and heap-growth checks are kept only for paths that genuinely allocate (table_keys/table_vals build fresh objects). The bug3 heap tests needed similar calibration — 20k cycles showed nothing even while leaking, since the engine grows the heap in large steps; at 400k cycles the unfixed build goes 68 MB → 320 MB. That stronger version is what caught the residual init_table leak after the first round of fixes.

Full run after a clean make wasm:

  • npm run test:bugs — 38/38
  • npm test — smoke + contract pass
  • npm run test:examples — 15 expression examples, 11 interactive demos, CDN example pass in Chrome

int64_t idx;
if (!obj || !val) return RAY_NULL_OBJ;
if (!jsidx_to_i64(idx_f, &idx)) return ray_error("index", "vec_set_idx: invalid index");
int8_t t = ray_type(obj);
if (t == RAY_LIST) {
ray_retain(val);
/* ray_list_set retains `val` itself — the caller's ref stays its own.
* Retaining here too would strand one ref per element. */
return ray_list_set(obj, idx, val);
}
if (t == RAY_STR) {
Expand All @@ -601,7 +668,7 @@ EMSCRIPTEN_KEEPALIVE ray_t* vec_push(ray_t* obj, ray_t* val) {
if (!obj || !val) return RAY_NULL_OBJ;
int8_t t = ray_type(obj);
if (t == RAY_LIST) {
ray_retain(val);
/* ray_list_append retains `val` itself — see vec_set_idx. */
return ray_list_append(obj, val);
}
if (t == RAY_STR) {
Expand All @@ -613,11 +680,13 @@ EMSCRIPTEN_KEEPALIVE ray_t* vec_push(ray_t* obj, ray_t* val) {
return ray_vec_append(obj, sp);
}

EMSCRIPTEN_KEEPALIVE ray_t* vec_insert(ray_t* obj, int64_t idx, ray_t* val) {
EMSCRIPTEN_KEEPALIVE ray_t* vec_insert(ray_t* obj, ray_jsidx_t idx_f, ray_t* val) {
int64_t idx;
if (!obj || !val) return RAY_NULL_OBJ;
if (!jsidx_to_i64(idx_f, &idx)) return ray_error("index", "vec_insert: invalid index");
int8_t t = ray_type(obj);
if (t == RAY_LIST) {
ray_retain(val);
/* ray_list_insert_at retains `val` itself — see vec_set_idx. */
return ray_list_insert_at(obj, idx, val);
}
if (t == RAY_STR) {
Expand All @@ -637,22 +706,28 @@ EMSCRIPTEN_KEEPALIVE ray_t* vec_insert(ray_t* obj, int64_t idx, ray_t* val) {
* the JS wrappers must build vectors fresh before filling.
* ============================================================================ */

EMSCRIPTEN_KEEPALIVE void fill_i64_vec(ray_t* obj, int64_t* data, int64_t len) {
if (!obj || !data || obj->type != RAY_I64) return;
EMSCRIPTEN_KEEPALIVE void fill_i64_vec(ray_t* obj, int64_t* data, ray_jsidx_t len_f) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Please validate len_f before narrowing in all three fill_* exports. With this new f64 ABI, len_f == -1 becomes copy_len == -1, and the byte count passed to memcpy wraps to nearly UINT32_MAX on wasm32. A raw _fill_i32_vec(..., -1) probe reached this path; depending on the Emscripten runtime this can overwrite linear memory or trap. Require a finite, integral, non-negative length before calculating the copy size.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed.

The narrowing

copy_len is int64_t, sizeof(T) is a 32-bit size_t on wasm32. The usual arithmetic conversions make copy_len * sizeof(int64_t) an int64_t of -8, which then truncates to 0xFFFFFFF8 in memcpy's size_t parameter — a ~4 GiB copy from a 64-byte buffer. NaN and Infinity are worse in kind: (int64_t) narrowing of either is undefined behavior, not merely a wrong number.

The fix

Two validators next to the ray_jsidx_t typedef (src/main.c:395-415):

static bool jsidx_to_i64(ray_jsidx_t v, int64_t* out) {
  if (!isfinite(v) || v < 0.0 || v > RAY_JSIDX_MAX || v != floor(v)) return false;
  *out = (int64_t)v;
  return true;
}

RAY_JSIDX_MAX is 2^53−1, the exact-integer ceiling of a double — past that the value being narrowed isn't the value JS meant anyway. The three fill_* exports (:709, :717, :725) now validate before clamping, cast to size_t after, and bail on copy_len <= 0 so a corrupt ray_len(obj) can't produce a negative count either.

Rest of the sweep

The other thirteen sites were the same latent bug. init_vector(type, NaN) was UB, and symbol_to_str(NaN) had already bitten us once — there's a comment at rayforce.sdk.js:1397 about a Number() wrapper producing NaN and the symbol coming back empty.

Each one now fails the way that function already fails for bad input, rather than introducing a new convention:

Site On an invalid value
init_symbol_str (:429), init_string_str (:435) ray_error("length", …)
init_vector (:555), init_list (:563) ray_error("length", …)
vec_set_idx (:648), vec_insert (:683) ray_error("index", …)
vec_at_idx (:629), table_row (:834), table_col (:823) RAY_NULL_OBJ — reads like out-of-range
symbol_to_str (:473), symbol_vec_get (:495), str_vec_get (:527) "" — their existing empty-buffer path
intern_symbol (:898) -1; interned IDs are non-negative slots

Three notes on the details:

String lengths clamp to the buffer, not just the number. All four string sites are reached through cwrap's 'string' marshaller, which hands us a NUL-terminated copy — so the real extent is knowable and jsidx_to_strlen (:408) validates the number and scans to the NUL. init_string_str(p, 4096) on "hi" now yields "hi" instead of reading 4 KB past the allocation. That's a stronger guarantee than a range check alone, and it costs one pass over a string we're about to copy anyway.

strnlen is unusable here — this TU builds -std=c17, where POSIX names are hidden, so it's open-coded. Worth knowing before someone reaches for it again; my first pass used it and only the real build caught it, since -fsyntax-only without -std=c17 accepts it.

Negative indices still work. The SDK normalizes and bounds-checks them in JS before calling in (rayforce.sdk.js:1113, :1139, :1214, :1243, :1258), so the C layer never legitimately sees one — list.set(-1, …) is unaffected, and its existing test still passes. symbol_vec_get's hand-rolled i < 0 check is now subsumed by the validator.

Tests

New bug5 section in test-bugs.mjs, 11 checks, each looping the full hostile set [-1, NaN, Infinity, -Infinity, 2.5, -0.5, 2^53+2] against the raw exports — which is where these bite, since the SDK's own bounds checks hide them from the JS API. 49/49 passing.

Verified diagnostic the same way as the last round: reverting src/main.c and rebuilding fails 9 of the 11.

The two that pass pre-fix are marked control:. One is the intended happy-path pin. The other is the heap canary, and it's worth being precise about why it passes: on this runtime the wrapped copy traps rather than scribbling, so the abort happens before any damage — which is one of the two outcomes you flagged. A runtime that clamps instead of trapping would corrupt linear memory there rather than throwing. The comment on that check says so, so nobody later reads it as proof the overwrite was harmless.

Ran locally after a clean make wasm: npm run test:bugs 49/49, npm test (smoke + contract) passing. I did not re-run npm run test:examples — that's the browser suite and nothing in this change touches those paths, but say the word if you want it before merge.

Separate defect this turned up — not fixed here

While auditing the parameters I checked the ones still typed int64_t, and four of them have the original bug 1 in full:

rf.i64(1234567890123n)             -> 1912276171          // exactly value mod 2^32
rf.timestamp(800000000000000000n)  -> 1999-12-31T23:59:59.669Z

init_i64, init_date, init_time and init_timestamp each take a bare int64_t. Under WASM_BIGINT=0 that legalizes into two i32 words, and the SDK cwraps each as a single 'number' (rayforce.sdk.js:136-141), so the high word is always zero. Anything ≥ 2^31 truncates — and since timestamps are nanoseconds since 2000 (~8×10^17), every timestamp constructed through the SDK is currently wrong.

I left it alone deliberately, because the ray_jsidx_t trick can't fix it: a double carries integers exactly only to 2^53 ≈ 9×10^15, two orders of magnitude short of the ns range. The real options are an explicit lo/hi parameter pair, passing the value as a string, or turning on WASM_BIGINT=1 — each with a different blast radius on the SDK surface, and none of them a drive-by change to a review fix.

int64_t len;
if (!obj || !data || obj->type != RAY_I64 || !jsidx_to_i64(len_f, &len)) return;
int64_t copy_len = len < ray_len(obj) ? len : ray_len(obj);
memcpy(ray_data(obj), data, copy_len * sizeof(int64_t));
if (copy_len <= 0) return;
memcpy(ray_data(obj), data, (size_t)copy_len * sizeof(int64_t));
}

EMSCRIPTEN_KEEPALIVE void fill_i32_vec(ray_t* obj, int32_t* data, int64_t len) {
if (!obj || !data || obj->type != RAY_I32) return;
EMSCRIPTEN_KEEPALIVE void fill_i32_vec(ray_t* obj, int32_t* data, ray_jsidx_t len_f) {
int64_t len;
if (!obj || !data || obj->type != RAY_I32 || !jsidx_to_i64(len_f, &len)) return;
int64_t copy_len = len < ray_len(obj) ? len : ray_len(obj);
memcpy(ray_data(obj), data, copy_len * sizeof(int32_t));
if (copy_len <= 0) return;
memcpy(ray_data(obj), data, (size_t)copy_len * sizeof(int32_t));
}

EMSCRIPTEN_KEEPALIVE void fill_f64_vec(ray_t* obj, double* data, int64_t len) {
if (!obj || !data || obj->type != RAY_F64) return;
EMSCRIPTEN_KEEPALIVE void fill_f64_vec(ray_t* obj, double* data, ray_jsidx_t len_f) {
int64_t len;
if (!obj || !data || obj->type != RAY_F64 || !jsidx_to_i64(len_f, &len)) return;
int64_t copy_len = len < ray_len(obj) ? len : ray_len(obj);
memcpy(ray_data(obj), data, copy_len * sizeof(double));
if (copy_len <= 0) return;
memcpy(ray_data(obj), data, (size_t)copy_len * sizeof(double));
}

/* ============================================================================
Expand Down Expand Up @@ -694,8 +769,8 @@ EMSCRIPTEN_KEEPALIVE ray_t* dict_get(ray_t* d, ray_t* key) {
*
* v2 builds tables column-by-column; init_table replays the v1 contract by
* iterating the JS-supplied (sym-vec, list-of-cols) and chaining add_col.
* Each col is retained once because ray_table_add_col → ray_list_append
* consumes the ref.
* ray_table_add_col retains each col itself, so the caller's refs (held by
* the JS wrappers) survive the call untouched.
* ============================================================================ */

EMSCRIPTEN_KEEPALIVE ray_t* init_table(ray_t* keys, ray_t* vals) {
Expand All @@ -711,9 +786,10 @@ EMSCRIPTEN_KEEPALIVE ray_t* init_table(ray_t* keys, ray_t* vals) {

int64_t* key_ids = (int64_t*)ray_data(keys);
for (int64_t i = 0; i < n; i++) {
ray_t* col = ray_list_get(vals, i);
ray_t* col = ray_list_get(vals, i); /* borrowed */
if (!col) continue;
ray_retain(col);
/* ray_table_add_col retains col_vec itself; an extra retain here would
* strand one ref per column. */
tbl = ray_table_add_col(tbl, key_ids[i], col);
if (RAY_IS_ERR(tbl)) return tbl;
}
Expand All @@ -737,23 +813,30 @@ EMSCRIPTEN_KEEPALIVE ray_t* table_vals(ray_t* t) {
ray_t* lst = ray_list_new(n);
for (int64_t i = 0; i < n; i++) {
ray_t* col = ray_table_get_col_idx(t, i); /* borrowed */
if (col) ray_retain(col);
/* ray_list_append takes the ref the returned list needs; retaining here
* too would strand one per column on every .values() call. */
lst = ray_list_append(lst, col ? col : RAY_NULL_OBJ);
}
return lst;
}

EMSCRIPTEN_KEEPALIVE ray_t* table_col(ray_t* t, const char* name, int64_t len) {
EMSCRIPTEN_KEEPALIVE ray_t* table_col(ray_t* t, const char* name, ray_jsidx_t len) {
size_t n;
if (!t || ray_type(t) != RAY_TABLE) return RAY_NULL_OBJ;
int64_t id = ray_sym_intern(name, (size_t)len);
if (!jsidx_to_strlen(name, len, &n)) return RAY_NULL_OBJ;
int64_t id = ray_sym_intern(name, n);
ray_t* col = ray_table_get_col(t, id); /* borrowed */
if (col) ray_retain(col);
return col ? col : RAY_NULL_OBJ;
}

/* Build a {col_name: col[idx]} dict for one row. */
EMSCRIPTEN_KEEPALIVE ray_t* table_row(ray_t* t, int64_t idx) {
EMSCRIPTEN_KEEPALIVE ray_t* table_row(ray_t* t, ray_jsidx_t idx) {
int64_t row;
if (!t || ray_type(t) != RAY_TABLE) return RAY_NULL_OBJ;
/* Checked here rather than left to vec_at_idx, so a bad index fails as a
* row instead of yielding a dict of nulls. */
if (!jsidx_to_i64(idx, &row)) return RAY_NULL_OBJ;
int64_t n = ray_table_ncols(t);
ray_t* keys = ray_sym_vec_new(RAY_SYM_W64, n);
ray_t* vals = ray_list_new(n);
Expand Down Expand Up @@ -811,8 +894,11 @@ EMSCRIPTEN_KEEPALIVE ray_t* table_upsert(ray_t* t, ray_t* match_count, ray_t* da
* Symbol / global env / type name
* ============================================================================ */

EMSCRIPTEN_KEEPALIVE int64_t intern_symbol(const char* s, int64_t len) {
return ray_sym_intern(s, (size_t)len);
/* Returns -1 for an invalid length; interned IDs are non-negative slots. */
EMSCRIPTEN_KEEPALIVE int64_t intern_symbol(const char* s, ray_jsidx_t len) {
size_t n;
if (!jsidx_to_strlen(s, len, &n)) return -1;
return ray_sym_intern(s, n);
}

EMSCRIPTEN_KEEPALIVE ray_t* global_set(ray_t* name, ray_t* val) {
Expand Down
8 changes: 7 additions & 1 deletion src/rayforce.sdk.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,13 @@ export declare class RayforceSDK {
* Set a global variable
*/
set(name: string, value: RayObject | any): void;


/**
* Delete a global binding, releasing the engine's ref to its value.
* Deleting an absent name is a no-op.
*/
unset(name: string): void;

/**
* Get a global variable
*/
Expand Down
Loading