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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ changelog should follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
- Core commit: `bd827dc85`
- Added `Debug` trait with `derive(Debug)` support, including `ignore=[..]` configuration for non-debuggable nested types
- Added new `moonbitlang/async` APIs including `@process.spawn`, advisory file locking, `@fs.tmpdir`, `@async.all`, and `@async.any`
- Added `Array::release_unused(placeholder~)`, which overwrites the unused capacity of an array in place with a placeholder, releasing the elements that earlier removals left there

#### Changed

- `Array` shrinking operations no longer null out the slots they vacate, so an `ArrayView` created before the mutation can no longer read uninitialized memory (previously a segfault on native and `null` on wasm-gc; the JavaScript backend is unchanged, and such a view still observes `undefined` past the array's current length there). Mutating an array while a view of it is alive remains a program error, but it now yields unspecified *valid* values rather than undefined behavior. The removed elements stay reachable from the buffer until later pushes reuse those slots, the buffer grows, or the array is dropped -- uniformly, `clear` included, which no longer releases them. `Array::release_unused(placeholder~)` overwrites the unused capacity in place with a placeholder to release them on demand, while `shrink_to_fit`, which already reallocated, lets them go with the old buffer. No existing signature changes
- **BREAKING**: `@json.parse` now rejects unpaired `\uXXXX` surrogate escapes in strings, raising `ParseError::InvalidChar` at the backslash that opens the offending escape; an escaped leading surrogate must be followed immediately by an escaped trailing surrogate, and the pair decodes to the character it denotes. Previously such escapes were decoded unchecked and produced a `String` that was not well-formed Unicode. `@json.valid` reports the same documents as invalid. JSON emitted by `JSON.stringify` in JavaScript can contain these escapes, so input that JavaScript and Python accept may now be rejected — as it is by Rust's serde_json
- `@json.inspect` has been migrated to `json_inspect`
- `String::sub` and `StringView::sub` now panic on invalid indices instead of raising `CreatingViewError`. The `CreatingViewError` type has been removed.
Expand Down
15 changes: 15 additions & 0 deletions builtin/array.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,13 @@ pub fn[T] Array::any(self : Array[T], f : (T) -> Bool raise?) -> Bool raise? {
///
/// This method has no effect on the allocated capacity of the array, only setting the length to 0.
///
/// Emptying an array is a removal like any other: the buffer keeps referring to
/// the elements that were in it, and they are released once later pushes reuse
/// those slots, the buffer grows, or the array is dropped. Call
/// `Array::release_unused` to overwrite them at once, or `Array::shrink_to_fit`
/// to hand the buffer back entirely. On the JavaScript backend the removed
/// elements are released right away and `Array::release_unused` is a no-op.
///
/// # Example
/// ```mbt check
/// test {
Expand Down Expand Up @@ -1862,6 +1869,14 @@ pub fn[A] Array::unsafe_pop_back(self : Array[A]) -> Unit {
/// - If `len` is negative, the function does nothing.
/// - If `len` exceeds current length, the array remains unchanged.
///
/// Elements beyond `len` are removed from the array, but the backing buffer
/// keeps referring to them: they are released once those slots are reused by
/// later pushes, once the buffer grows, or once the array is dropped. Call
/// `Array::release_unused` to overwrite them at once, or `Array::shrink_to_fit`
/// to move the survivors into an exact-size buffer. On the JavaScript backend
/// the removed elements are released right away and `Array::release_unused` is a
/// no-op.
///
/// Example:
///
/// ```mbt check
Expand Down
206 changes: 206 additions & 0 deletions builtin/array_nonjs_test.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,209 @@ test "shrink_to_fit" {
v.shrink_to_fit()
inspect(v.capacity(), content="3")
}

///|
/// Removing elements must never leave a slot a view could read as
/// uninitialized memory, so nothing is written to the slots a removal vacates
/// and a view taken beforehand still sees the original elements.
test "clear leaves the emptied slots as they were" {
let arr = ["a", "b", "c"]
let view = arr[0:3]
arr.clear()
inspect(arr.length(), content="0")
debug_inspect(
view,
content=(
#|<ArrayView: ["a", "b", "c"]>
),
)
}

///|
test "release_unused after clear overwrites the whole buffer" {
let arr = ["a", "b", "c"]
let view = arr[0:3]
arr.clear()
arr.release_unused(placeholder="-")
inspect(arr.length(), content="0")
debug_inspect(
view,
content=(
#|<ArrayView: ["-", "-", "-"]>
),
)
}

///|
test "truncate keeps removed elements reachable until release_unused" {
let kept = ["a", "b", "c", "d"]
let kept_view = kept[0:4]
kept.truncate(2)
debug_inspect(
kept,
content=(
#|["a", "b"]
),
)
debug_inspect(
kept_view,
content=(
#|<ArrayView: ["a", "b", "c", "d"]>
),
)
let filled = ["a", "b", "c", "d"]
let filled_view = filled[0:4]
filled.truncate(2)
filled.release_unused(placeholder="-")
debug_inspect(
filled,
content=(
#|["a", "b"]
),
)
debug_inspect(
filled_view,
content=(
#|<ArrayView: ["a", "b", "-", "-"]>
),
)
}

///|
/// No shrinking operation replaces the buffer, so emptying an array never
/// costs an allocation -- whether it happens through `clear`, `truncate`,
/// `remove` or `drain`. A later push makes that observable: it lands in the
/// reused buffer, which a view taken beforehand still points at.
test "no shrinking operation replaces the buffer" {
let cleared = ["a", "b", "c"]
let cleared_view = cleared[0:3]
cleared.clear()
cleared.push("x")
debug_inspect(
cleared_view,
content=(
#|<ArrayView: ["x", "b", "c"]>
),
)
let truncated = ["a", "b", "c"]
let truncated_view = truncated[0:3]
truncated.truncate(0)
truncated.push("x")
debug_inspect(
truncated_view,
content=(
#|<ArrayView: ["x", "b", "c"]>
),
)
// `remove(0)` shifts "b" down, so the buffer reads [b, b] before the push.
let removed = ["a", "b"]
let removed_view = removed[0:2]
let _ = removed.remove(0)
removed.push("x")
debug_inspect(
removed_view,
content=(
#|<ArrayView: ["b", "x"]>
),
)
// `drain(0, 2)` shifts "c" down, so the buffer reads [c, b, c] before the push.
let drained = ["a", "b", "c"]
let drained_view = drained[0:3]
let _ = drained.drain(0, 2)
drained.push("x")
debug_inspect(
drained_view,
content=(
#|<ArrayView: ["c", "x", "c"]>
),
)
}

///|
test "release_unused overwrites the tail a drain vacated" {
let arr = ["a", "b", "c", "d"]
let view = arr[0:4]
let drained = arr.drain(1, 3)
arr.release_unused(placeholder="-")
debug_inspect(
drained,
content=(
#|["b", "c"]
),
)
debug_inspect(
arr,
content=(
#|["a", "d"]
),
)
debug_inspect(
view,
content=(
#|<ArrayView: ["a", "d", "-", "-"]>
),
)
}

///|
/// `pop`, `remove` and `retain` offer no fill value of their own, so the
/// elements they remove stay in the buffer until something overwrites them.
/// `release_unused` is what releases them without reallocating, and a view taken
/// beforehand shows exactly which slots it reached.
test "release_unused releases what pop and remove leave behind" {
let popped = ["a", "b", "c"]
let popped_view = popped[0:3]
let _ = popped.pop()
debug_inspect(
popped_view,
content=(
#|<ArrayView: ["a", "b", "c"]>
),
)
popped.release_unused(placeholder="-")
debug_inspect(
popped_view,
content=(
#|<ArrayView: ["a", "b", "-"]>
),
)
let removed = ["a", "b", "c"]
let removed_view = removed[0:3]
let _ = removed.remove(0)
removed.release_unused(placeholder="-")
debug_inspect(
removed,
content=(
#|["b", "c"]
),
)
debug_inspect(
removed_view,
content=(
#|<ArrayView: ["b", "c", "-"]>
),
)
}

///|
/// `release_unused` covers the whole unused region, not just the slots the most
/// recent operation vacated, so one call settles a run of removals.
test "release_unused spans every outstanding removal" {
let arr = ["a", "b", "c", "d"]
let view = arr[0:4]
let _ = arr.pop()
arr.truncate(1)
arr.release_unused(placeholder="-")
debug_inspect(
arr,
content=(
#|["a"]
),
)
debug_inspect(
view,
content=(
#|<ArrayView: ["a", "-", "-", "-"]>
),
)
}
26 changes: 26 additions & 0 deletions builtin/arraycore_js.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,29 @@ pub fn[T] Array::shrink_to_fit(self : Array[T]) -> Unit {
ignore(self)
}

///|
/// Overwrites the array's unused capacity with `placeholder`, releasing
/// whatever those slots held.
///
/// **NOTE**: This method does nothing on the js platform -- shrinking a
/// JavaScript array releases the removed elements outright, so there is no
/// unused capacity holding on to them.
///
/// Example:
///
/// ```mbt check
/// test {
/// let arr = ["a", "b", "c"]
/// let _ = arr.pop()
/// arr.release_unused(placeholder="")
/// debug_inspect(arr, content="[\"a\", \"b\"]")
/// }
/// ```
pub fn[T] Array::release_unused(self : Array[T], placeholder~ : T) -> Unit {
ignore(self)
ignore(placeholder)
}

///|
/// Adds an element to the end of the array.
///
Expand Down Expand Up @@ -430,6 +453,9 @@ pub fn[T] Array::remove(self : Array[T], index : Int) -> T {
/// @test.assert_eq(v, [3, 5])
/// }
/// ```
///
/// On this backend the underlying JavaScript array is spliced directly, which
/// already releases the drained elements.
pub fn[T] Array::drain(self : Array[T], begin : Int, end : Int) -> Array[T] {
guard begin >= 0 && end <= self.length() && begin <= end else {
abort(
Expand Down
Loading
Loading