Files
scriptc/packages/runtime/test/test_sort.c
T
Chris Tate 229e336382 Hold TimSort gallop bounds in ptrdiff_t and check gallop results
The gallop searches in the comparator sort held their search bounds in
long, which is 32 bits on Windows x64, so a run of more than 2^31 slots
gave a truncated bound and the merges could copy outside the array. The
bounds are now ptrdiff_t; every other index, length, offset, hint, run
length and run-stack entry in the sort was already size_t. Each gallop
result is checked against its run length (0 <= k <= length) before any
merge uses it, and a result outside the run is an internal error rather
than a copy.

A new runtime unit test calls gallop and the merge step, through test-only
entry points built with SCR_SORT_TEST, at indices past 2^32 and over runs
of more than 2^32 slots in a sparse mapping, and checks that an
out-of-range gallop result traps.
2026-10-14 23:13:45 -05:00

268 lines
11 KiB
C

/* Unit tests for the comparator sort's TimSort steps (scr_array.c), through
* the SCR_SORT_TEST entry points. Built with ASan by sort.test.ts:
*
* (no args) run all assertions; prints "N/N cases passed"
* --crash-gallop-range validate an out-of-range gallop result → abort()
*
* The gallop and merge cases index the work array from bases past 2^32 (a
* base pointer below a small buffer), and the long-run cases gallop over
* runs of more than 2^32 slots in a sparse mapping whose untouched slots
* read as one fill key. Any index, length, offset or bound held in a 32-bit
* type would then read or copy outside the written slots, which ASan
* reports, or give a wrong result.
*/
#include "../src/scr_runtime.h"
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifndef _WIN32
#include <sys/mman.h>
#endif
size_t scr_sort_test_gallop(bool right, const uint64_t *arr, uint64_t key, size_t base,
size_t length, size_t hint, ScrSortLess less);
int scr_sort_test_merge(uint64_t *a, size_t base, size_t len_a, size_t len_b, uint64_t *tmp,
size_t tmp_cap, ScrSortLess less);
size_t scr_sort_test_gallop_result(ptrdiff_t k, size_t length);
static long total = 0, failed = 0;
static void check(bool ok, const char *what) {
total++;
if (!ok) {
failed++;
if (failed <= 20) fprintf(stderr, "FAIL: %s\n", what);
}
}
/* Slots hold a key in the high 32 bits and an origin tag in the low 32, so
* a merge's stability shows in the tags. A zero slot (an untouched page of
* the sparse mapping) has the key fill_key. */
static long calls = 0;
static uint64_t fill_key = 0;
static uint64_t slot_key(uint64_t x) { return x == 0 ? fill_key : x >> 32; }
static bool key_less(void *ctx, uint64_t x, uint64_t y, bool *failed_) {
(void)ctx;
(void)failed_;
calls++;
return slot_key(x) < slot_key(y);
}
static uint64_t rng_state = 0x9e3779b97f4a7c15u;
static uint64_t rng(void) {
rng_state ^= rng_state << 13;
rng_state ^= rng_state >> 7;
rng_state ^= rng_state << 17;
return rng_state;
}
static int cmp_u64(const void *x, const void *y) {
uint64_t a = *(const uint64_t *)x, b = *(const uint64_t *)y;
return a < b ? -1 : a > b ? 1 : 0;
}
/* Bases past 2^32 (and one near the top of the index range) at which the
* tests address their buffers. */
static const size_t bases[] = {
0,
((size_t)1 << 32) - 3,
((size_t)1 << 32) + 5,
((size_t)1 << 33) + 12345,
((size_t)1 << 60) / sizeof(uint64_t) * 7,
};
#define BASES (sizeof bases / sizeof bases[0])
/* The pointer `buf - base`, built through uintptr_t so the test indexes the
* buffer as the sort indexes a work array that large. */
static uint64_t *rebase(uint64_t *buf, size_t base) {
return (uint64_t *)((uintptr_t)buf - (uintptr_t)base * sizeof(uint64_t));
}
static void test_gallop(void) {
enum { N = 300 };
uint64_t *buf = malloc(N * sizeof(uint64_t));
for (size_t trial = 0; trial < 40; trial++) {
size_t length = 1 + rng() % N;
for (size_t i = 0; i < length; i++) buf[i] = (rng() % (length / 3 + 2)) << 32 | i;
qsort(buf, length, sizeof(uint64_t), cmp_u64);
for (size_t b = 0; b < BASES; b++) {
const uint64_t *arr = rebase(buf, bases[b]);
for (size_t probe = 0; probe < 12; probe++) {
uint64_t key = (rng() % (length / 3 + 4)) << 32;
size_t hint = rng() % length;
size_t lower = 0, upper = 0;
while (lower < length && (buf[lower] >> 32) < (key >> 32)) lower++;
upper = lower;
while (upper < length && (buf[upper] >> 32) == (key >> 32)) upper++;
size_t left = scr_sort_test_gallop(false, arr, key, bases[b], length, hint, key_less);
size_t right = scr_sort_test_gallop(true, arr, key, bases[b], length, hint, key_less);
check(left == lower, "gallop_left finds the first slot not less than the key");
check(right == upper, "gallop_right finds the first slot greater than the key");
}
}
}
free(buf);
}
/* Two sorted runs merged at each base, against a stable reference merge. */
static void merge_case(size_t len_a, size_t len_b, uint64_t key_range, bool *took_low,
bool *took_high) {
size_t n = len_a + len_b;
uint64_t *init = malloc(n * sizeof(uint64_t));
uint64_t *want = malloc(n * sizeof(uint64_t));
uint64_t *buf = malloc(n * sizeof(uint64_t));
uint64_t *tmp = malloc((n / 2 + 1) * sizeof(uint64_t));
for (size_t i = 0; i < n; i++) init[i] = (rng() % key_range) << 32;
qsort(init, len_a, sizeof(uint64_t), cmp_u64);
qsort(init + len_a, len_b, sizeof(uint64_t), cmp_u64);
for (size_t i = 0; i < n; i++) init[i] |= i;
for (size_t i = 0, x = 0, y = len_a; i < n; i++)
want[i] = y == n || (x < len_a && (init[x] >> 32) <= (init[y] >> 32)) ? init[x++] : init[y++];
for (size_t b = 0; b < BASES; b++) {
memcpy(buf, init, n * sizeof(uint64_t));
int merge = scr_sort_test_merge(rebase(buf, bases[b]), bases[b], len_a, len_b, tmp,
n / 2 + 1, key_less);
if (merge == 1) *took_low = true;
if (merge == 2) *took_high = true;
check(memcmp(buf, want, n * sizeof(uint64_t)) == 0, "merge matches the stable merge");
}
free(init);
free(want);
free(buf);
free(tmp);
}
static void test_merge(void) {
bool took_low = false, took_high = false;
/* Few distinct keys give long winning streaks, so the merges gallop. */
static const uint64_t ranges[] = {2, 5, 40, 1000000};
for (size_t trial = 0; trial < 60; trial++) {
size_t len_a = 1 + rng() % 400, len_b = 1 + rng() % 400;
merge_case(len_a, len_b, ranges[trial % 4], &took_low, &took_high);
}
/* One run entirely inside the other, at both ends. */
merge_case(1, 500, 3, &took_low, &took_high);
merge_case(500, 1, 3, &took_low, &took_high);
merge_case(256, 256, 2, &took_low, &took_high);
check(took_low, "merge_low ran");
check(took_high, "merge_high ran");
check(calls > 0, "the comparator ran");
}
#ifndef _WIN32
/* Writes `n` sorted slots with keys in [1, 8] at map[at, at + n). */
static void write_run(uint64_t *map, size_t at, size_t n) {
for (size_t i = 0; i < n; i++) map[at + i] = (1 + rng() % 8) << 32;
qsort(map + at, n, sizeof(uint64_t), cmp_u64);
for (size_t i = 0; i < n; i++) map[at + i] |= (at + i + 1) & 0xffffffffu;
}
/* Merges map[a0, a0 + na) and map[b0, b0 + nb) (both sorted, b0 = a0 + na
* not required) stably into want[0, na + nb). */
static void stable_merge(const uint64_t *map, size_t a0, size_t na, size_t b0, size_t nb,
uint64_t *want) {
for (size_t i = 0, x = 0, y = 0; i < na + nb; i++)
want[i] = y == nb || (x < na && slot_key(map[a0 + x]) <= slot_key(map[b0 + y]))
? map[a0 + x++]
: map[b0 + y++];
}
/* Gallops and merges over runs longer than 2^32 slots. The mapping reserves
* no memory: only the slots the steps read or write are touched. */
static void test_long_runs(void) {
const size_t huge = ((size_t)1 << 32) + 77;
const size_t slots = ((size_t)1 << 33) + 4096;
bool took_low = false, took_high = false;
for (int trial = 0; trial < 12; trial++) {
uint64_t *map = mmap(NULL, slots * sizeof(uint64_t), PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0);
if (map == MAP_FAILED) {
check(false, "mmap of a sparse 64 GiB work array");
return;
}
uint64_t want[256], tmp[128];
size_t na, nb, base;
if (trial % 2 == 0) {
/* Run A: `huge` slots of key 0 (fill), then `tail` written slots;
* run B: nb written slots after it. */
fill_key = 0;
base = 3 + rng() % 1000;
size_t tail = 1 + rng() % 100;
na = huge + tail;
nb = 1 + rng() % 100;
write_run(map, base + huge, tail);
write_run(map, base + na, nb);
size_t first = scr_sort_test_gallop(false, map, (uint64_t)1 << 32, base, na, 0, key_less);
check(first == huge, "gallop_left from the front of a long run");
size_t past = scr_sort_test_gallop(true, map, 0, base, na, na - 1, key_less);
check(past == huge, "gallop_right from the back of a long run");
stable_merge(map, base + huge, tail, base + na, nb, want);
int merge = scr_sort_test_merge(map, base, na, nb, tmp, 128, key_less);
if (merge == 1) took_low = true;
if (merge == 2) took_high = true;
check(memcmp(map + base + huge, want, (tail + nb) * sizeof(uint64_t)) == 0,
"merge after a long run A");
check(map[base + huge - 1] == 0 && map[base] == 0, "the fill slots of run A stay");
} else {
/* Run A: na written slots past index 2^32; run B: `head` written
* slots, then `huge` slots of key 9 (fill). */
fill_key = 9;
base = ((size_t)1 << 32) + 5 + rng() % 1000;
na = 1 + rng() % 100;
size_t head = 1 + rng() % 100;
nb = head + huge;
if (base + na + nb > slots) base = slots - na - nb;
write_run(map, base, na);
write_run(map, base + na, head);
size_t last = scr_sort_test_gallop(false, map, (uint64_t)9 << 32, base + na, nb, nb - 1,
key_less);
check(last == head, "gallop_left from the back of a long run");
size_t top = scr_sort_test_gallop(true, map, (uint64_t)8 << 32, base + na, nb, 0, key_less);
check(top == head, "gallop_right from the front of a long run");
stable_merge(map, base, na, base + na, head, want);
int merge = scr_sort_test_merge(map, base, na, nb, tmp, 128, key_less);
if (merge == 1) took_low = true;
if (merge == 2) took_high = true;
check(memcmp(map + base, want, (na + head) * sizeof(uint64_t)) == 0,
"merge before a long run B");
check(map[base + na + head] == 0 && map[base + na + nb - 1] == 0,
"the fill slots of run B stay");
}
munmap(map, slots * sizeof(uint64_t));
}
fill_key = 0;
check(took_low, "merge_low ran after trimming a long run");
check(took_high, "merge_high ran after trimming a long run");
}
#endif
static void test_gallop_result(void) {
check(scr_sort_test_gallop_result(0, 0) == 0, "gallop result 0 of 0");
check(scr_sort_test_gallop_result(7, 7) == 7, "gallop result at the run length");
size_t big = ((size_t)1 << 33) + 1;
check(scr_sort_test_gallop_result((ptrdiff_t)big, big) == big, "gallop result past 2^32");
}
int main(int argc, char **argv) {
if (argc > 1) {
if (strcmp(argv[1], "--crash-gallop-range") == 0) {
scr_sort_test_gallop_result(-1, 4);
} else {
fprintf(stderr, "unknown mode %s\n", argv[1]);
return 2;
}
fprintf(stderr, "expected a trap, still alive\n");
return 2;
}
test_gallop();
test_merge();
#ifndef _WIN32
test_long_runs();
#endif
test_gallop_result();
fprintf(stderr, "%ld/%ld cases passed\n", total - failed, total);
return failed == 0 ? 0 : 1;
}