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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,30 @@ All notable changes to Hemlock will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.9.1] - 2026-07-28

Patch release. `array.sort()` was quadratic on inputs programs routinely
produce, and crashed outright on large ones in compiled code.

### Fixed

- **`array.sort()` no longer degrades to quadratic time, and no longer segfaults on large sorted arrays.** The compiled runtime used a Lomuto-partition quicksort with a last-element pivot, no randomization, recursing on both halves — so sorted or reverse-sorted input hit the worst case in both dimensions at once: O(n²) comparisons *and* O(n) recursion depth. A presorted 200k-element array segfaulted from stack exhaustion, and 2000 elements took ~5 ms instead of ~0 ms. Sorted input is not an exotic case; it is what any incremental workload (re-sorting a mostly-ordered list each frame or each tick) produces by construction. The interpreter used an insertion sort — the mirror image: linear on presorted input, quadratic on shuffled.

Both backends now use the same **stable bottom-up merge sort**: O(n log n) comparisons on every input with no quadratic worst case, and iterative, so no array size can exhaust the C stack. Each merge first checks whether its two runs are already in order and skips the copy when they are, keeping fully sorted input at O(n) comparisons with no data movement.

Measured on the compiled backend: presorted 200k now sorts in 1 ms (previously segfaulted); presorted 2000 drops from ~5 ms to ~0 ms. Interpreter: 50k shuffled sorts in 7 ms.

- **`sort()` is stable on both backends.** Quicksort is unstable and insertion sort is stable, so the two backends disagreed on the order of equal-comparing elements and `sort()` had no usable stability contract. Ties now keep their original relative order everywhere, and that is a documented guarantee rather than a backend-dependent accident.

- **A comparator that throws leaves the array intact.** Each merge builds its output in scratch space and copies back only once complete, so an abandoned sort leaves the array a valid permutation of its elements — every element still present exactly once (the ordering is unspecified). The old interpreter path returned mid-shift, duplicating one slot and dropping an element, which corrupted reference counts.

- **A comparator that resizes the array being sorted raises instead of corrupting memory.** Growing the array from inside a comparator reallocates the element storage out from under the in-progress merge. This now raises a catchable `sort() comparator resized the array being sorted` rather than being a use-after-free. Sorting a *different* array from inside a comparator is still fine.

### Notes

- Known limitation, unchanged in kind from `map()`/`filter()`: in compiled code a throwing comparator `longjmp`s past the sort and leaks the scratch buffer, since `hml_throw()` neither unwinds nor runs defers and there is no cleanup hook to attach the `free()` to. The array itself stays intact, which is the property that matters for reference counting.
- New `tests/parity/methods/array_sort.hml` covers default and custom comparators, stability, presorted/reversed/shuffled input, edge cases, and the throwing-comparator path. Suites: 332/332 parity, 54 compiler; verified clean under valgrind on both backends.

## [2.9.0] - 2026-07-27

Semantics-hardening release. The theme is making the documented contracts
Expand Down
17 changes: 16 additions & 1 deletion docs/reference/array-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -811,7 +811,22 @@ words.sort();
print(words); // ["apple", "banana", "cherry"]
```

**Note:** Default comparison orders by value type, then by value within type. Uses stable insertion sort.
**Note:** Default comparison orders by value type, then by value within type.

Uses a stable bottom-up merge sort: equal-comparing elements keep their original
relative order, and every input costs O(n log n) comparisons — there is no input
that degrades it to quadratic time. Already-sorted input is detected run by run
and costs O(n) comparisons with no data movement. The sort is iterative, so no
array size can exhaust the C stack. It allocates a scratch buffer of `n` values
for the duration of the sort.

If the comparator throws, the sort is abandoned and the exception propagates. The
array is left as a valid permutation of its elements — every element is still
present exactly once — but the ordering is unspecified.

A comparator that resizes the array being sorted (via `push`, `pop`, and so on)
throws `sort() comparator resized the array being sorted`. Sorting a *different*
array from inside a comparator is fine.

---

Expand Down
5 changes: 3 additions & 2 deletions include/version.h

Large diffs are not rendered by default.

91 changes: 67 additions & 24 deletions runtime/src/builtins_array.c
Original file line number Diff line number Diff line change
Expand Up @@ -766,10 +766,12 @@ HmlValue hml_array_flat(HmlValue arr) {
return result;
}

// Quicksort comparator context
// Merge sort comparator context
typedef struct {
HmlValue comparator;
int has_comparator;
HmlArray *array; // array being sorted, for the mid-sort resize check
int expected_length;
} SortContext;

// Default comparison function for sorting
Expand Down Expand Up @@ -798,7 +800,11 @@ static int default_compare(HmlValue a, HmlValue b) {
}
}

// Compare two values using either default or custom comparator
// Compare two values using either default or custom comparator.
//
// A comparator that resizes the array mid-sort would invalidate the element
// pointer the merge is walking, so that is rejected with a clear error rather
// than left as a use-after-free.
static int compare_values(HmlValue a, HmlValue b, SortContext *ctx) {
if (!ctx->has_comparator) {
return default_compare(a, b);
Expand All @@ -809,36 +815,71 @@ static int compare_values(HmlValue a, HmlValue b, SortContext *ctx) {
HmlValue result = hml_call_function(ctx->comparator, args, 2);
int cmp = hml_to_i32(result);
hml_release(&result);

if (ctx->array->length != ctx->expected_length) {
hml_runtime_error("sort() comparator resized the array being sorted");
}
return cmp;
}

// Quicksort partition
static int partition(HmlValue *arr, int low, int high, SortContext *ctx) {
HmlValue pivot = arr[high];
int i = low - 1;

for (int j = low; j < high; j++) {
if (compare_values(arr[j], pivot, ctx) <= 0) {
i++;
HmlValue tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
// Merge the adjacent sorted runs arr[lo..mid) and arr[mid..hi).
//
// The merged output is built in scratch[lo..hi) and only copied back over the
// array once the merge has completed. A comparator that throws longjmps out of
// this function, so leaving `arr` untouched until the very end is what keeps
// the array a valid permutation of its elements (no duplicated slots, so no
// reference-count corruption) when a sort is abandoned mid-flight.
static void merge_runs(HmlValue *arr, HmlValue *scratch, int lo, int mid, int hi,
SortContext *ctx) {
// Fast path: the two runs are already in order, so nothing has to move.
// This is what keeps an already-sorted array at O(n) comparisons overall.
if (compare_values(arr[mid - 1], arr[mid], ctx) <= 0) {
return;
}

int i = lo, j = mid, k = lo;
while (i < mid && j < hi) {
// `<= 0` takes from the left run on ties, which makes the sort stable.
if (compare_values(arr[i], arr[j], ctx) <= 0) {
scratch[k++] = arr[i++];
} else {
scratch[k++] = arr[j++];
}
}
while (i < mid) scratch[k++] = arr[i++];
while (j < hi) scratch[k++] = arr[j++];

HmlValue tmp = arr[i + 1];
arr[i + 1] = arr[high];
arr[high] = tmp;
return i + 1;
memcpy(arr + lo, scratch + lo, (size_t)(hi - lo) * sizeof(HmlValue));
}

// Quicksort implementation
static void quicksort(HmlValue *arr, int low, int high, SortContext *ctx) {
if (low < high) {
int pi = partition(arr, low, high, ctx);
quicksort(arr, low, pi - 1, ctx);
quicksort(arr, pi + 1, high, ctx);
// Bottom-up merge sort: stable, O(n log n) comparisons on every input, and
// iterative so no input can blow the C stack. Replaces a Lomuto quicksort that
// degraded to O(n^2) time and O(n) recursion depth on sorted input.
//
// Note: a comparator that throws longjmps straight past this frame, so the
// scratch buffer is leaked on that path. hml_throw() does not unwind or run
// defers, so there is no cleanup hook to hang the free() on; the same is true
// of the result array in map()/filter(). The array itself stays intact, which
// is the property that matters for reference counting.
static void merge_sort(HmlValue *arr, int n, SortContext *ctx) {
if ((size_t)n > SIZE_MAX / sizeof(HmlValue)) {
hml_runtime_error("sort() array too large");
}
HmlValue *scratch = malloc((size_t)n * sizeof(HmlValue));
if (!scratch) {
hml_runtime_error("sort() failed to allocate scratch space for %d elements", n);
}

// `width` is 64-bit so the doubling cannot overflow for large `n`.
for (long long width = 1; width < n; width *= 2) {
for (long long lo = 0; lo + width < n; lo += 2 * width) {
long long end = lo + 2 * width;
merge_runs(arr, scratch, (int)lo, (int)(lo + width),
end < n ? (int)end : n, ctx);
}
}

free(scratch);
}

void hml_array_sort(HmlValue arr, HmlValue comparator) {
Expand All @@ -854,8 +895,10 @@ void hml_array_sort(HmlValue arr, HmlValue comparator) {
SortContext ctx;
ctx.comparator = comparator;
ctx.has_comparator = (comparator.type == HML_VAL_FUNCTION);
ctx.array = a;
ctx.expected_length = a->length;

quicksort(a->elements, 0, a->length - 1, &ctx);
merge_sort(a->elements, a->length, &ctx);
}

void hml_array_fill(HmlValue arr, HmlValue value, HmlValue start, HmlValue end) {
Expand Down
182 changes: 130 additions & 52 deletions src/backends/interpreter/io/array_methods.c
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,133 @@ static Value call_function_value(Value func, Value *args, int num_args, Executio
return result;
}

// ========== ARRAY SORTING ==========

// Default ordering used by sort() when no comparator is supplied: values are
// ordered by type tag first, then by value within a type.
static int sort_default_compare(Value a, Value b) {
if (a.type != b.type) {
return (int)a.type - (int)b.type;
}

switch (a.type) {
case VAL_I8: return (a.as.as_i8 < b.as.as_i8) ? -1 : (a.as.as_i8 > b.as.as_i8) ? 1 : 0;
case VAL_I16: return (a.as.as_i16 < b.as.as_i16) ? -1 : (a.as.as_i16 > b.as.as_i16) ? 1 : 0;
case VAL_I32: return (a.as.as_i32 < b.as.as_i32) ? -1 : (a.as.as_i32 > b.as.as_i32) ? 1 : 0;
case VAL_I64: return (a.as.as_i64 < b.as.as_i64) ? -1 : (a.as.as_i64 > b.as.as_i64) ? 1 : 0;
case VAL_U8: return (a.as.as_u8 < b.as.as_u8) ? -1 : (a.as.as_u8 > b.as.as_u8) ? 1 : 0;
case VAL_U16: return (a.as.as_u16 < b.as.as_u16) ? -1 : (a.as.as_u16 > b.as.as_u16) ? 1 : 0;
case VAL_U32: return (a.as.as_u32 < b.as.as_u32) ? -1 : (a.as.as_u32 > b.as.as_u32) ? 1 : 0;
case VAL_U64: return (a.as.as_u64 < b.as.as_u64) ? -1 : (a.as.as_u64 > b.as.as_u64) ? 1 : 0;
case VAL_F32: return (a.as.as_f32 < b.as.as_f32) ? -1 : (a.as.as_f32 > b.as.as_f32) ? 1 : 0;
case VAL_F64: return (a.as.as_f64 < b.as.as_f64) ? -1 : (a.as.as_f64 > b.as.as_f64) ? 1 : 0;
case VAL_BOOL: return (int)a.as.as_bool - (int)b.as.as_bool;
case VAL_STRING: return strcmp(a.as.as_string->data, b.as.as_string->data);
default: return 0; // Objects, arrays, etc. - no default ordering
}
}

// Compare two values with either the default ordering or a user comparator.
// Writes the result to *out and returns 1; returns 0 if the comparator threw.
//
// A comparator that resizes the array mid-sort would invalidate the element
// pointer the merge is walking, so that is rejected with a clear error rather
// than left as a use-after-free.
static int sort_compare(Value a, Value b, Value comparator, int has_comparator,
Array *arr, int expected_length, ExecutionContext *ctx, int *out) {
if (!has_comparator) {
*out = sort_default_compare(a, b);
return 1;
}

Value cmp_args[2] = { a, b };
Value cmp_result = call_function_value(comparator, cmp_args, 2, ctx);
if (ctx->exception_state.is_throwing) {
return 0;
}
*out = value_to_int(cmp_result);
value_release(cmp_result);

if (arr->length != expected_length) {
throw_runtime_error(ctx, "sort() comparator resized the array being sorted");
return 0;
}
return 1;
}

// Merge the adjacent sorted runs arr[lo..mid) and arr[mid..hi).
//
// The merged output is built in scratch[lo..hi) and only copied back over the
// array once the merge has completed, so a comparator that throws part-way
// through leaves the array as a valid permutation of its elements: no slot is
// ever duplicated, so no element's reference count is corrupted.
static int sort_merge_runs(Array *arr, Value *scratch, int lo, int mid, int hi,
Value comparator, int has_comparator, int n,
ExecutionContext *ctx) {
Value *elems = arr->elements;
int cmp;

// Fast path: the two runs are already in order, so nothing has to move.
// This is what keeps an already-sorted array at O(n) comparisons overall.
if (!sort_compare(elems[mid - 1], elems[mid], comparator, has_comparator, arr, n, ctx, &cmp)) {
return 0;
}
if (cmp <= 0) {
return 1;
}

int i = lo, j = mid, k = lo;
while (i < mid && j < hi) {
if (!sort_compare(elems[i], elems[j], comparator, has_comparator, arr, n, ctx, &cmp)) {
return 0;
}
// `<= 0` takes from the left run on ties, which makes the sort stable.
if (cmp <= 0) {
scratch[k++] = elems[i++];
} else {
scratch[k++] = elems[j++];
}
}
while (i < mid) scratch[k++] = elems[i++];
while (j < hi) scratch[k++] = elems[j++];

memcpy(elems + lo, scratch + lo, (size_t)(hi - lo) * sizeof(Value));
return 1;
}

// Sort an array in place with a stable bottom-up merge sort: O(n log n)
// comparisons on every input, and iterative so no input can exhaust the C
// stack. Returns 0 if the sort was abandoned (comparator threw, or scratch
// space could not be allocated), in which case ctx holds the exception.
static int array_merge_sort(Array *arr, Value comparator, int has_comparator,
ExecutionContext *ctx) {
int n = arr->length;
if ((size_t)n > SIZE_MAX / sizeof(Value)) {
throw_runtime_error(ctx, "sort() array too large");
return 0;
}
Value *scratch = malloc((size_t)n * sizeof(Value));
if (!scratch) {
throw_runtime_error(ctx, "sort() failed to allocate scratch space for %d elements", n);
return 0;
}

// `width` is 64-bit so the doubling cannot overflow for large `n`.
for (long long width = 1; width < n; width *= 2) {
for (long long lo = 0; lo + width < n; lo += 2 * width) {
long long end = lo + 2 * width;
if (!sort_merge_runs(arr, scratch, (int)lo, (int)(lo + width),
end < n ? (int)end : n, comparator, has_comparator, n, ctx)) {
free(scratch);
return 0;
}
}
}

free(scratch);
return 1;
}

// ========== ARRAY METHOD HANDLING ==========

// Helper function to check if value matches array element type
Expand Down Expand Up @@ -248,58 +375,9 @@ Value call_array_method(Array *arr, const char *method, Value *args, int num_arg
return val_null(); // Already sorted
}

// Simple insertion sort (stable, good for small arrays)
int has_comparator = (num_args == 1);

for (int i = 1; i < arr->length; i++) {
Value key = arr->elements[i];
int j = i - 1;

while (j >= 0) {
int cmp;
if (has_comparator) {
Value cmp_args[2];
cmp_args[0] = arr->elements[j];
cmp_args[1] = key;
Value cmp_result = call_function_value(args[0], cmp_args, 2, ctx);
if (ctx->exception_state.is_throwing) {
return val_null();
}
cmp = value_to_int(cmp_result);
value_release(cmp_result);
} else {
// Default comparison by value type
Value a = arr->elements[j];
Value b = key;

// Compare by type first
if (a.type != b.type) {
cmp = (int)a.type - (int)b.type;
} else {
switch (a.type) {
case VAL_I8: cmp = (a.as.as_i8 < b.as.as_i8) ? -1 : (a.as.as_i8 > b.as.as_i8) ? 1 : 0; break;
case VAL_I16: cmp = (a.as.as_i16 < b.as.as_i16) ? -1 : (a.as.as_i16 > b.as.as_i16) ? 1 : 0; break;
case VAL_I32: cmp = (a.as.as_i32 < b.as.as_i32) ? -1 : (a.as.as_i32 > b.as.as_i32) ? 1 : 0; break;
case VAL_I64: cmp = (a.as.as_i64 < b.as.as_i64) ? -1 : (a.as.as_i64 > b.as.as_i64) ? 1 : 0; break;
case VAL_U8: cmp = (a.as.as_u8 < b.as.as_u8) ? -1 : (a.as.as_u8 > b.as.as_u8) ? 1 : 0; break;
case VAL_U16: cmp = (a.as.as_u16 < b.as.as_u16) ? -1 : (a.as.as_u16 > b.as.as_u16) ? 1 : 0; break;
case VAL_U32: cmp = (a.as.as_u32 < b.as.as_u32) ? -1 : (a.as.as_u32 > b.as.as_u32) ? 1 : 0; break;
case VAL_U64: cmp = (a.as.as_u64 < b.as.as_u64) ? -1 : (a.as.as_u64 > b.as.as_u64) ? 1 : 0; break;
case VAL_F32: cmp = (a.as.as_f32 < b.as.as_f32) ? -1 : (a.as.as_f32 > b.as.as_f32) ? 1 : 0; break;
case VAL_F64: cmp = (a.as.as_f64 < b.as.as_f64) ? -1 : (a.as.as_f64 > b.as.as_f64) ? 1 : 0; break;
case VAL_BOOL: cmp = (int)a.as.as_bool - (int)b.as.as_bool; break;
case VAL_STRING: cmp = strcmp(a.as.as_string->data, b.as.as_string->data); break;
default: cmp = 0; break; // Objects, arrays, etc. - no default ordering
}
}
}

if (cmp <= 0) break;

arr->elements[j + 1] = arr->elements[j];
j--;
}
arr->elements[j + 1] = key;
Value comparator = (num_args == 1) ? args[0] : val_null();
if (!array_merge_sort(arr, comparator, num_args == 1, ctx)) {
return val_null(); // Comparator threw, or scratch allocation failed
}
return val_null();
}
Expand Down
Loading
Loading