Skip to content

fix(table): make the public table accessors total over ray_t - #568

Merged
singaraiona merged 2 commits into
devfrom
fix/table-accessors-total
Sep 18, 2026
Merged

singaraiona merged 2 commits into
devfrom
fix/table-accessors-total

Conversation

@singaraiona

Copy link
Copy Markdown
Collaborator

Fixes #567.

What

ray_table_nrows / ray_table_ncols guard NULL and errors but not the type tag, then decode the argument's payload as the two-slot {schema, cols} layout and dereference whatever comes out.

The reporter framed it as a quirk of IPC-deserialized atoms, since a local ray_i64(42) was benign. The real trigger is just payload bytes that dereference badly — a plain I64 vector does it deterministically:

int64_t poison[] = {0x4141414141414141LL, ...};
ray_t* v = ray_vec_from_raw(RAY_I64, poison, 4);
ray_table_ncols(v);   /* SIGSEGV */
src/table/table.c:274:33: runtime error: member access within misaligned
address 0x4141414141414141 for type 'union ray_t'

Why option 1, and why all eight

These accessors aren't holding a deliberate line that the rest of the layer crosses — they're the outlier in it:

function tag check
ray_dict_keys / ray_dict_vals (dict.c:112) d->type != RAY_DICT → NULL
ray_parted_nrows (table.c:292) not parted → v->len
ray_table_validate_rectangular (table.c:167) typed error
the eight public table accessors none

So ray_dict_len returning 0 in the reporter's probe was never luck — that path was already total. Making the table side match is consistency, not a new tax.

The issue named two functions; the identical hole is in all eight that touch tbl_slots. ray_table_schema, get_col, get_col_idx, col_name, set_col_idx and set_col_name all crash or corrupt the same way, so fixing two would have left six public functions with the reported defect.

How

One tbl_is_table() predicate next to the slot accessors it protects; every public accessor routes through it. Wrong tag → empty answer (0 / NULL / -1), mutators → no-ops, no read through a decoded payload.

Also did the reporter's option 2: rayforce.h now documents the guarantee, and points callers who want a diagnosable rejection at ray_table_validate_rectangular — these accessors answer 0/NULL indistinguishably from a genuinely empty table, which is worth saying out loud.

Tests

Two new cases in test/test_table.c, written before the fix and watched to fail:

  • table/accessors_wrong_type — poisoned I64 vector through all eight, including asserting the mutators left the payload untouched. Before the fix this aborts the suite at table.c:274; after, it passes.
  • table/accessors_reject_dict — a dict shares the table's two-slot layout, so the accessors would read real pointers out of it and answer with a dict's shape. The tag is the only thing separating them.

make test: 3889 of 3889 passed (0 skipped, 0 failed). Nothing else moved, which is the useful signal across all 324 internal call sites — none depended on the unchecked behaviour.

Performance

The call sites are per-operation, not per-row — agg_engine.c:128's parallel-size test is the hot one the issue worried about, and it runs once per op. The compare hits a field in the same cache line as the NULL check already there.

Measured anyway, interleaved A/B on the 10M splayed store, min-of-3, two passes (ms):

query base fix
q13 86.19 / 86.31 84.94 / 85.58
q16 83.40 / 84.07 86.08 / 81.91
q17 92.78 / 92.63 92.99 / 92.46
q20 60.08 / 59.46 62.08 / 59.73
q22 76.99 / 76.79 76.43 / 77.44
q28 375.42 / 374.25 373.79 / 376.56

Differences fall inside run-to-run spread in both directions.

ray_table_nrows and ray_table_ncols segfault when handed a value that is
not a table (#567). They guard NULL and errors but not the type tag, then
decode the argument's payload as the two-slot {schema, cols} layout and
dereference whatever comes out.

The reporter framed this as a quirk of IPC-deserialized atoms, since a
locally built ray_i64(42) was benign. The real trigger is just payload
bytes that dereference badly — a plain I64 vector crashes deterministically:

    ray_t* v = ray_vec_from_raw(RAY_I64, poison, 4);
    ray_table_ncols(v);   /* SIGSEGV at table.c:274 */

These are not holding a deliberate line. The rest of the layer already
checks its own tag — ray_dict_keys rejects a non-RAY_DICT, ray_parted_nrows
rejects a non-parted vector — so the table accessors are the outlier, and
the same hole is in all eight of them, not just the two reported. An
embedder sees one opaque ray_t* and has nothing to check against but the
tag the accessor already holds.

Add tbl_is_table() and route every public accessor through it: wrong tag
gets the empty answer (0 / NULL / -1), mutators become no-ops, and none of
them reads the payload as slots. Document the guarantee in rayforce.h,
which previously said nothing either way, and point callers who need a
diagnosable rejection at ray_table_validate_rectangular.

No cost: the call sites are per-operation, not per-row (agg_engine.c:128's
parallel-size test is the hot one), and the compare hits a field in the
same cache line as the NULL check already there. Interleaved A/B over
ClickBench q13/16/17/20/22/28 on the 10M splayed store, min-of-3 x 2
passes, shows no movement outside run-to-run spread.
@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown

Rayforce targeted audit passed

The required Rayforce audit gate passed on the latest run.

Workflow run: https://github.com/RayforceDB/rayforce/actions/runs/35329164918

…n type

The audit caught a real hole in the first commit. The header block I added
promised totality for every declaration below it, and ray_table_add_col is
in that list but was not converted — so the documentation invited exactly
the call that corrupts memory: a non-table gets ray_cow'd, then new schema
and cols pointers are written straight into the argument's payload. Worse
than the crash #567 reported, and now advertised as safe.

Guard it. It consumes its tbl ref and owns its result, so the wrong-tag
answer is a typed "type" error rather than an empty one, releasing tbl on
that path exactly as the bad-column path beside it already does.

Rewrite the header block to say what each function actually returns on a
wrong tag instead of asserting one blanket answer for a list whose members
have different return contracts, and to spell out that add_col consumes tbl
on the error path.

Tests: table/add_col_wrong_type, written first and watched to fail in
ray_vec_append on the decoded 0x4141... payload. Broadened
table/accessors_reject_dict from three functions to the whole set, since a
dict carries real pointers and was certifying less than the header claimed.

Also corrects a stale comment the audit flagged in passing: table.h:30 said
RAY_TABLE was 13, it is 98.

make test: 3890 of 3890 passed.
@singaraiona

Copy link
Copy Markdown
Collaborator Author

Both blocking findings were correct and are fixed in 142de18b.

Finding 1 — ray_table_add_col inside a totality guarantee it doesn't honour. Right, and it was the worse half of the two: the accessors crash, add_col writesray_cow then slots[0] = new_schema; slots[1] = new_cols; straight into the argument's payload. Documenting that as safe to call on an opaque ray_t* was the actual defect in the PR, and it was mine: I converted eight functions and then wrote a contract covering eleven.

Took the guard rather than the carve-out. As the finding notes, the shape was already there — it consumes tbl and owns its result, so a wrong tag returns a typed "type" error and releases tbl, exactly as the bad-column path beside it does:

if (tbl->type != RAY_TABLE) {
    ray_t* err = ray_error("type", "table add_col: expected table, got %s",
                           ray_type_name(tbl->type));
    ray_release(tbl);
    return err;
}

The header block was the other half of the problem, so it's rewritten: instead of one blanket claim over a list whose members have different return contracts, it now states the wrong-tag answer per function (0 / -1 / NULL / no-op / typed error) and says explicitly that add_col consumes tbl on the error path too.

Finding 2 — coverage narrower than the claim. Also right, and the omission was self-selecting: add_col was the one function I didn't test and the one that would have failed. table/add_col_wrong_type was written before the guard and watched to fail in the right place:

src/vec/vec.c:238:17: runtime error: member access within misaligned address
0x4141414141414141 for type 'union ray_t'

It asserts the typed error, the error code, and that both poisoned payload words survive untouched. table/accessors_reject_dict is broadened from three functions to the full set, including add_col — your point that a dict carries real pointers is what makes that the sharper test of the two.

make test: 3890 of 3890 passed (0 skipped, 0 failed).

Also took the stale comment from the non-blocking list — table.h:30 said RAY_TABLE (13), it is 98.

The rest of the non-blocking section matches what I found independently: the ambiguous call sites all gate on the tag first, and the dict-shares-the-layout case was the real regression risk, which is why that test is in the PR.

@singaraiona
singaraiona merged commit 4ca3a69 into dev Sep 18, 2026
9 checks passed
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.

ray_table_nrows/ncols segfault on a non-table (public API, no documented precondition)

1 participant