From 40a2c2adf20f0e5f6368e72c751552901feecefa Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 17:06:33 -0700 Subject: [PATCH] ProgramLifetime in sanitized builds: skip the RC audit's leak report and scan instances as LeakSanitizer roots Program-lifetime instances are never freed, and neither is anything they hold, so the RC audit of --sanitize builds reported every one of them, and every string, array and map they reference, as live at exit (4633-contract-program-lifetime failed the sanitized differential lane: 306 strings, 15 arrays, 338 objects). Like a program that abandons fibers, a program that keeps program-lifetime objects now skips the leak report with "scriptc RC audit skipped: N program-lifetime object(s) never freed", which the harnesses already filter. Sanitized constructors call scr_obj_lifetime_note(obj), which counts the instance and registers it with LeakSanitizer as an intentional root, as publication does for published objects: once the audit no longer exits early, LeakSanitizer would otherwise report the instances the program drops. Plain builds emit nothing. --- packages/compiler/src/backend/llvm/classes.test.ts | 5 +++++ packages/compiler/src/backend/llvm/classes.ts | 7 +++++++ packages/runtime/src/scr_console.c | 10 ++++++++++ packages/runtime/src/scr_object.c | 12 ++++++++++++ packages/runtime/src/scr_runtime.h | 5 +++++ 5 files changed, 39 insertions(+) diff --git a/packages/compiler/src/backend/llvm/classes.test.ts b/packages/compiler/src/backend/llvm/classes.test.ts index e5fc34099..cfcafb392 100644 --- a/packages/compiler/src/backend/llvm/classes.test.ts +++ b/packages/compiler/src/backend/llvm/classes.test.ts @@ -123,7 +123,12 @@ test("ProgramLifetime hierarchies are immortal from construction and take no ref expect(nw, cls).toContain("store i64 -1, ptr %o"); expect(nw, cls).not.toContain("scr_cyc_alloc"); expect(llvm, cls).not.toContain(`@${mangleClassTrace(cls)}.cyctype`); + // The RC audit (sanitize builds) learns that the program keeps + // program-lifetime objects; other builds emit nothing for it. + expect(nw, cls).toContain("call void @scr_obj_lifetime_note(ptr %o)"); } + const plain = emitLlvmModule(mod, { pointerBits: 64, objectAudit: false }); + expect(plain).not.toContain("scr_obj_lifetime_note"); // The mortal Owner keeps its header, counts and possible-root step. expect(body(mangleClassNew("Owner"))).toContain("store i64 1, ptr %o"); expect(body(mangleClassNew("Owner"))).toContain("scr_cyc_alloc"); diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts index 3e5d07722..00ff42ffc 100644 --- a/packages/compiler/src/backend/llvm/classes.ts +++ b/packages/compiler/src/backend/llvm/classes.ts @@ -632,6 +632,13 @@ export function emitClassShapes( ), ); nw.push(` store ${host.sizeType} ${lifetime ? -1 : 1}, ptr %o`); + if (lifetime && audit) { + // --sanitize builds: the RC audit skips its leak report when the + // program keeps program-lifetime objects (scr_console.c), and + // LeakSanitizer scans them as roots (scr_object.c). + host.declare(`declare void @scr_obj_lifetime_note(ptr)`); + nw.push(` call void @scr_obj_lifetime_note(ptr %o)`); + } if (meta.hierarchy) { nw.push( ` %vtp = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 1`, diff --git a/packages/runtime/src/scr_console.c b/packages/runtime/src/scr_console.c index bc558318d..b43d57e6d 100644 --- a/packages/runtime/src/scr_console.c +++ b/packages/runtime/src/scr_console.c @@ -43,6 +43,7 @@ extern long scr_map_live_count(void); /* scr_map.c */ extern long scr_box_live_count(void); /* scr_closure.c */ extern long scr_closure_live_count(void); /* scr_closure.c */ extern long scr_obj_live_count(void); /* scr_object.c */ +extern long scr_obj_lifetime_count(void); /* scr_object.c */ extern long scr_union_live_count(void); /* scr_union.c */ extern long scr_dyn_live_count(void); /* scr_json.c */ extern long scr_bytes_live_count(void); /* scr_bytes.c */ @@ -59,6 +60,15 @@ static void scr_rc_audit_at_exit(void) { scr_abandoned_fibers); return; } + /* ProgramLifetime instances (@scriptc/types) are never freed, and neither + * is anything they hold: a strict audit would only report that deliberate + * retention. */ + long lifetime = scr_obj_lifetime_count(); + if (lifetime > 0) { + fprintf(stderr, "scriptc RC audit skipped: %ld program-lifetime object(s) never freed\n", + lifetime); + return; + } long strings = scr_str_live_count(); long arrays = scr_arr_live_count(); long maps = scr_map_live_count(); diff --git a/packages/runtime/src/scr_object.c b/packages/runtime/src/scr_object.c index eb1bac8fb..5e5e29216 100644 --- a/packages/runtime/src/scr_object.c +++ b/packages/runtime/src/scr_object.c @@ -87,12 +87,24 @@ void scr_record_key_miss(ScrStr *k) { #ifdef SCR_RC_AUDIT static SCR_TL long scr_live_objects = 0; +/* ProgramLifetime instances (@scriptc/types) this thread created: never + * freed by design, and neither is anything they hold (see the exit audit in + * scr_console.c). */ +static SCR_TL long scr_lifetime_objects = 0; long scr_obj_live_count(void) { return scr_live_objects; } +long scr_obj_lifetime_count(void) { return scr_lifetime_objects; } void scr_obj_alloc_note(void) { scr_live_objects++; } void scr_obj_free_note(void) { scr_live_objects--; } +/* Also an intentional LeakSanitizer root, like published objects: what the + * instance holds stays reachable. */ +void scr_obj_lifetime_note(const void *obj) { + scr_lifetime_objects++; + scr_lsan_immortal(obj); +} #else #undef scr_obj_alloc_note #undef scr_obj_free_note void scr_obj_alloc_note(void) {} void scr_obj_free_note(void) {} +void scr_obj_lifetime_note(const void *obj) { (void)obj; } #endif diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 7b8544211..db6414b6e 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -5416,8 +5416,13 @@ long scr_dyn_live_count(void); */ void scr_obj_alloc_note(void); void scr_obj_free_note(void); +/* Emitted by the RC-audit lane's ProgramLifetime constructors, after the + * allocation note: such instances are never freed (the exit audit skips its + * leak report, and LeakSanitizer scans them as roots). */ +void scr_obj_lifetime_note(const void *obj); #ifdef SCR_RC_AUDIT long scr_obj_live_count(void); +long scr_obj_lifetime_count(void); #else /* Only the RC audit lane counts live objects. Other runtimes keep the * out-of-line no-op symbols for direct-emission objects that still call