feat: materialize readonly compile-time byte slices

This commit is contained in:
2026-10-30 14:23:39 +01:00
parent 6748b38eac
commit 8e790b30cc
7 changed files with 505 additions and 34 deletions
+57
View File
@@ -946,3 +946,60 @@ computed_callbacks_recompile_without_retaining_interpreter_function_ids test {
try testing.expect_equal(i64(42), outcome.value.data.integer)
}
}
computed_byte_slices_recompile_without_borrowing_evaluation_storage test {
diagnostic_store := diagnostics.init(mem.c_allocator)
defer diagnostics.deinit(&diagnostic_store)
modules [2]mut ir.Module := undefined
{
session := init(mem.c_allocator)
defer deinit(&session)
root :: try add_package(&session)
dependency :: try add_package(&session)
bytes := try owned_text(
`lib :: import "../library"
`main proc() i16 {
` packet :: $(lib.make())
` if (packet.bytes.len != 4 or packet.suffix.len != 3) return -1
` if (packet.bytes[0] != 255 or packet.bytes[1] != 0) return -2
` if (packet.bytes[2] != 128 or packet.bytes[3] != 42) return -3
` if (packet.suffix[0] != 65 or packet.suffix[1] != 0) return -4
` if (packet.suffix[2] != 66 or packet.suffix.ptr[3] != 0) return -5
` return 42
`}
,
)
defer arraylist.deinit(&bytes)
root_source :: try add_source(&session, root, "/byte_slices/app/main.hon", &bytes, &diagnostic_store)
imported := try owned_text(
`Packet :: struct { bytes []u8, suffix [;0]u8 }
`make proc() Packet {
` bytes [4]mut u8 := undefined
` bytes[0] = 255
` bytes[1] = 0
` bytes[2] = 128
` bytes[3] = 42
` return Packet { bytes = bytes[..], suffix = "xA\0B"[1..;0] }
`}
,
)
defer arraylist.deinit(&imported)
_ = try add_source(&session, dependency, "/byte_slices/library/bytes.hon", &imported, &diagnostic_store)
try arraylist.append(&session.imports, Import{
node = declaration(&session, root_source, 0).node,
target = dependency,
})
modules[0] = try compile(&session, root, &diagnostic_store)
errdefer ir.deinit(&modules[0])
modules[1] = try compile(&session, root, &diagnostic_store)
}
defer ir.deinit(&modules[0])
defer ir.deinit(&modules[1])
try testing.expect_equal(0, diagnostics.count(&diagnostic_store))
for modules |module| {
outcome := try interpreter.run(mem.c_allocator, &module, &diagnostic_store)
defer interpreter.deinit(&outcome)
try testing.expect(outcome.trap == null)
try testing.expect_equal(i64(42), outcome.value.data.integer)
}
}
+78 -10
View File
@@ -9,12 +9,27 @@ Outcome :: struct {
trap ?diagnostics.DiagnosticId = null
allocator ?mem.Allocator = null
owned_elements ?[]mut ir.Value = null
evaluation_memory ?[]mut EvaluationMemory = null
}
#! release an escaped aggregate with its retained allocator; scalar and trap outcomes need no allocation.
#! strings and aggregate descriptors borrow the module, which must outlive this outcome.
#! releases result storage and any retained compile-time evaluation memory.
#! string IDs and aggregate descriptors borrow the module, which must outlive this outcome.
deinit proc(outcome @mut Outcome) void {
if (outcome.owned_elements) |elements| mem.free(outcome.allocator?, elements)
if (outcome.evaluation_memory) |owned| {
memory :: &owned[0]
allocator :: outcome.allocator?
for (memory.frames.items) |frame| free_frame(allocator, frame)
arraylist.deinit(&memory.frames)
arraylist.deinit(&memory.pointers)
mem.free(allocator, memory.globals)
mem.free(allocator, memory.aggregate_globals)
mem.free(allocator, memory.strings)
mem.free(allocator, memory.string_cells)
mem.free(allocator, memory.scratch)
mem.free(allocator, owned)
}
outcome.evaluation_memory = null
outcome.owned_elements = null
outcome.allocator = null
outcome.value = ir.Value{}
@@ -82,6 +97,31 @@ Frame :: struct {
addresses []mut Address
}
@hide:file
EvaluationMemory :: struct {
globals []mut GlobalState
aggregate_globals []mut ir.Value
strings []mut StringView
string_cells []mut ir.Value
scratch []mut ir.Value
pointers arraylist.ArrayList(Pointer)
frames arraylist.ArrayList(Frame)
}
#! borrows one checked byte from retained evaluation storage; null means an invalid pointer/range.
evaluation_byte proc(outcome @Outcome, module @ir.Module, pointer ir.Value, index usize) ?ir.Value {
if (outcome.evaluation_memory == null) return null
memory :: &outcome.evaluation_memory?[0]
handle :: pointer.data.unsigned
if (handle >= u64(memory.pointers.items.len)) return null
location := memory.pointers.items[usize(handle)]
if (index > maxval!(usize) - location.byte_offset) return null
location.byte_offset += index
if (pointer_address(module, location, .u8, ir.no_array)) |address|
return read_address(module, address, memory.scratch)
return null
}
#! executes typed IR with invocation/global storage released on every exit.
#! aggregate results own flat storage released by `deinit`; string IDs and shapes resolve through `module`.
run proc(
@@ -93,8 +133,7 @@ run proc(
}
#! executes an isolated compile-time module with bounded work and stack depth.
#! ownership and diagnostic identities follow run.
#! temporary execution storage uses `allocator` and is released before returning.
#! result backing, including addressed completed frames, remains owned by the outcome until `deinit`.
run_evaluation proc(
allocator mem.Allocator,
module @ir.Module,
@@ -119,8 +158,9 @@ execute proc(
return Outcome{ trap = module.entry_diagnostic }
}
transferred := false
globals :: try mem.alloc(GlobalState, allocator, module.globals.items.len)
defer mem.free(allocator, globals)
defer if (!transferred) mem.free(allocator, globals)
mem.set(globals, GlobalState{})
global_cells usize := 0
for (module.globals.items) |global| {
@@ -130,7 +170,7 @@ execute proc(
global_cells += cells
}
aggregate_globals :: try mem.alloc(ir.Value, allocator, global_cells)
defer mem.free(allocator, aggregate_globals)
defer if (!transferred) mem.free(allocator, aggregate_globals)
global_offset usize := 0
for (module.globals.items) |global, i| {
if (!ir.aggregate(global.type)) continue
@@ -144,7 +184,7 @@ execute proc(
if (shape.byte_size > scratch_size) scratch_size = shape.byte_size
}
scratch :: try mem.alloc(ir.Value, allocator, scratch_size)
defer mem.free(allocator, scratch)
defer if (!transferred) mem.free(allocator, scratch)
source_scratch :: try mem.alloc(ir.Value, allocator, scratch_size)
defer mem.free(allocator, source_scratch)
string_size usize := 0
@@ -153,9 +193,9 @@ execute proc(
string_size += string.len + 1
}
string_cells :: try mem.alloc(ir.Value, allocator, string_size)
defer mem.free(allocator, string_cells)
defer if (!transferred) mem.free(allocator, string_cells)
strings :: try mem.alloc(StringView, allocator, module.strings.items.len)
defer mem.free(allocator, strings)
defer if (!transferred) mem.free(allocator, strings)
string_offset usize := 0
for (module.strings.items) |string, index| {
cells :: string_cells[string_offset..string_offset + string.len + 1]
@@ -165,7 +205,12 @@ execute proc(
string_offset += cells.len
}
pointers := arraylist.init(Pointer, allocator)
defer arraylist.deinit(&pointers)
defer if (!transferred) arraylist.deinit(&pointers)
retained := arraylist.init(Frame, allocator)
defer if (!transferred) {
for (retained.items) |frame| free_frame(allocator, frame)
arraylist.deinit(&retained)
}
frames := arraylist.init(Frame, allocator)
defer {
for (frames.items) |frame| free_frame(allocator, frame)
@@ -211,6 +256,21 @@ execute proc(
globals[global_index].status = .ready
}
if (frames.items.len == 1) {
if $(bounded) {
if (pointers.items.len != 0 and (ir.aggregate(value.type) or value.type == .pointer)) {
owned :: try mem.alloc(EvaluationMemory, allocator, 1)
errdefer mem.free(allocator, owned)
try arraylist.append(&retained, completed)
_ = arraylist.pop(&frames)
owned[0] = EvaluationMemory{
globals = globals, aggregate_globals = aggregate_globals,
strings = strings, string_cells = string_cells, scratch = scratch,
pointers = pointers, frames = retained,
}
transferred = true
return Outcome{ value = value, allocator = allocator, evaluation_memory = owned }
}
}
if (ir.aggregate(value.type)) {
escaped :: try mem.alloc(ir.Value, allocator, value.data.elements.len)
memcopy!(escaped, value.data.elements)
@@ -220,6 +280,14 @@ execute proc(
return Outcome{ value = value }
}
if (completed.destination != ir.no_value) copy_value(module, &frames.items[frame_index - 1].values[usize(completed.destination)], value)
if $(bounded) {
# ponytail: retain address-bearing frames until snapshotting; trace live roots if memory grows.
if (completed.addresses.len != 0) {
try arraylist.append(&retained, completed)
_ = arraylist.pop(&frames)
continue
}
}
_ = arraylist.pop(&frames)
free_frame(allocator, completed)
continue
+61
View File
@@ -494,6 +494,67 @@ frames_and_globals_are_freed_at_every_allocation_boundary_and_trap test {
try testing.expect_equal(usize(0), state.live)
}
evaluation_backing_survives_returns_and_releases_at_every_allocation_boundary test {
module := ir.init(mem.c_allocator)
defer ir.deinit(&module)
store := diagnostics.init(mem.c_allocator)
defer diagnostics.deinit(&store)
types [1]ir.Type :: [.u8]
helper :: try add_function(&module, types[..], 0, .pointer)
try emit(&module, helper, ir.Instruction{
op = .constant, type = .u8, destination = ir.ValueId(0),
constant = ir.Value{ type = .u8, data = ir.ValueData{ unsigned = 42 } },
})
try emit(&module, helper, ir.Instruction{ op = .store_local, left = ir.ValueId(0) })
try emit(&module, helper, ir.Instruction{
op = .address_local, type = .address, pointee = .u8, destination = ir.ValueId(1),
})
try emit(&module, helper, ir.Instruction{
op = .pointer_from_address, type = .pointer, left = ir.ValueId(1), destination = ir.ValueId(2),
})
return_value(&module, helper, ir.ValueId(2))
main :: try add_function(&module, &[], 0, .pointer)
module.entry = main
try emit(&module, main, function_value(0, helper))
try emit(&module, main, ir.Instruction{
op = .call, type = .pointer, left = ir.ValueId(0), destination = ir.ValueId(1),
})
return_value(&module, main, ir.ValueId(1))
trap_id :: try diagnostics.add(&store, .invalid_escape, diagnostics.Span{ start = 0, end = 0 })
for [false, true] |trap| {
if (trap) module.functions.items[usize(main)].blocks.items[0].terminator =
ir.Terminator{ kind = .trap, diagnostic = trap_id }
succeeded := false
for (usize(0)..usize(64)) |limit| {
state := AllocationState{ remaining = limit }
allocator :: mem.Allocator{ context = &state, vtable = &limited_vtable }
outcome := run_evaluation(allocator, &module, &store) catch |error| {
match error {
.out_of_memory: {}
else: return .expectation_failed
}
try testing.expect_equal(usize(0), state.live)
continue
}
defer deinit(&outcome)
if (trap) {
try testing.expect_equal(trap_id, outcome.trap?)
} else {
try testing.expect(outcome.trap == null)
byte :: evaluation_byte(&outcome, &module, outcome.value, 0)
try testing.expect(byte != null)
try testing.expect_equal(u64(42), byte?.data.unsigned)
try testing.expect(evaluation_byte(&outcome, &module, outcome.value, 1) == null)
}
deinit(&outcome)
try testing.expect_equal(usize(0), state.live)
succeeded = true
break
}
try testing.expect(succeeded)
}
}
runtime_diagnostic_allocation_failure_is_infrastructure_error_and_frees_frames test {
module := ir.init(mem.c_allocator)
defer ir.deinit(&module)
+54 -10
View File
@@ -2331,13 +2331,14 @@ evaluate_expected proc(state @mut State, id, annotation ast.NodeId) Value ! Erro
return try evaluate(state, id)
}
# values leaving the temporary interpreter must own no interpreter addresses or module IDs.
# rebuild contextual source literals and source-bound callback references before releasing it.
# values leaving temporary evaluation must own no interpreter addresses or module IDs.
# snapshot readonly bytes and rebuild source literals/references before releasing its backing.
@hide:file
serialize_value proc(
state @mut State,
module @ir.Module,
checked @typecheck.Result,
outcome @interpreter.Outcome,
functions []ast.NodeId,
type typecheck.Type,
value ir.Value,
@@ -2355,13 +2356,53 @@ serialize_value proc(
return no_node
}
result := no_node
if (type.kind == .array) {
if (type.kind == .slice) {
slice :: typecheck.array_type(checked, type.array_type)
if (
slice.mutable or slice.element.kind != .u8 or slice.element.nominal != no_node
or (slice.sentinel != null and slice.sentinel? != 0)
) return no_node
pointer :: ir.aggregate_item(module, value, 0)
length :: ir.aggregate_item(module, value, 1)
if (pointer.uninitialized or length.uninitialized) {
state.evaluation_diagnostic = try diagnostics.add(state.diagnostics, .read_before_initialization, span)
return no_node
}
if (
length.data.unsigned != 0
and interpreter.evaluation_byte(outcome, module, pointer, usize(length.data.unsigned - 1)) == null
) return no_node
bytes :: try mem.alloc(u8, state.allocator, usize(length.data.unsigned))
defer mem.free(state.allocator, bytes)
for (bytes) |_, index| {
byte :: interpreter.evaluation_byte(outcome, module, pointer, index)
if (byte == null) return no_node
if (byte?.uninitialized) {
state.evaluation_diagnostic = try diagnostics.add(state.diagnostics, .read_before_initialization, span)
return no_node
}
bytes[index] = u8(byte?.data.unsigned)
}
if (slice.sentinel != null) {
sentinel :: interpreter.evaluation_byte(outcome, module, pointer, bytes.len)
if (sentinel == null) return no_node
if (sentinel?.uninitialized) {
state.evaluation_diagnostic = try diagnostics.add(state.diagnostics, .read_before_initialization, span)
return no_node
}
if (sentinel?.data.unsigned != 0) {
state.evaluation_diagnostic = try diagnostics.add(state.diagnostics, .sentinel_mismatch, span)
return no_node
}
}
result = try ast.add_string(state.builder, span, bytes)
} else if (type.kind == .array) {
array :: typecheck.array_type(checked, type.array_type)
elements := arraylist.init(ast.NodeId, state.allocator)
defer arraylist.deinit(&elements)
for (0..array.len) |i| {
child :: ir.aggregate_item(module, value, i)
node :: try serialize_value(state, module, checked, functions, array.element, child, span)
node :: try serialize_value(state, module, checked, outcome, functions, array.element, child, span)
if (node == no_node) return no_node
try arraylist.append(&elements, node)
}
@@ -2381,7 +2422,7 @@ serialize_value proc(
return result
}
child :: ir.aggregate_item(module, value, i)
node :: try serialize_value(state, module, checked, functions, field.type, child, span)
node :: try serialize_value(state, module, checked, outcome, functions, field.type, child, span)
if (node == no_node) return no_node
name := field.name
if (name != null) name = try member_name(state, name?)
@@ -2396,7 +2437,7 @@ serialize_value proc(
index :: if (failed) usize(1) else usize(0)
channel :: if (failed) container.error_type else container.element
child :: ir.aggregate_item(module, value, index)
result = try serialize_value(state, module, checked, functions, channel, child, span)
result = try serialize_value(state, module, checked, outcome, functions, channel, child, span)
if (result == no_node) return no_node
# keep named success/error types distinct even when their members overlap.
annotation := no_node
@@ -2425,12 +2466,12 @@ serialize_value proc(
value.data.function == maxval!(ir.FunctionId)
else value.data.unsigned == maxval!(u64)
if (absent) result = try ast.add_literal(state.builder, .null, span)
else result = try serialize_value(state, module, checked, functions, container.element, value, span)
else result = try serialize_value(state, module, checked, outcome, functions, container.element, value, span)
} else if (!ir.aggregate_item(module, value, 0).data.boolean) {
result = try ast.add_literal(state.builder, .null, span)
} else {
child :: ir.aggregate_item(module, value, 1)
payload :: try serialize_value(state, module, checked, functions, container.element, child, span)
payload :: try serialize_value(state, module, checked, outcome, functions, container.element, child, span)
if (payload == no_node) return no_node
name :: try ast.add_generated_identifier(state.builder, span,
try strpool.intern(state.strings, "some"), "some")
@@ -2635,13 +2676,16 @@ evaluate_ir_body proc(state @mut State, at, body, result_type ast.NodeId) Value
}
value :: outcome.value
source_type :: typecheck.type_of(&checked, result_type)
node :: try serialize_value(state, &module, &checked, functions.items, source_type, value, span)
node :: try serialize_value(state, &module, &checked, &outcome, functions.items, source_type, value, span)
if (node == no_node) {
if (state.evaluation_diagnostic == diagnostics.no_diagnostic)
state.evaluation_diagnostic = try diagnostics.add(state.diagnostics, .comptime_value_escape, span)
return Value{}
}
if (ir.aggregate(value.type) or source_type.kind == .enumeration or source_type.kind == .void)
if (
(ir.aggregate(value.type) and source_type.kind != .slice)
or source_type.kind == .enumeration or source_type.kind == .void
)
return Value{ kind = .aggregate, node = node, annotation = result_type }
result := try evaluate(state, node)
result.annotation = result_type