Skip to content

fix(sdk): correct i64 parameter legalization and dict key decoding - #5

Open
kurumpa wants to merge 2 commits into
RayforceDB:masterfrom
kurumpa:master
Open

kurumpa wants to merge 2 commits into
RayforceDB:masterfrom
kurumpa:master

Conversation

@kurumpa

@kurumpa kurumpa commented Aug 17, 2026

Copy link
Copy Markdown

Summary

Fixes the two SDK bugs reported against 0.2.1, both of which corrupted data
silently — no throw, no error object, just a wrong value.

  • rf.list([...]) returned RAY_NULL for any non-empty array, and rf.dict({...})
    (which builds its values via list()) produced a dict with null values.
  • Dict.toJS() and the dict iterator collapsed every key to "", so an N-key
    dict decoded to a 1-key object.

Bug 1 — int64_t params in non-final position

The build links -s WASM_BIGINT=0, which legalizes every int64_t parameter
into two i32 words (lo, hi). The SDK's cwrap arg lists declare one 'number'
per C parameter, so an int64_t that is not the last parameter shifts every
parameter after it:

ray_t* vec_set_idx(ray_t* obj, int64_t idx, ray_t* val)  // -> (obj, idx_lo, idx_hi, val)

val was consumed as the index's high word, the real val arrived as 0, and
the if (!obj || !val) return RAY_NULL_OBJ; guard fired. The C was correct; the
binding was not.

Fix: a new ray_jsidx_t = double typedef in src/main.c, applied to the
index/length/symbol-id parameters that cross the JS boundary. A double is
passed as one f64, needs no legalization, matches the JS Number domain exactly,
and is exact to 2^53 — far beyond any index reachable in a 4 GiB wasm32 heap.
JS call sites and cwrap arg lists are unchanged.

Chosen over the alternatives: -s WASM_BIGINT=1 would force BigInt at every
cwrap arg list across the whole SDK; int32_t would cap indices at 2^31 for no
benefit over double.

Migrated: init_symbol_str, init_string_str, symbol_to_str, symbol_vec_get,
str_vec_get, init_vector, init_list, vec_at_idx, vec_set_idx,
vec_insert, table_col, table_row, intern_symbol (13 reachable from the
SDK), plus fill_i64_vec / fill_i32_vec / fill_f64_vec, which have no cwrap
and are reachable only by embedders via Module._*.

This also closes a latent truncation: exports whose int64_t was already in
final position appeared to work because the omitted high word defaults to 0, but
silently aliased any index >= 2^32 down to its low word. Index 2**32 used to
overwrite element 0; it now errors.

Silent-corruption hardening

List.set() and List.push() assigned this._ptr = <result> unconditionally,
so a failed op replaced a live list with the null result — which is why this bug
presented as corruption rather than an exception. Both now route through
List._rebind(ptr, op), which throws on a null or error result and leaves the
existing handle intact.

Bug 2 — empty dict keys

Vector.at() on a SYM vector already returns a JS string (it routes through
symbol_vec_get). Dict.toJS() treated it as a symbol id and re-wrapped it:

const keyStr = this._sdk._symbolToStr(Number(keys.at(i)));

Number("x") -> NaN -> symbol_to_str(NaN) -> "". Every key collapsed to
the same empty string and entries overwrote each other, so the last value won.

Fix: use keys.at(i) directly, at both Dict.toJS() and
Dict[Symbol.iterator].

Tests

Adds test-bugs.mjs (23 cases) and a test:bugs script. The cases marked
control pin the paths that were not broken, so a fix that regresses them is
caught. Alongside the public-API assertions, the suite asserts at the raw export
level — the bug was an ABI mismatch, and Vector.at() bounds-checks the
truncation case away before it reaches the boundary. test-bugs.mjs:114
specifically pins that the 4-arg split-index form no longer applies, so a future
WASM_BIGINT change can't silently reintroduce the split.

23/23 passed, 0 failed (bug1: 0, bug2: 0)
npm test: green (smoke + contract)

Not addressed in this MR

Follow-up work, all in the same class as Bug 1 and none of it a regression from
this change:

  • i64 return values. Nine exports still return int64_t through a 'number'
    cwrap (read_i64, read_timestamp, read_symbol_id, get_obj_len,
    str_atom_len, get_data_byte_size, intern_symbol, get_cmd_counter,
    table_count). An i64 return is legalized the same way a parameter is, so JS
    gets the low word only: rf.eval('1099511627776').toJS() -> 0 while
    format() is correct. Same double narrowing applies.
  • Atom constructors. init_i64 / init_date / init_time / init_timestamp
    keep int64_t in trailing position and truncate at 2^32 —
    rf.i64(2**32+5) -> 5. Timestamps are nanoseconds since 2000, so every real
    value is over the line.
  • Double retain in the list bindings. vec_set_idx / vec_push /
    vec_insert call ray_retain(val) before delegating, and ray_list_set /
    ray_list_append / ray_list_insert_at each retain the item again. One extra
    ref per element on success, and an outright leak on the error path. Previously
    unreachable because Bug 1 kept rf.list() / rf.dict() from getting there —
    this MR makes that path the default, so it should land next.
  • test:bugs is not wired into npm test. Deliberate for now, pending the
    above; wire it in once the follow-ups land so the suite actually gates.
  • Non-SYM dict keys. keys.at(i) is correct for SYM / STR / numeric key
    vectors. Core dicts also permit RAY_LIST (heterogeneous) keys, where at()
    returns a RayObject that stringifies through format(). Needs a
    k instanceof RayObject ? k.toJS() : k coercion.

@singaraiona singaraiona left a comment

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.

The ABI correction fixes the reported symptoms and the focused regression suite passes, but I found two merge-blocking memory-safety/ownership issues and two additional correctness/coverage gaps. Details are inline.

Comment thread src/main.c
}

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.ts — test-contract.mjs caught that omission on its own.

Tests

Added bug3 (double retain) and bug4 (discarded handles) sections to test-bugs.mjs — 38/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

Comment thread src/main.c
* ============================================================================ */

EMSCRIPTEN_KEEPALIVE void fill_i64_vec(ray_t* obj, int64_t* data, int64_t len) {
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.

Comment thread src/rayforce.sdk.js Outdated
// keys.at() already decodes SYM cells to strings via symbol_vec_get;
// re-wrapping in Number() gave NaN -> symbol_to_str(NaN) -> "", so every
// key collapsed to the same empty string and entries overwrote each other.
result[keys.at(i)] = vals.at(i).toJS();

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.

[P2] Writing decoded, user-controlled keys into {} still loses a valid __proto__ key (and an object-valued entry changes the result's prototype). I reproduced (dict [__proto__ x] (list 1 2)).toJS() returning only x as an own key. Please build the result with Object.create(null) or use Object.defineProperty so every dict key round-trips.

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.

What changed (src/rayforce.sdk.js):

  • Added a setOwn(obj, key, value) helper (line 89) that uses Object.defineProperty with writable/enumerable/configurable: true.
  • Applied it at all three sites that write user-controlled keys: Dict.toJS() (:1414), Table.toJS() (:1559), Table.toRows() (:1593).

Why defineProperty over Object.create(null): it fixes the same round-trip problem without changing the shape of what the SDK hands back. Null-prototype objects break result.hasOwnProperty(...), print as [Object: null prototype], and would be an observable API change for every existing consumer. With defineProperty the result stays an ordinary object, and __proto__ becomes an own enumerable data property that shadows the inherited accessor.

Verified against the reviewer's exact repro:

(dict [__proto__ x] (list 1 2)).toJS()
  keys: ['__proto__', 'x'], JSON: {"__proto__":1,"x":2}, prototype intact
(dict [__proto__ x] (list (dict [a] (list 1)) 2)).toJS()
  object-valued entry no longer re-points the prototype
(table [__proto__ x] (list [1 2] [3 4]))
  toJS + toRows both keep the column

The table path was reachable too — an engine-built table can carry a __proto__ column even though a JS object literal can't express one on the input side.

Tests: added three cases to the bug2 group in test-bugs.mjs. Confirmed they fail on the pre-fix SDK and pass after; full suite is 52/52, with test.mjs and test-contract.mjs also green. dist/rayforce.sdk.js is synced with src/.

Comment thread package.json Outdated
"build:dev": "make dev",
"test": "node test.mjs && node test-contract.mjs",
"test:examples": "node test-examples.mjs",
"test:bugs": "node test-bugs.mjs",

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.

[P2] This script is not invoked by npm test, while CI and prepublishOnly only run npm test plus examples. As written, all 23 new regression cases can fail while CI remains green. Please include node test-bugs.mjs in the main test command or add an explicit CI/publish step.

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 by adding node test-bugs.mjs to the test script.

@kurumpa
kurumpa requested a review from singaraiona August 18, 2026 14:18

@morsel-ray morsel-ray left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed current4c2e8d4 against its default pinned corev2.5.13 (2cdb8e03) plus the WASM patch, not today's core. I read Anton's four requests and your follow-up replies first. The redundant success-path retains/temporary wrappers, invalid fill-number checks, own __proto__ properties and npm test wiring are addressed in the current source; I am not reporting those as still missing or dismissing his review.

Three further issues below need addressing before my approval. This is source review, not an independently executed WASM build or runtime reproduction. The capacity-cast and string-storage conclusions follow the pinned code/marshaller paths; the post-COW allocation-failure case especially needs the proposed controlled probe. Your52-case/reversion/browser results remain your validation, not mine.

Please also reconcile the body: retention fixes and test wiring are no longer deferred in the current diff. Keep the disclosed int64 constructor/return precision and heterogeneous-key follow-ups separate; I am not treating f64 as a nanosecond-precision solution.

CI follow-up: the historical run32147506588 had zero jobs; its public annotation says approval expired, not that code/tests failed. Maintain access is now available. One ordinary retry with the changed permissions was refused because the run was created over a month ago; it remains attempt1 with no new run or result. Extra Admin access is not the remedy being requested. Fresh CI should come with the actual author changes; no policy bypass or manufactured code change just to clear the old red state. No release or project-source edit performed.

Subsequent independent local baseline: I have now built this same4c2e8d4 with its pinned core2.5.13 and known WASM patch, Emscripten6.0.6/Node22.23.3. The smoke test,24-class/module/README contract check, and all52 existing regression cases pass. Source/HEAD and SDK mirrors match. This updates the earlier no-local-build status; it does not execute the three additional cases discussed above. No browser-example run or new approval is claimed; the requested follow-ups remain.

The string-length A/B follow-up is now executed: P2 thread4326631099 records normal SDK inputs whose engine lengths change3→1 and26→19, while the prior base preserves3/26 and the ASCII control passes both. Separate pinned builds and unchanged-source/binary guards verified. This supersedes the earlier all-three-unexecuted wording for that string case only. The other two additional findings remain source-backed, with no targeted runtime claim; requested changes and review state remain unchanged.

Comment thread src/main.c
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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1] Safe JS integer is not a wasm32 allocation-size bound. len_f=2**32 passes this validator and now reaches the pinned core intact. In core2cdb8e03 ray_vec_new first casts capacity to size_t, then checks the multiplied result against that already narrowed capacity. On wasm32 both become0 here, so even I32 can receive a zero-payload allocation; line559 then installs logical length2^32. The old trailing-i64 bridge lost the high word and yielded length0 for this particular input; the new f64 ABI exposes a malformed-storage case, not simply OOM. The underlying core check is inherited, but cannot be relied on by the expanded bridge.

Please check the actual physical element-byte capacity before this call (including the SYM constructor), or backport the constructor guard in the pinned dependency patch. Finite/integral values below2^53 still need size_t/header bounds. Add raw-export cases at2^32 and nearby/product boundaries requiring a clean error without writing into any malformed allocation. Source-derived finding; I have not executed that probe.

Comment thread src/rayforce.sdk.js
throw new Error(`List.${op}() failed: the engine returned null`);
}
if (this._sdk._isObjError(ptr)) {
throw new Error(`List.${op}() failed: ${this._sdk._getErrorMessage(ptr)}`);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1] Keeping the address is not necessarily keeping its owned reference. Pinned core2cdb8e03 ray_cow allocates a shared-list copy, then releases one reference to the original. ray_list_append can subsequently fail its growth allocation, release that copy and return an error. _rebind then throws while leaving the old address here, but that wrapper's original reference has already been consumed.

Source sequence: two wrappers from clone() sharing a list, choose a length where the COW copy is full, let copy allocation succeed and append-growth allocation fail. Both wrappers still claim the old pointer but only the other owner's ref remains; dropping either can leave the other dangling. If the COW allocation itself fails, ownership was not consumed—failure timing matters. The current invalid-index preservation case fails before COW and doesn't cover this.

Please establish a failure-atomic bridge/patched-core contract and add post-copy-growth-failure, pre-copy-failure and success controls under instrumentation. Do not blindly retain/release the old pointer only from the JS error branch: those paths have different ownership effects. This is a source-backed unexecuted failure sequence, not a claim that I ran an OOM experiment.

Comment thread src/main.c
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++;

@morsel-ray morsel-ray Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] The first NUL is not the extent of a marshalled JS string. This is now independently reproduced, not only source-derived.

Built base7120fc60 and head4c2e8d4 separately with the same pinned core2.5.13, known WASM patch, Emscripten6.0.6 and Node22.23.3. Using ordinary rf.string(text), then a bound (strlen string_length_probe), gives:

ASCII-byte input Expected Base stored length / engine strlen Head stored length / engine strlen
abc 3 3 /3 3 /3
a\u0000b (JS escape) 3 3 /3 1 /1
long-string-prefix-\u0000suffix (JS escape) 26 26 /26 19 /19

The actual fixture uses JavaScript's \u0000 escape to create the zero character, not six literal text characters. I checked both the engine atom-length accessor and the language-level consumer; this is not a toJS()/formatter-only observation. Both tracked source trees and built JS/WASM checksums remain unchanged after the comparison. The existing52-test baseline passed on the head and does not catch these cases. No single-hunk mutation or broad Unicode/string-vector matrix is claimed.

Please preserve an explicit byte-length/extent contract, or deliberately reject/document unsupported embedded NUL rather than silently dropping the suffix. Add short and long regression cases with a length-aware engine oracle. Pinned Emscripten's marshalled buffer includes the suffix; base passed the explicit length to ray_str, while this loop reduces it to the prefix.

The pre-existing C-string getter/formatter limitation and UTF-16-count versus UTF-8-byte mismatch are separate; I am not claiming those baseline surfaces were correct.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants