Os.heap_bytes: the heap without the renderer; string_temps reads it

Vk.heap_bytes pulled the Vk module - and on lang/uifree the GL window path - into a plain program,
which then failed to link (_cgl_offscreen, lgl_GetError). Os.heap_bytes is malloc_zone_statistics
through a weak reference (0 where there is none, and on Windows). Reseeded. Docs for it and for
Json.free / Json.free_all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-28 12:31:42 +03:00
parent 1adce5b58b
commit 22d1e2d66a
10 changed files with 33227 additions and 33008 deletions

View file

@ -9,3 +9,5 @@ variable, an argument, a function's result - is left alone. A loop of 20,000 tem
2.9 MB and now grows 0 (`examples/lang/string_temps.ludic`).
A slice of a string (`s[a .. b]`, always a copy) counts too: `path[0 .. n - 4] + ".dds"` and
`path[n - 4 .. n] == ".png"` no longer leave the slice behind.
`Os.heap_bytes()` reads malloc's live bytes (macOS; 0 elsewhere) with no renderer linked in, for a
test that a path allocates nothing.

View file

@ -0,0 +1,25 @@
---
id: json-free
name: Json.free
category: json
kind: namespace-method
tokens: Json.free
sig: Json.free(value) -> void
tip: Give back a parsed tree's nodes; its strings stay.
order: 3
ns: Json
member: free
---
Gives back every node and list of a tree <a href="json-parse"><code>Json.parse</code></a> made, once what it describes has been built. Ludic has no garbage collector, so a tree read and dropped is otherwise kept for as long as the program runs. The tree's strings stay, because a reader may keep a name it took out of it; when nothing was kept, <a href="json-free_all"><code>Json.free_all</code></a> gives those back too. Only for a parsed tree: one put together by hand may share a node or hold a literal.
```ludic
program Free {
entry {
let doc = Json.parse("{\"hp\": 3}")
let hp = value_as_int(value_get(doc, "hp"))
Json.free(doc)
print(hp)
}
}
```

View file

@ -0,0 +1,26 @@
---
id: json-free_all
name: Json.free_all
category: json
kind: namespace-method
tokens: Json.free_all
sig: Json.free_all(value) -> void
tip: Give back a parsed tree whole, strings and keys included.
order: 4
ns: Json
member: free_all
---
<a href="json-free"><code>Json.free</code></a> and every string value and object key the parser made as well: for a reader that copied whatever it keeps (<code>s[0 .. len(s)]</code>), so the whole document goes. A text read, parsed and let go this way leaves nothing behind.
```ludic
program FreeAll {
entry {
let doc = Json.parse("{\"name\": \"camp\"}")
let s = value_as_str(value_get(doc, "name"))
let name = s[0 .. len(s)]
Json.free_all(doc)
print(name)
}
}
```

View file

@ -0,0 +1,25 @@
---
id: os-heap_bytes
name: Os.heap_bytes
category: os
kind: namespace-method
tokens: Os.heap_bytes
sig: Os.heap_bytes() -> long
tip: malloc's live bytes (macOS; 0 elsewhere).
order: 19
ns: Os
member: heap_bytes
---
The bytes malloc holds live across every zone of the process, on macOS; 0 on other platforms. Ludic has no garbage collector, so a path that runs every frame must give back all it takes: read this before and after many rounds of it, and the difference is what it keeps. A test built on it should allow for 0 elsewhere.
```ludic
program Heap {
entry {
let before = Os.heap_bytes()
var n = 0
for i in 0 .. 1000 { if `{i}` == "7" { n += 1 } }
print(Os.heap_bytes() - before)
}
}
```

View file

@ -42,9 +42,9 @@ program StringTemps {
var bad = 0
for i in 0 .. 200 { bad += round(i, a, b) }
if bad == 0 { print(7) }
let before = Vk.heap_bytes()
let before = Os.heap_bytes()
for i in 0 .. 20000 { bad += round(i, a, b) }
let grew = Vk.heap_bytes() - before
let grew = Os.heap_bytes() - before
if bad == 0 { print(`grew {grew}`) } else { print(`bad {bad}`) }
}
}

View file

@ -37,6 +37,7 @@ var g_uses_noisert: bool = false # Noise.* was emitted -> emit the fixed-point
var g_uses_logrt: bool = false # Log.* was emitted -> emit the log level register + console sink
var g_uses_osrt: bool = false # Os.* (prelude-backed methods) was emitted -> emit the Os runtime
var g_uses_pid: bool = false # Os.pid() was emitted -> declare libc getpid
var g_uses_heap: bool = false # Os.heap_bytes() was emitted -> @lp_os_heap_bytes
var g_uses_unicodert: bool = false # Unicode.* was emitted -> emit the UTF-8 runtime
var g_uses_fsrt: bool = false # Fs.*/Path.*/Mime.* was emitted -> emit the filesystem runtime
var g_uses_pak: bool = false # file_open was emitted -> emit the asset-pack runtime (implies fsrt)

View file

@ -350,6 +350,7 @@ function emit_program() -> void {
if g_uses_logrt { emit_log_prelude() } # @L_log_level + @lp_log_emit (levelled stderr sink)
if g_uses_osrt { emit_os_prelude() } # @lp_os_args/platform/arch/save_dir/... (libc env + uname)
if g_uses_pid and (not g_target_win) { emith("declare i32 @getpid()\n") }
if g_uses_heap { emit_os_heap() }
if g_uses_unicodert { emit_unicode_prelude() } # @lp_uni_len/valid/decode/case/truncate/grapheme (UTF-8)
if g_uses_fsrt { emit_fs_prelude() } # @fn_fs_*/fn_path_*/fn_mime_* (libc + string ops)
if g_uses_pak { emit_pak_prelude() } # @lp_pak_* asset packs + the pre-main mount ctor

View file

@ -22,6 +22,8 @@
# Os.config_dir(app) -> string per-user config directory for `app`
# Os.cache_dir(app) -> string per-user cache directory for `app`
# Os.temp_dir() -> string the system temporary directory
# Os.heap_bytes() -> long malloc's live bytes (macOS; 0 elsewhere): a test that a
# path allocates nothing, with no renderer linked in
#
# Determinism: args/env/platform are non-deterministic host input — read them at
# startup to configure the game, but keep them out of the replayable simulation.
@ -39,6 +41,7 @@ function is_os_ns(meth: pointer) -> bool {
if (meth == "exit") or (meth == "pid") or (meth == "platform") or (meth == "arch") { return true }
if (meth == "stdout_write") or (meth == "stderr_write") { return true }
if (meth == "save_dir") or (meth == "config_dir") or (meth == "cache_dir") or (meth == "temp_dir") { return true }
if (meth == "heap_bytes") { return true }
return false
}
@ -53,6 +56,11 @@ function emit_os_ns(meth: pointer, e: Node) -> Val {
return val(emit_bind(`load ptr, ptr {q}`), "string")
}
if (meth == "pid") { g_uses_pid = true; return val(emit_bind("call i32 @getpid()"), "int") }
if (meth == "heap_bytes") {
if g_target_win { return val("0", "long") }
g_uses_heap = true
return val(emit_bind("call i64 @lp_os_heap_bytes()"), "long")
}
if (meth == "exit") {
let n = emit_expr(e.kids[0])
emit(` call void @exit(i32 {n.code})\n`)
@ -243,3 +251,14 @@ function emit_os_prelude() -> void {
emith("entry:\n %buf = call ptr @malloc(i64 8192)\n call i32 @uname(ptr %buf)\n")
emith(" %m = getelementptr i8, ptr %buf, i64 1024\n ret ptr %m\n}\n")
}
# Os.heap_bytes: malloc_zone_statistics' in-use bytes, all zones. The symbol is weak, so a libc
# without it links and reads 0.
function emit_os_heap() -> void {
emith("declare extern_weak void @malloc_zone_statistics(ptr, ptr)\n")
emith("define i64 @lp_os_heap_bytes() {\n")
emith("entry:\n %has = icmp ne ptr @malloc_zone_statistics, null\n br i1 %has, label %read, label %none\n")
emith("none:\n ret i64 0\n")
emith("read:\n %st = alloca [4 x i64], align 8\n call void @malloc_zone_statistics(ptr null, ptr %st)\n")
emith(" %p = getelementptr [4 x i64], ptr %st, i32 0, i32 1\n %n = load i64, ptr %p\n ret i64 %n\n}\n")
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff