nomen-lang 0.6.9 → 0.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/audit_runtime.c +157 -19
- package/core/System/Test.nm +18 -0
- package/dist/index.mjs +257 -158
- package/package.json +1 -1
- package/src/index.ts +8 -1
- package/src/test.ts +95 -33
package/audit_runtime.c
CHANGED
|
@@ -2,44 +2,164 @@
|
|
|
2
2
|
#include <stdlib.h>
|
|
3
3
|
#include <string.h>
|
|
4
4
|
|
|
5
|
+
// The allocation ledger behind `--audit` (`nomen test --audit`,
|
|
6
|
+
// `Tester.audit_count`, and the generated harness's per-test attribution).
|
|
7
|
+
//
|
|
8
|
+
// Every wrapped block carries a 16-byte header (magic, size, state) in front of
|
|
9
|
+
// the payload the program sees, so every wrapped free can be matched against a
|
|
10
|
+
// LIVE block. The header is the whole point: a bare counter cannot tell a
|
|
11
|
+
// correct free from an incorrect one, and both failures it used to absorb are
|
|
12
|
+
// ownership bugs the generated code cannot prove away.
|
|
13
|
+
//
|
|
14
|
+
// - free of a pointer this runtime never handed out (a pool/BSS/rodata
|
|
15
|
+
// address, a libc allocation, a stale pointer kept across a realloc) used
|
|
16
|
+
// to decrement the counter like any other free. That silently hid the exact
|
|
17
|
+
// failure the allmark aarch64 sanitize runs hit — `free()` of a non-heap
|
|
18
|
+
// address aborting in libmalloc with no audit-level diagnosis — and it let
|
|
19
|
+
// the reported count drift away from the allocator's real state, so a
|
|
20
|
+
// genuine leak count and a phantom one were indistinguishable.
|
|
21
|
+
// - a second free of the SAME block (a double free, or a stale pointer whose
|
|
22
|
+
// memory the allocator already recycled) decremented a second time.
|
|
23
|
+
// - `realloc(p, 0)` frees `p`; the old counter kept it live, so every such
|
|
24
|
+
// call read as one phantom allocation.
|
|
25
|
+
//
|
|
26
|
+
// A stale free no longer decrements and no longer reaches libc `free` (it may
|
|
27
|
+
// not be ours to free at all): it is counted in `nomen_stale_frees` and
|
|
28
|
+
// reported by `nomen_audit_check`, so the run fails with a diagnosis instead of
|
|
29
|
+
// dying inside the allocator. The allocation count itself is unchanged — one
|
|
30
|
+
// increment per wrapped block, one decrement per reclaimed block — so it stays
|
|
31
|
+
// exactly as before: the audit test contract.
|
|
32
|
+
|
|
5
33
|
// Atomic: worker threads from the pool malloc/free concurrently, and a
|
|
6
34
|
// plain `long` would lose updates under that contention (showing up as
|
|
7
35
|
// spurious LEAK/negative-count failures in tests that use spawns).
|
|
8
36
|
static volatile long nomen_malloc_count = 0;
|
|
9
37
|
|
|
10
|
-
|
|
11
|
-
|
|
38
|
+
// Frees that did not match a live wrapped block (foreign pointer, double
|
|
39
|
+
// free, realloc of a stale pointer). Reported at exit; never affects the
|
|
40
|
+
// allocation count.
|
|
41
|
+
static volatile long nomen_stale_frees = 0;
|
|
42
|
+
|
|
43
|
+
#define NOMEN_AUDIT_MAGIC 0x6e6f6d656e415531UL // "nomenAU1"
|
|
44
|
+
#define NOMEN_AUDIT_LIVE 1UL
|
|
45
|
+
#define NOMEN_AUDIT_DEAD 2UL
|
|
46
|
+
|
|
47
|
+
typedef struct {
|
|
48
|
+
unsigned long magic;
|
|
49
|
+
unsigned long size;
|
|
50
|
+
unsigned long state;
|
|
51
|
+
unsigned long pad; // keeps the payload 16-byte aligned
|
|
52
|
+
} nomen_audit_header;
|
|
53
|
+
|
|
54
|
+
static void *audit_alloc(unsigned long size) {
|
|
55
|
+
nomen_audit_header *header = (nomen_audit_header *)malloc(size + sizeof(nomen_audit_header));
|
|
56
|
+
if (!header) return 0;
|
|
57
|
+
header->magic = NOMEN_AUDIT_MAGIC;
|
|
58
|
+
header->size = size;
|
|
59
|
+
header->state = NOMEN_AUDIT_LIVE;
|
|
12
60
|
__atomic_add_fetch(&nomen_malloc_count, 1, __ATOMIC_SEQ_CST);
|
|
13
|
-
return
|
|
61
|
+
return (void *)(header + 1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The header in front of `ptr`, or 0 when `ptr` is not one of ours (a libc
|
|
65
|
+
// allocation, a static/BSS address, or already-freed memory whose header was
|
|
66
|
+
// cleared).
|
|
67
|
+
static nomen_audit_header *audit_header(void *ptr) {
|
|
68
|
+
nomen_audit_header *header = (nomen_audit_header *)ptr - 1;
|
|
69
|
+
if (header->magic != NOMEN_AUDIT_MAGIC) return 0;
|
|
70
|
+
return header;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Retire a block: poison the header first so a second free of the same pointer
|
|
74
|
+
// is recognized as stale, then hand the memory back and drop the count.
|
|
75
|
+
static void audit_release(nomen_audit_header *header) {
|
|
76
|
+
header->magic = 0;
|
|
77
|
+
header->state = NOMEN_AUDIT_DEAD;
|
|
78
|
+
__atomic_sub_fetch(&nomen_malloc_count, 1, __ATOMIC_SEQ_CST);
|
|
79
|
+
free(header);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
static long audit_record_stale(void) {
|
|
83
|
+
return __atomic_add_fetch(&nomen_stale_frees, 1, __ATOMIC_SEQ_CST);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Diagnose each KIND once: a shape that double-frees per iteration would
|
|
87
|
+
// otherwise print thousands of identical lines. The exit-time
|
|
88
|
+
// AUDIT-STALE-FREE count carries the total.
|
|
89
|
+
static void audit_report_stale(int kind) {
|
|
90
|
+
static volatile int reported[2] = {0, 0};
|
|
91
|
+
static const char *const messages[2] = {
|
|
92
|
+
"audit: free of a pointer that is not a live audit allocation "
|
|
93
|
+
"(a foreign address, or one already freed)",
|
|
94
|
+
"audit: realloc of a pointer that is not a live audit allocation",
|
|
95
|
+
};
|
|
96
|
+
if (kind < 0 || kind > 1) return;
|
|
97
|
+
if (__atomic_exchange_n(&reported[kind], 1, __ATOMIC_SEQ_CST)) return;
|
|
98
|
+
fprintf(stderr, "%s\n", messages[kind]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
void *nomen_malloc_wrap(unsigned long size) {
|
|
102
|
+
return audit_alloc(size);
|
|
14
103
|
}
|
|
15
104
|
|
|
16
105
|
void *nomen_calloc_wrap(unsigned long count, unsigned long size) {
|
|
17
|
-
|
|
18
|
-
|
|
106
|
+
unsigned long total = count * size;
|
|
107
|
+
void *ptr = audit_alloc(total);
|
|
108
|
+
if (ptr) memset(ptr, 0, total);
|
|
19
109
|
return ptr;
|
|
20
110
|
}
|
|
21
111
|
|
|
112
|
+
void *nomen_strdup_wrap(const char *s) {
|
|
113
|
+
unsigned long len = strlen(s) + 1;
|
|
114
|
+
void *ptr = audit_alloc(len);
|
|
115
|
+
if (ptr) memcpy(ptr, s, len);
|
|
116
|
+
return ptr;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// realloc: the count is unchanged (one block in, one block out) — the block is
|
|
120
|
+
// only RETIRED, which is what fixes `realloc(p, 0)`: C lets it free `p` and
|
|
121
|
+
// return NULL, and the old counter left `p` live, reading as a phantom
|
|
122
|
+
// allocation. This implementation always returns the fresh block (a valid
|
|
123
|
+
// unique pointer, the other conforming option), so callers that treat a NULL
|
|
124
|
+
// result as "the buffer is gone" keep working.
|
|
125
|
+
//
|
|
126
|
+
// A stale or foreign `old_ptr` reallocates to NULL (realloc's failure mode)
|
|
127
|
+
// and is counted as a stale free: the pointer is left untouched rather than
|
|
128
|
+
// memcpy'd out of memory that isn't ours.
|
|
22
129
|
void *nomen_realloc_wrap(void *old_ptr, unsigned long size) {
|
|
23
|
-
if (!old_ptr)
|
|
24
|
-
|
|
130
|
+
if (!old_ptr) return audit_alloc(size);
|
|
131
|
+
nomen_audit_header *header = audit_header(old_ptr);
|
|
132
|
+
if (!header || header->state != NOMEN_AUDIT_LIVE) {
|
|
133
|
+
audit_record_stale();
|
|
134
|
+
audit_report_stale(1);
|
|
135
|
+
return 0;
|
|
25
136
|
}
|
|
26
|
-
|
|
137
|
+
unsigned long old_size = header->size;
|
|
138
|
+
void *ptr = audit_alloc(size);
|
|
139
|
+
// An allocation failure keeps the old block live (realloc's contract).
|
|
140
|
+
if (!ptr) return 0;
|
|
141
|
+
memcpy(ptr, old_ptr, old_size < size ? old_size : size);
|
|
142
|
+
audit_release(header);
|
|
27
143
|
return ptr;
|
|
28
144
|
}
|
|
29
145
|
|
|
30
146
|
void nomen_free_wrap(void *ptr) {
|
|
31
|
-
if (ptr)
|
|
32
|
-
|
|
33
|
-
|
|
147
|
+
if (!ptr) return;
|
|
148
|
+
nomen_audit_header *header = audit_header(ptr);
|
|
149
|
+
// Not a live wrapped block: a libc allocation, a static/BSS/rodata
|
|
150
|
+
// address, a pointer the allocator already recycled, or a block this
|
|
151
|
+
// runtime already freed (the double-free case — the header is poisoned on
|
|
152
|
+
// release, so a stale pointer can never look live). Freeing it would be an
|
|
153
|
+
// invalid free, and libmalloc aborts INSIDE free() with no context, which
|
|
154
|
+
// is exactly how the allmark aarch64 sanitize run died. Report it instead
|
|
155
|
+
// of passing it on, and leave the count alone: an unmatched free is not
|
|
156
|
+
// evidence that a live block was reclaimed.
|
|
157
|
+
if (!header || header->state != NOMEN_AUDIT_LIVE) {
|
|
158
|
+
audit_record_stale();
|
|
159
|
+
audit_report_stale(0);
|
|
160
|
+
return;
|
|
34
161
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
void *nomen_strdup_wrap(const char *s) {
|
|
38
|
-
unsigned long len = strlen(s) + 1;
|
|
39
|
-
void *ptr = malloc(len);
|
|
40
|
-
if (ptr) memcpy(ptr, s, len);
|
|
41
|
-
__atomic_add_fetch(&nomen_malloc_count, 1, __ATOMIC_SEQ_CST);
|
|
42
|
-
return ptr;
|
|
162
|
+
audit_release(header);
|
|
43
163
|
}
|
|
44
164
|
|
|
45
165
|
void nomen_audit_check(void) {
|
|
@@ -47,4 +167,22 @@ void nomen_audit_check(void) {
|
|
|
47
167
|
if (count != 0) {
|
|
48
168
|
printf("LEAK: %ld allocation(s)\n", count);
|
|
49
169
|
}
|
|
170
|
+
long stale = __atomic_load_n(&nomen_stale_frees, __ATOMIC_SEQ_CST);
|
|
171
|
+
if (stale != 0) {
|
|
172
|
+
printf("AUDIT-STALE-FREE: %ld pointer(s) freed that were not live audit allocations\n",
|
|
173
|
+
stale);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Per-test leak attribution (`nomen test`): the generated harness snapshots
|
|
178
|
+
// the counter before every test and diffs it after, so a leaking test is
|
|
179
|
+
// named instead of a whole file aggregating into one exit-time count.
|
|
180
|
+
long nomen_audit_count(void) {
|
|
181
|
+
return __atomic_load_n(&nomen_malloc_count, __ATOMIC_SEQ_CST);
|
|
50
182
|
}
|
|
183
|
+
|
|
184
|
+
// Frees that matched no live block (foreign pointer / double free). Polled the
|
|
185
|
+
// same way as the count so a run can attribute them like leaks.
|
|
186
|
+
long nomen_audit_stale_frees(void) {
|
|
187
|
+
return __atomic_load_n(&nomen_stale_frees, __ATOMIC_SEQ_CST);
|
|
188
|
+
}
|
package/core/System/Test.nm
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
//
|
|
12
12
|
// \nomen|start|<test>
|
|
13
13
|
// \nomen|fail|<test>|<message>
|
|
14
|
+
// \nomen|leaks|<test>|<n>
|
|
14
15
|
// \nomen|done|<test>|<passed>|<failed>|<ns>
|
|
15
16
|
// \nomen|bench|<label>|<n>|<min>|<median>|<max>|<mean>|<stddev>
|
|
16
17
|
//
|
|
@@ -70,6 +71,23 @@ pub class Tester {
|
|
|
70
71
|
var bench_label = ""
|
|
71
72
|
var bench_samples = 1000
|
|
72
73
|
|
|
74
|
+
// (audit) The process's current malloc/free balance — allocations made
|
|
75
|
+
// but not yet freed. `nomen test --audit` snapshots this around every
|
|
76
|
+
// test so a leak is attributed to the test that leaked instead of one
|
|
77
|
+
// file-level exit-time count. The audit runtime is linked into every
|
|
78
|
+
// test build; in other binaries the counter simply never moves.
|
|
79
|
+
pub func audit_count = (out int64) {
|
|
80
|
+
```
|
|
81
|
+
#arch: c
|
|
82
|
+
extern long nomen_audit_count(void);
|
|
83
|
+
return (long long)nomen_audit_count();
|
|
84
|
+
```
|
|
85
|
+
```
|
|
86
|
+
#arch: aarch64
|
|
87
|
+
bl _nomen_audit_count
|
|
88
|
+
```
|
|
89
|
+
}
|
|
90
|
+
|
|
73
91
|
// (harness) Start timing `name` and clear the per-test state. Called by
|
|
74
92
|
// the `main` that `nomen test` generates — not by test code.
|
|
75
93
|
pub func begin_test = (ref self, string name) {
|
package/dist/index.mjs
CHANGED
|
@@ -2122,8 +2122,40 @@ function try_parse_operands(parts) {
|
|
|
2122
2122
|
* is not an instruction — label, directive, comment, blank, OR an
|
|
2123
2123
|
* instruction outside the contract table). Shared by the validator and the
|
|
2124
2124
|
* phase-2 optimizer so both see identical shapes.
|
|
2125
|
-
|
|
2125
|
+
*
|
|
2126
|
+
* Memoized by raw text: the post-processing pipeline (frame slots, coalesce,
|
|
2127
|
+
* loop promote, if-convert, float forwarding, widen masks, …) plus the
|
|
2128
|
+
* validators each re-parse the SAME instruction texts — prologue/epilogue
|
|
2129
|
+
* lines repeat across every function, so the hit rate is very high. Every
|
|
2130
|
+
* call returns a PRIVATE deep copy (the passes treat parsed instructions as
|
|
2131
|
+
* mutable IR — coalesce renames operands in place — so sharing a parsed
|
|
2132
|
+
* object would corrupt the memo and cross-pass state).
|
|
2133
|
+
*/
|
|
2134
|
+
const parse_memo = /* @__PURE__ */ new Map();
|
|
2135
|
+
const PARSE_MEMO_CAP = 2e5;
|
|
2136
|
+
function clone_parsed(c, line) {
|
|
2137
|
+
return {
|
|
2138
|
+
text: c.text,
|
|
2139
|
+
op: c.op,
|
|
2140
|
+
setsFlags: c.setsFlags,
|
|
2141
|
+
readsFlags: c.readsFlags,
|
|
2142
|
+
operands: c.operands.map((o) => {
|
|
2143
|
+
const copy = { ...o };
|
|
2144
|
+
if (copy.kind === "mem" && copy.offset) copy.offset = { ...copy.offset };
|
|
2145
|
+
return copy;
|
|
2146
|
+
}),
|
|
2147
|
+
line
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
2126
2150
|
function parse_asm_instruction(text, line) {
|
|
2151
|
+
const cached = parse_memo.get(text);
|
|
2152
|
+
if (cached !== void 0) return cached === null ? null : clone_parsed(cached, line);
|
|
2153
|
+
const parsed = parse_asm_instruction_uncached(text, line);
|
|
2154
|
+
if (parse_memo.size >= PARSE_MEMO_CAP) parse_memo.clear();
|
|
2155
|
+
parse_memo.set(text, parsed ? clone_parsed(parsed, 0) : null);
|
|
2156
|
+
return parsed;
|
|
2157
|
+
}
|
|
2158
|
+
function parse_asm_instruction_uncached(text, line) {
|
|
2127
2159
|
const t = strip_comment$1(text).trim();
|
|
2128
2160
|
const m = /^([a-z][a-z0-9.]*)\s*(.*)$/.exec(t);
|
|
2129
2161
|
if (!m) return null;
|
|
@@ -2407,34 +2439,13 @@ function validate_asm(code) {
|
|
|
2407
2439
|
}
|
|
2408
2440
|
return errors;
|
|
2409
2441
|
}
|
|
2410
|
-
/**
|
|
2411
|
-
*
|
|
2412
|
-
*
|
|
2413
|
-
*
|
|
2414
|
-
|
|
2415
|
-
* the lifted function bodies:
|
|
2416
|
-
*
|
|
2417
|
-
* - Block splitting: labels and branch/terminator instructions bound blocks,
|
|
2418
|
-
* exactly like the CFG the emitters produce.
|
|
2419
|
-
* - Per-instruction sp effects: pre-indexed `stp/str [sp, #-N]!` subtracts,
|
|
2420
|
-
* post-indexed `ldp/ldr [sp], #N` adds, `sub/add sp, sp, #imm` adjusts;
|
|
2421
|
-
* any other sp write (`mov sp, xN`, register-scaled adds, `ldr sp, …`)
|
|
2422
|
-
* is UNKNOWN and conservatively poisons downstream deltas.
|
|
2423
|
-
* - Join = require equal deltas or unknown: a label reached by paths with
|
|
2424
|
-
* differing deltas becomes unknown; only a KNOWN non-zero delta at a
|
|
2425
|
-
* `ret` is reported. Unknown never errors — the check is conservative by
|
|
2426
|
-
* construction (no false positives, only silence).
|
|
2427
|
-
* - `bl`/`blr` preserve sp (the callee rebalances under the ABI) and do not
|
|
2428
|
-
* terminate blocks; `br xN` (tail-jump) ends propagation with no check;
|
|
2429
|
-
* `svc` continues (the kernel preserves sp).
|
|
2430
|
-
* - Branch targets resolve within the function, including the numeric local
|
|
2431
|
-
* form raw `#arch` blocks use (`1:` / `b.hs 1f` — forward/backward by
|
|
2432
|
-
* suffix); an unresolvable target makes downstream deltas unknown.
|
|
2433
|
-
*/
|
|
2434
|
-
function validate_stack_balance(code) {
|
|
2435
|
-
const { result } = lift_functions(code);
|
|
2436
|
-
if (!result.ok) return [];
|
|
2442
|
+
/** Stack-balance check over an ALREADY-lifted result — lets the build
|
|
2443
|
+
* pipeline lift+validate once and share the structured functions between
|
|
2444
|
+
* the shape/branch validation and the balance pass (lift_functions runs
|
|
2445
|
+
* validate_asm internally, so re-lifting per check doubled the cost). */
|
|
2446
|
+
function stack_balance_errors_from_lift(result) {
|
|
2437
2447
|
const errors = [];
|
|
2448
|
+
if (!result.ok) return [];
|
|
2438
2449
|
for (const fn of result.functions) check_function_balance(fn, errors);
|
|
2439
2450
|
return errors;
|
|
2440
2451
|
}
|
|
@@ -2562,37 +2573,37 @@ function line_effect(text) {
|
|
|
2562
2573
|
};
|
|
2563
2574
|
}
|
|
2564
2575
|
/** Resolve a branch target token to a body line index, or null when the
|
|
2565
|
-
* target is outside this function
|
|
2566
|
-
*
|
|
2567
|
-
|
|
2576
|
+
* target is outside this function. Backed by the per-function label maps
|
|
2577
|
+
* built once in check_function_balance — a linear rescan of the body per
|
|
2578
|
+
* pending branch used to be the single hottest spot in large builds
|
|
2579
|
+
* (branches × body-length re-stripping + regex). Numeric `Nf`/`Nb` forms
|
|
2580
|
+
* resolve directionally against the (possibly redefined) definitions,
|
|
2581
|
+
* matching the assembler's semantics; a named label resolves to its FIRST
|
|
2582
|
+
* definition (the old body scan returned the first match too). */
|
|
2583
|
+
function resolve_target(token, from, named_labels, numeric_labels) {
|
|
2568
2584
|
const numeric = /^(\d+)([fb])$/.exec(token);
|
|
2569
2585
|
if (numeric) {
|
|
2570
|
-
const
|
|
2586
|
+
const defs = numeric_labels.get(numeric[1]);
|
|
2587
|
+
if (!defs) return null;
|
|
2571
2588
|
if (numeric[2] === "f") {
|
|
2572
|
-
for (let
|
|
2573
|
-
const lm = /^(\d+):/.exec(strip_comment$1(body[i].text).trim());
|
|
2574
|
-
if (lm && lm[1] === n) return i;
|
|
2575
|
-
}
|
|
2589
|
+
for (let k = 0; k < defs.length; k++) if (defs[k] > from) return defs[k];
|
|
2576
2590
|
return null;
|
|
2577
2591
|
}
|
|
2578
|
-
for (let
|
|
2579
|
-
const lm = /^(\d+):/.exec(strip_comment$1(body[i].text).trim());
|
|
2580
|
-
if (lm && lm[1] === n) return i;
|
|
2581
|
-
}
|
|
2592
|
+
for (let k = defs.length - 1; k >= 0; k--) if (defs[k] < from) return defs[k];
|
|
2582
2593
|
return null;
|
|
2583
2594
|
}
|
|
2584
|
-
|
|
2585
|
-
const lm = LABEL_RE.exec(strip_comment$1(body[i].text).trim());
|
|
2586
|
-
if (lm && lm[1] === token) return i;
|
|
2587
|
-
}
|
|
2588
|
-
return null;
|
|
2595
|
+
return named_labels.get(token) ?? null;
|
|
2589
2596
|
}
|
|
2590
2597
|
function check_function_balance(fn, errors) {
|
|
2591
2598
|
const body = fn.body;
|
|
2599
|
+
const stripped_text = [];
|
|
2592
2600
|
const effects = [];
|
|
2593
2601
|
const is_code = [];
|
|
2602
|
+
const named_labels = /* @__PURE__ */ new Map();
|
|
2603
|
+
const numeric_labels = /* @__PURE__ */ new Map();
|
|
2594
2604
|
for (let i = 0; i < body.length; i++) {
|
|
2595
2605
|
const t = strip_comment$1(body[i].text).trim();
|
|
2606
|
+
stripped_text.push(t);
|
|
2596
2607
|
effects.push({
|
|
2597
2608
|
delta: null,
|
|
2598
2609
|
branch: null,
|
|
@@ -2601,8 +2612,15 @@ function check_function_balance(fn, errors) {
|
|
|
2601
2612
|
unknown_jump: false
|
|
2602
2613
|
});
|
|
2603
2614
|
is_code.push(false);
|
|
2604
|
-
if (!t) continue;
|
|
2605
2615
|
const lm = LABEL_RE.exec(t);
|
|
2616
|
+
if (lm) {
|
|
2617
|
+
if (/^\d+$/.test(lm[1])) {
|
|
2618
|
+
const defs = numeric_labels.get(lm[1]);
|
|
2619
|
+
if (defs) defs.push(i);
|
|
2620
|
+
else numeric_labels.set(lm[1], [i]);
|
|
2621
|
+
} else if (!named_labels.has(lm[1])) named_labels.set(lm[1], i);
|
|
2622
|
+
}
|
|
2623
|
+
if (!t) continue;
|
|
2606
2624
|
const rest = lm ? lm[2].trim() : t;
|
|
2607
2625
|
if (rest.startsWith(".") || /^[\w.$]+\s*=\s*[\w.$]+$/.test(rest) || !rest) continue;
|
|
2608
2626
|
is_code[i] = true;
|
|
@@ -2614,7 +2632,7 @@ function check_function_balance(fn, errors) {
|
|
|
2614
2632
|
let cur = -1;
|
|
2615
2633
|
let terminated = true;
|
|
2616
2634
|
for (let i = 0; i < body.length; i++) {
|
|
2617
|
-
const t =
|
|
2635
|
+
const t = stripped_text[i];
|
|
2618
2636
|
if (!t) continue;
|
|
2619
2637
|
if (LABEL_RE.test(t)) {
|
|
2620
2638
|
cur++;
|
|
@@ -2662,7 +2680,7 @@ function check_function_balance(fn, errors) {
|
|
|
2662
2680
|
}
|
|
2663
2681
|
}
|
|
2664
2682
|
for (const p of pending) {
|
|
2665
|
-
const line = resolve_target(p.token, p.from,
|
|
2683
|
+
const line = resolve_target(p.token, p.from, named_labels, numeric_labels);
|
|
2666
2684
|
blocks[p.block].targets.push(line === null ? null : line_block.get(line) ?? null);
|
|
2667
2685
|
}
|
|
2668
2686
|
const preds = Array.from({ length: blocks.length }, () => []);
|
|
@@ -8972,6 +8990,85 @@ function record_ctor_heap_default_fields(var_name, struct_node, status, is_owned
|
|
|
8972
8990
|
}
|
|
8973
8991
|
}
|
|
8974
8992
|
//#endregion
|
|
8993
|
+
//#region ../src/build_common/destroy_analysis.ts
|
|
8994
|
+
/**
|
|
8995
|
+
* Whether any field of the struct is an enum-with-data value owning heap
|
|
8996
|
+
* payloads (a string payload — always heap, case construction strdups it —
|
|
8997
|
+
* or a class/trait reference payload — construction transfers ownership).
|
|
8998
|
+
* Such a field must be reclaimed when the struct dies.
|
|
8999
|
+
*/
|
|
9000
|
+
function struct_has_owning_enum_field(struct, table) {
|
|
9001
|
+
for (const field of struct.fields) {
|
|
9002
|
+
if (field.type.is_ref) continue;
|
|
9003
|
+
const en = table.enums?.find((e) => e.name === field.type.name);
|
|
9004
|
+
if (!en?.has_associated_data) continue;
|
|
9005
|
+
for (const c of en.cases) for (const p of c.params) {
|
|
9006
|
+
if (p.type.name === "string" && !p.type.is_array) return true;
|
|
9007
|
+
if (table.traits?.some((t) => t.name === p.type.name)) return true;
|
|
9008
|
+
if (table.structs.find((s) => s.name === p.type.name)?.is_class) return true;
|
|
9009
|
+
}
|
|
9010
|
+
}
|
|
9011
|
+
return false;
|
|
9012
|
+
}
|
|
9013
|
+
/**
|
|
9014
|
+
* Whether a struct declares its own `#destroy` (user side effects /
|
|
9015
|
+
* resource release).
|
|
9016
|
+
*/
|
|
9017
|
+
function has_destroy(struct) {
|
|
9018
|
+
return !!struct.functions.find((f) => f.name === "#destroy");
|
|
9019
|
+
}
|
|
9020
|
+
/**
|
|
9021
|
+
* Whether any field (recursively) requires destroy handling — a class
|
|
9022
|
+
* field (heap instance to reclaim) or a nested struct with its own
|
|
9023
|
+
* `#destroy` (or nested fields that do). Deliberately excludes `string`
|
|
9024
|
+
* fields: a struct local whose only owning fields are strings is NOT
|
|
9025
|
+
* auto-destroyed at scope exit (the strings may be raw rodata arg
|
|
9026
|
+
* pointers, not heap) — see `struct_needs_auto_destroy` for the broader
|
|
9027
|
+
* form used to decide whether a `<Struct>_destroy` function is generated.
|
|
9028
|
+
*/
|
|
9029
|
+
function has_struct_fields_with_destroy(struct, table) {
|
|
9030
|
+
for (const field of struct.fields) {
|
|
9031
|
+
if (field.type.is_ref) continue;
|
|
9032
|
+
const field_struct = resolve_struct_type(field.type, table);
|
|
9033
|
+
if (!field_struct) continue;
|
|
9034
|
+
if (field_struct.is_class) return true;
|
|
9035
|
+
if (has_destroy(field_struct)) return true;
|
|
9036
|
+
if (has_struct_fields_with_destroy(field_struct, table)) return true;
|
|
9037
|
+
}
|
|
9038
|
+
return false;
|
|
9039
|
+
}
|
|
9040
|
+
/**
|
|
9041
|
+
* Whether a struct (or any embedded struct field, recursively) needs a
|
|
9042
|
+
* destroy call at scope exit — it has a `#destroy`, a class-typed field,
|
|
9043
|
+
* or a nested struct field that itself needs destroying. This does NOT
|
|
9044
|
+
* count `string` fields (see `has_struct_fields_with_destroy`).
|
|
9045
|
+
*/
|
|
9046
|
+
function struct_needs_destroy(struct, table) {
|
|
9047
|
+
return has_destroy(struct) || has_struct_fields_with_destroy(struct, table) || struct_has_owning_enum_field(struct, table);
|
|
9048
|
+
}
|
|
9049
|
+
/**
|
|
9050
|
+
* Whether a struct needs an auto-generated `<Struct>_destroy` function.
|
|
9051
|
+
* Broader than `struct_needs_destroy`: also returns true for structs with
|
|
9052
|
+
* `string` fields, because a Buffer<T> for such a struct deep-copies the
|
|
9053
|
+
* strings into slots (strdup on store) and the per-element destroy must
|
|
9054
|
+
* free them. The generated destroy frees each string field; it is called
|
|
9055
|
+
* from Buffer's specialized #destroy / replace_T, NOT from struct local
|
|
9056
|
+
* scope exit (`struct_needs_destroy` governs that path and excludes
|
|
9057
|
+
* strings).
|
|
9058
|
+
*/
|
|
9059
|
+
function struct_needs_auto_destroy(struct, table) {
|
|
9060
|
+
if (struct_needs_destroy(struct, table)) return true;
|
|
9061
|
+
if (struct_has_owning_enum_field(struct, table)) return true;
|
|
9062
|
+
for (const field of struct.fields) {
|
|
9063
|
+
if (field.type.is_ref) continue;
|
|
9064
|
+
if (field.type.is_view) continue;
|
|
9065
|
+
if (field.type.name === "string" && !field.type.is_array) return true;
|
|
9066
|
+
const field_struct = resolve_struct_type(field.type, table);
|
|
9067
|
+
if (field_struct && !field_struct.is_class && struct_needs_auto_destroy(field_struct, table)) return true;
|
|
9068
|
+
}
|
|
9069
|
+
return false;
|
|
9070
|
+
}
|
|
9071
|
+
//#endregion
|
|
8975
9072
|
//#region ../src/nodes/child_nodes.ts
|
|
8976
9073
|
/** Whether `value` is an AST node (carries a string `node_type`). */
|
|
8977
9074
|
function is_ast_node(value) {
|
|
@@ -11648,85 +11745,6 @@ function array_struct_name(type, status) {
|
|
|
11648
11745
|
return status.structs.find((s) => s.name === mono && !s.is_generic) ? mono : void 0;
|
|
11649
11746
|
}
|
|
11650
11747
|
//#endregion
|
|
11651
|
-
//#region ../src/build_common/destroy_analysis.ts
|
|
11652
|
-
/**
|
|
11653
|
-
* Whether any field of the struct is an enum-with-data value owning heap
|
|
11654
|
-
* payloads (a string payload — always heap, case construction strdups it —
|
|
11655
|
-
* or a class/trait reference payload — construction transfers ownership).
|
|
11656
|
-
* Such a field must be reclaimed when the struct dies.
|
|
11657
|
-
*/
|
|
11658
|
-
function struct_has_owning_enum_field(struct, table) {
|
|
11659
|
-
for (const field of struct.fields) {
|
|
11660
|
-
if (field.type.is_ref) continue;
|
|
11661
|
-
const en = table.enums?.find((e) => e.name === field.type.name);
|
|
11662
|
-
if (!en?.has_associated_data) continue;
|
|
11663
|
-
for (const c of en.cases) for (const p of c.params) {
|
|
11664
|
-
if (p.type.name === "string" && !p.type.is_array) return true;
|
|
11665
|
-
if (table.traits?.some((t) => t.name === p.type.name)) return true;
|
|
11666
|
-
if (table.structs.find((s) => s.name === p.type.name)?.is_class) return true;
|
|
11667
|
-
}
|
|
11668
|
-
}
|
|
11669
|
-
return false;
|
|
11670
|
-
}
|
|
11671
|
-
/**
|
|
11672
|
-
* Whether a struct declares its own `#destroy` (user side effects /
|
|
11673
|
-
* resource release).
|
|
11674
|
-
*/
|
|
11675
|
-
function has_destroy(struct) {
|
|
11676
|
-
return !!struct.functions.find((f) => f.name === "#destroy");
|
|
11677
|
-
}
|
|
11678
|
-
/**
|
|
11679
|
-
* Whether any field (recursively) requires destroy handling — a class
|
|
11680
|
-
* field (heap instance to reclaim) or a nested struct with its own
|
|
11681
|
-
* `#destroy` (or nested fields that do). Deliberately excludes `string`
|
|
11682
|
-
* fields: a struct local whose only owning fields are strings is NOT
|
|
11683
|
-
* auto-destroyed at scope exit (the strings may be raw rodata arg
|
|
11684
|
-
* pointers, not heap) — see `struct_needs_auto_destroy` for the broader
|
|
11685
|
-
* form used to decide whether a `<Struct>_destroy` function is generated.
|
|
11686
|
-
*/
|
|
11687
|
-
function has_struct_fields_with_destroy(struct, table) {
|
|
11688
|
-
for (const field of struct.fields) {
|
|
11689
|
-
if (field.type.is_ref) continue;
|
|
11690
|
-
const field_struct = resolve_struct_type(field.type, table);
|
|
11691
|
-
if (!field_struct) continue;
|
|
11692
|
-
if (field_struct.is_class) return true;
|
|
11693
|
-
if (has_destroy(field_struct)) return true;
|
|
11694
|
-
if (has_struct_fields_with_destroy(field_struct, table)) return true;
|
|
11695
|
-
}
|
|
11696
|
-
return false;
|
|
11697
|
-
}
|
|
11698
|
-
/**
|
|
11699
|
-
* Whether a struct (or any embedded struct field, recursively) needs a
|
|
11700
|
-
* destroy call at scope exit — it has a `#destroy`, a class-typed field,
|
|
11701
|
-
* or a nested struct field that itself needs destroying. This does NOT
|
|
11702
|
-
* count `string` fields (see `has_struct_fields_with_destroy`).
|
|
11703
|
-
*/
|
|
11704
|
-
function struct_needs_destroy(struct, table) {
|
|
11705
|
-
return has_destroy(struct) || has_struct_fields_with_destroy(struct, table) || struct_has_owning_enum_field(struct, table);
|
|
11706
|
-
}
|
|
11707
|
-
/**
|
|
11708
|
-
* Whether a struct needs an auto-generated `<Struct>_destroy` function.
|
|
11709
|
-
* Broader than `struct_needs_destroy`: also returns true for structs with
|
|
11710
|
-
* `string` fields, because a Buffer<T> for such a struct deep-copies the
|
|
11711
|
-
* strings into slots (strdup on store) and the per-element destroy must
|
|
11712
|
-
* free them. The generated destroy frees each string field; it is called
|
|
11713
|
-
* from Buffer's specialized #destroy / replace_T, NOT from struct local
|
|
11714
|
-
* scope exit (`struct_needs_destroy` governs that path and excludes
|
|
11715
|
-
* strings).
|
|
11716
|
-
*/
|
|
11717
|
-
function struct_needs_auto_destroy(struct, table) {
|
|
11718
|
-
if (struct_needs_destroy(struct, table)) return true;
|
|
11719
|
-
if (struct_has_owning_enum_field(struct, table)) return true;
|
|
11720
|
-
for (const field of struct.fields) {
|
|
11721
|
-
if (field.type.is_ref) continue;
|
|
11722
|
-
if (field.type.is_view) continue;
|
|
11723
|
-
if (field.type.name === "string" && !field.type.is_array) return true;
|
|
11724
|
-
const field_struct = resolve_struct_type(field.type, table);
|
|
11725
|
-
if (field_struct && !field_struct.is_class && struct_needs_auto_destroy(field_struct, table)) return true;
|
|
11726
|
-
}
|
|
11727
|
-
return false;
|
|
11728
|
-
}
|
|
11729
|
-
//#endregion
|
|
11730
11748
|
//#region ../src/build_common/scan_force_heap_strings.ts
|
|
11731
11749
|
function scan_force_heap_strings(statements, structs) {
|
|
11732
11750
|
const result = /* @__PURE__ */ new Set();
|
|
@@ -14909,7 +14927,33 @@ function emit_base_ptr(status, decl_name, is_class_parent) {
|
|
|
14909
14927
|
if (is_class_parent) emit_var_load(status, "x0", decl_name, 8);
|
|
14910
14928
|
else emit_var_address(status, "x0", decl_name);
|
|
14911
14929
|
}
|
|
14912
|
-
|
|
14930
|
+
/**
|
|
14931
|
+
* Reclaim a struct's owning fields in place (base + every nested owning field).
|
|
14932
|
+
*
|
|
14933
|
+
* `base_offset` (added to each field offset) and `is_class_parent` (a class's
|
|
14934
|
+
* base is a pointer LOADED from the slot; a value struct is inline AT it)
|
|
14935
|
+
* locate `decl_name`'s storage.
|
|
14936
|
+
*
|
|
14937
|
+
* `free_strings` is REQUIRED — it is a per-call-site OWNOWNITY fact, not a
|
|
14938
|
+
* per-type one, so it deliberately has no default:
|
|
14939
|
+
* - `true` from a DESTROY function (`<T>_destroy`), which only ever runs on
|
|
14940
|
+
* heap-owned storage: a class's plain string fields are always heap-owned
|
|
14941
|
+
* (`_init` strdup's defaults, assignments strdup a non-heap RHS), and a
|
|
14942
|
+
* VALUE struct arrives with the per-slot copies `store_T`/`_copy` strdup'd
|
|
14943
|
+
* (Buffer per-element teardown, the displaced-field reclaim, the
|
|
14944
|
+
* anchor-slot walk). Matches C's build_auto_destroy, which frees string
|
|
14945
|
+
* fields unconditionally.
|
|
14946
|
+
* - `false` from struct-LOCAL scope exit, where a string field may still hold
|
|
14947
|
+
* a rodata literal (aarch64 constructors don't strdup) and freeing it would
|
|
14948
|
+
* `free` rodata (SIGABRT).
|
|
14949
|
+
*
|
|
14950
|
+
* A default (`= true`) previously stood in for the `true` arm while
|
|
14951
|
+
* `node.is_class` (undefined on plain parsed structs) was passed as if it
|
|
14952
|
+
* selected between the arms — so the day `is_class` becomes explicitly false,
|
|
14953
|
+
* value-struct slot strings would silently stop being reclaimed. Passing the
|
|
14954
|
+
* boolean at every call site makes both arms visible.
|
|
14955
|
+
*/
|
|
14956
|
+
function emit_field_destroys(status, struct_type, decl_name, base_offset, is_class_parent, free_strings) {
|
|
14913
14957
|
const destroy_fields = struct_type.is_class ? layout_fields(struct_type, status) : struct_type.fields;
|
|
14914
14958
|
for (const field of destroy_fields) {
|
|
14915
14959
|
const offset = get_field_offset_of_fields(destroy_fields, field.name, status);
|
|
@@ -21215,7 +21259,7 @@ function build_destroy_function(node, func, status) {
|
|
|
21215
21259
|
status.code = status.code.slice(0, after_prologue) + saves + status.code.slice(after_prologue);
|
|
21216
21260
|
}
|
|
21217
21261
|
}
|
|
21218
|
-
if (node.is_class) emit_field_destroys(status, node, "self", void 0, false);
|
|
21262
|
+
if (node.is_class) emit_field_destroys(status, node, "self", void 0, false, true);
|
|
21219
21263
|
emit_asm(status, `${return_label}:\n`);
|
|
21220
21264
|
const total_stack = Math.ceil((status.stack_size || 0) / 16) * 16;
|
|
21221
21265
|
status.code = status.code.replace(`sub sp, sp, #${stack_placeholder}`, total_stack > 0 ? `sub sp, sp, #${total_stack}` : `// no stack needed`);
|
|
@@ -21272,7 +21316,7 @@ function build_auto_destroy_function(node, status) {
|
|
|
21272
21316
|
status.function_param_regs.set("self", "x19");
|
|
21273
21317
|
emit_asm(status, `sub sp, sp, #${stack_placeholder}\n`);
|
|
21274
21318
|
emit_asm(status, `mov x29, sp\n`);
|
|
21275
|
-
emit_field_destroys(status, node, "self", void 0, false,
|
|
21319
|
+
emit_field_destroys(status, node, "self", void 0, false, true);
|
|
21276
21320
|
emit_asm(status, `${return_label}:\n`);
|
|
21277
21321
|
const total_stack = Math.ceil((status.stack_size || 0) / 16) * 16;
|
|
21278
21322
|
status.code = status.code.replace(`sub sp, sp, #${stack_placeholder}`, total_stack > 0 ? `sub sp, sp, #${total_stack}` : `// no stack needed`);
|
|
@@ -24988,6 +25032,17 @@ function build_declaration_node$1(node, status, nir_init, nir_swap) {
|
|
|
24988
25032
|
check_heap();
|
|
24989
25033
|
return;
|
|
24990
25034
|
}
|
|
25035
|
+
const decl_string_field_read = node.type.name === "string" && !node.type.is_view && !node.type.is_array && size === 16 && node.value.node_type === "access" && node.value.access.node_type === "access_field";
|
|
25036
|
+
if (decl_string_field_read && !!status.force_heap_strings?.has(node.name)) {
|
|
25037
|
+
emit_init_value$1(node.value, nir_init, status);
|
|
25038
|
+
ensure_newline(status);
|
|
25039
|
+
emit_strdup_string(status);
|
|
25040
|
+
emit_var_store(status, "x0", node.name, size);
|
|
25041
|
+
mark_heap_string(status, node.name);
|
|
25042
|
+
status.last_result_is_heap = false;
|
|
25043
|
+
return;
|
|
25044
|
+
}
|
|
25045
|
+
if (decl_string_field_read) status.last_result_is_heap = false;
|
|
24991
25046
|
emit_init_value$1(node.value, nir_init, status);
|
|
24992
25047
|
emit_var_store(status, "x0", node.name, size);
|
|
24993
25048
|
check_heap();
|
|
@@ -41684,7 +41739,9 @@ function build_assignment_node(node, status, nir_rhs, nir_swap) {
|
|
|
41684
41739
|
const rhs_is_string_var = !node.swap && !!rhs_value && typeof rhs_value.value === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(rhs_value.value) && rhs_value.value !== "true" && rhs_value.value !== "false" && rhs_value.value !== "null" && !rhs_value.is_moved && size === 16 && rhs_value.type?.name === "string" && !rhs_value.type?.is_view;
|
|
41685
41740
|
const move_source = rhs_is_string_var && node.last_use_move && move_on_last_use_enabled() ? rhs_value.value : void 0;
|
|
41686
41741
|
const explicit_move_source = !node.swap && !!rhs_value && rhs_value.is_moved && size === 16 && rhs_value.type?.name === "string" && !rhs_value.type?.is_view ? rhs_value.value : void 0;
|
|
41687
|
-
const
|
|
41742
|
+
const rhs_is_borrow_reception = !node.swap && size === 16 && is_string_borrow(node.right_value);
|
|
41743
|
+
const rhs_is_field_read = !node.swap && size === 16 && node.right_value.node_type === "access" && node.right_value.access.node_type === "access_field";
|
|
41744
|
+
const borrow_needs_dup = (rhs_is_borrow_reception || rhs_is_field_read) && !!status.force_heap_strings?.has(name);
|
|
41688
41745
|
const move_source_owns_heap = move_source !== void 0 && !!status.heap_strings?.has(move_source);
|
|
41689
41746
|
if (rhs_is_view_into_owned) {
|
|
41690
41747
|
if (!status.heap_strings) status.heap_strings = /* @__PURE__ */ new Set();
|
|
@@ -41694,7 +41751,8 @@ function build_assignment_node(node, status, nir_rhs, nir_swap) {
|
|
|
41694
41751
|
if (move_source !== void 0 && move_source_owns_heap) status.heap_strings?.delete(move_source);
|
|
41695
41752
|
if (!status.heap_strings) status.heap_strings = /* @__PURE__ */ new Set();
|
|
41696
41753
|
status.heap_strings.add(name);
|
|
41697
|
-
} else if (status.last_result_is_heap
|
|
41754
|
+
} else if (rhs_is_field_read) status.last_result_is_heap = false;
|
|
41755
|
+
else if (status.last_result_is_heap) {
|
|
41698
41756
|
if (!status.heap_strings) status.heap_strings = /* @__PURE__ */ new Set();
|
|
41699
41757
|
status.heap_strings.add(name);
|
|
41700
41758
|
} else if (explicit_move_source !== void 0 && status.heap_strings?.delete(explicit_move_source)) status.heap_strings.add(name);
|
|
@@ -41911,6 +41969,22 @@ function build_assignment_node(node, status, nir_rhs, nir_swap) {
|
|
|
41911
41969
|
get_base_address(access, status, "x0");
|
|
41912
41970
|
emit_asm(status, `str x0, [sp, #-16]!\n`);
|
|
41913
41971
|
}
|
|
41972
|
+
if (!node.operator && node.right_value.node_type === "value" && node.right_value.is_moved) {
|
|
41973
|
+
const field_resolved = resolve_struct_type(field_type, status);
|
|
41974
|
+
const field_target_var = access.target.node_type === "value" ? access.target.value : "";
|
|
41975
|
+
const first_init_write = is_first_init_field_write(field_target_var, field_name, status);
|
|
41976
|
+
mark_init_field_write(field_target_var, field_name, status);
|
|
41977
|
+
if (field_resolved && !field_resolved.is_class && !first_init_write && struct_needs_auto_destroy(field_resolved, status)) {
|
|
41978
|
+
if (base_reg !== void 0) {
|
|
41979
|
+
if (offset !== 0) emit_asm(status, `add x0, ${base_reg}, #${offset}\n`);
|
|
41980
|
+
else emit_asm(status, `mov x0, ${base_reg}\n`);
|
|
41981
|
+
} else {
|
|
41982
|
+
emit_asm(status, `ldr x0, [sp]\n`);
|
|
41983
|
+
if (offset !== 0) emit_asm(status, `add x0, x0, #${offset}\n`);
|
|
41984
|
+
}
|
|
41985
|
+
emit_asm(status, `bl ${field_resolved.name}_destroy\n`);
|
|
41986
|
+
}
|
|
41987
|
+
}
|
|
41914
41988
|
get_source_address(node.right_value, status, nir_rhs);
|
|
41915
41989
|
ensure_newline(status);
|
|
41916
41990
|
emit_asm(status, `mov x1, x0\n`);
|
|
@@ -47312,7 +47386,8 @@ function build(root, options = {}) {
|
|
|
47312
47386
|
status.code = status.code.replaceAll("bl _strdup\n", "bl _nomen_strdup_wrap\n");
|
|
47313
47387
|
status.code = status.code.replaceAll("bl _free\n", "bl _nomen_free_wrap\n");
|
|
47314
47388
|
}
|
|
47315
|
-
const
|
|
47389
|
+
const { result: lifted } = lift_functions(status.code);
|
|
47390
|
+
const lift_errors = lifted.errors;
|
|
47316
47391
|
if (lift_errors.length > 0) {
|
|
47317
47392
|
if (!status.build_errors) status.build_errors = [];
|
|
47318
47393
|
for (const e of lift_errors.slice(0, 20)) status.build_errors.push({
|
|
@@ -47320,7 +47395,7 @@ function build(root, options = {}) {
|
|
|
47320
47395
|
start: 0
|
|
47321
47396
|
});
|
|
47322
47397
|
}
|
|
47323
|
-
const balance_errors =
|
|
47398
|
+
const balance_errors = stack_balance_errors_from_lift(lifted);
|
|
47324
47399
|
if (balance_errors.length > 0) {
|
|
47325
47400
|
if (!status.build_errors) status.build_errors = [];
|
|
47326
47401
|
for (const e of balance_errors.slice(0, 20)) status.build_errors.push({
|
|
@@ -63125,11 +63200,19 @@ function generate_harness(tests, benches) {
|
|
|
63125
63200
|
const n = Math.min(raw, 4096);
|
|
63126
63201
|
return template.replace(/__NAME__/g, b.name).replace(/__TARGET__/g, b.target).replace(/__N__/g, String(n));
|
|
63127
63202
|
}).join("\n");
|
|
63128
|
-
let main = "\nimport System\nimport System::Test\n
|
|
63203
|
+
let main = "\nimport System\nimport System::Test\n";
|
|
63204
|
+
main += "\npub func main = () {\n";
|
|
63129
63205
|
main += " var Tester t = Tester()\n";
|
|
63206
|
+
main += " var int64 audit_mark = 0\n";
|
|
63207
|
+
main += " var int64 audit_leaked = 0\n";
|
|
63130
63208
|
for (const test of tests) {
|
|
63209
|
+
main += `\taudit_mark = Tester.audit_count()\n`;
|
|
63131
63210
|
main += `\tt.begin_test("${escape_nm_string(test.name)}")\n`;
|
|
63132
63211
|
main += `\t${test.name}(ref t)\n`;
|
|
63212
|
+
main += `\taudit_leaked = Tester.audit_count() - audit_mark\n`;
|
|
63213
|
+
main += `\tif audit_leaked > 0 {\n`;
|
|
63214
|
+
main += `\t\tConsole.write_line("\\\\nomen|leaks|" + "${escape_nm_string(test.name)}" + "|\\{audit_leaked}")\n`;
|
|
63215
|
+
main += `\t}\n`;
|
|
63133
63216
|
main += `\tt.end_test()\n`;
|
|
63134
63217
|
}
|
|
63135
63218
|
for (const bench of benches) {
|
|
@@ -63148,6 +63231,7 @@ function parse_records(stdout) {
|
|
|
63148
63231
|
const tests = [];
|
|
63149
63232
|
const fails = [];
|
|
63150
63233
|
const benches = [];
|
|
63234
|
+
const leaks = [];
|
|
63151
63235
|
const other = [];
|
|
63152
63236
|
for (const line of stdout.split("\n")) {
|
|
63153
63237
|
if (!line.startsWith(RECORD_PREFIX)) {
|
|
@@ -63166,6 +63250,10 @@ function parse_records(stdout) {
|
|
|
63166
63250
|
test: parts[1],
|
|
63167
63251
|
message: parts.slice(2).join("|")
|
|
63168
63252
|
});
|
|
63253
|
+
else if (kind === "leaks") leaks.push({
|
|
63254
|
+
test: parts[1],
|
|
63255
|
+
count: parseInt(parts[2] || "0", 10)
|
|
63256
|
+
});
|
|
63169
63257
|
else if (kind === "bench") benches.push({
|
|
63170
63258
|
label: parts[1],
|
|
63171
63259
|
n: parseInt(parts[2] || "0", 10),
|
|
@@ -63180,12 +63268,19 @@ function parse_records(stdout) {
|
|
|
63180
63268
|
tests,
|
|
63181
63269
|
fails,
|
|
63182
63270
|
benches,
|
|
63271
|
+
leaks,
|
|
63183
63272
|
other
|
|
63184
63273
|
};
|
|
63185
63274
|
}
|
|
63186
|
-
/**
|
|
63275
|
+
/**
|
|
63276
|
+
* Pull the exit-time audit lines out of stdout: "LEAK: N allocation(s)" (a
|
|
63277
|
+
* nonzero malloc/free balance) and "AUDIT-STALE-FREE: N pointer(s) freed that
|
|
63278
|
+
* were not live audit allocations" (a free that matched no live block — a
|
|
63279
|
+
* foreign pointer or a double free; the runtime no longer passes those to
|
|
63280
|
+
* libc free, so this is the only record they leave). Both fail the file.
|
|
63281
|
+
*/
|
|
63187
63282
|
function extract_leaks(stdout) {
|
|
63188
|
-
return stdout.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("LEAK:"));
|
|
63283
|
+
return stdout.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("LEAK:") || l.startsWith("AUDIT-STALE-FREE:"));
|
|
63189
63284
|
}
|
|
63190
63285
|
/**
|
|
63191
63286
|
* Compile one `*.test.nm` file (with the generated harness) and run it,
|
|
@@ -63201,8 +63296,9 @@ async function run_test_file(entry_path, lib_path, arch, audit = false, audit_ru
|
|
|
63201
63296
|
tests: [],
|
|
63202
63297
|
fails: [],
|
|
63203
63298
|
benches: [],
|
|
63204
|
-
other: [],
|
|
63205
63299
|
leaks: [],
|
|
63300
|
+
leaking_tests: [],
|
|
63301
|
+
other: [],
|
|
63206
63302
|
ms: 0
|
|
63207
63303
|
};
|
|
63208
63304
|
const resolved = path.resolve(entry_path);
|
|
@@ -63230,24 +63326,21 @@ async function run_test_file(entry_path, lib_path, arch, audit = false, audit_ru
|
|
|
63230
63326
|
}
|
|
63231
63327
|
const buildDir = build_dir_for(resolved, true);
|
|
63232
63328
|
if (!fs.existsSync(buildDir)) fs.mkdirSync(buildDir, { recursive: true });
|
|
63233
|
-
|
|
63234
|
-
if (
|
|
63235
|
-
|
|
63236
|
-
|
|
63237
|
-
|
|
63238
|
-
|
|
63239
|
-
|
|
63240
|
-
result.ms = performance.now() - start;
|
|
63241
|
-
return result;
|
|
63242
|
-
}
|
|
63243
|
-
audit_obj = path.join(buildDir, "audit_runtime.o");
|
|
63244
|
-
await execFile_async("clang", [
|
|
63245
|
-
"-c",
|
|
63246
|
-
runtime_src,
|
|
63247
|
-
"-o",
|
|
63248
|
-
audit_obj
|
|
63249
|
-
]);
|
|
63329
|
+
const runtime_src = audit_runtime ? path.resolve(audit_runtime) : find_audit_runtime(path.dirname(resolved)) ?? find_bundled_audit_runtime();
|
|
63330
|
+
if (!runtime_src || !fs.existsSync(runtime_src)) {
|
|
63331
|
+
result.ok = false;
|
|
63332
|
+
result.phase = "setup";
|
|
63333
|
+
result.crashed = "audit_runtime.c was not found. Pass --audit-runtime <path>.";
|
|
63334
|
+
result.ms = performance.now() - start;
|
|
63335
|
+
return result;
|
|
63250
63336
|
}
|
|
63337
|
+
const audit_obj = path.join(buildDir, "audit_runtime.o");
|
|
63338
|
+
await execFile_async("clang", [
|
|
63339
|
+
"-c",
|
|
63340
|
+
runtime_src,
|
|
63341
|
+
"-o",
|
|
63342
|
+
audit_obj
|
|
63343
|
+
]);
|
|
63251
63344
|
const platform = default_platform();
|
|
63252
63345
|
const ext = arch === "aarch64" ? ".s" : platform === "macos" || platform === "ios" ? ".m" : ".c";
|
|
63253
63346
|
const codefile = path.join(buildDir, path.basename(entry_path, ".nm") + ext);
|
|
@@ -63297,9 +63390,10 @@ async function run_test_file(entry_path, lib_path, arch, audit = false, audit_ru
|
|
|
63297
63390
|
result.tests = records.tests;
|
|
63298
63391
|
result.fails = records.fails;
|
|
63299
63392
|
result.benches = records.benches;
|
|
63393
|
+
result.leaking_tests = records.leaks;
|
|
63300
63394
|
result.other = records.other;
|
|
63301
63395
|
result.leaks = audit ? extract_leaks(runStdout) : [];
|
|
63302
|
-
if (result.leaks.length) result.other = result.other.filter((l) => !l.trim().startsWith("LEAK:"));
|
|
63396
|
+
if (result.leaks.length) result.other = result.other.filter((l) => !l.trim().startsWith("LEAK:") && !l.trim().startsWith("AUDIT-STALE-FREE:"));
|
|
63303
63397
|
result.crashed = crashed;
|
|
63304
63398
|
if (crashed) result.phase = "run";
|
|
63305
63399
|
result.ok = !(result.fails.length > 0 || result.tests.some((t) => t.failed > 0)) && crashed === void 0 && result.leaks.length === 0;
|
|
@@ -63340,6 +63434,10 @@ function report_file(result) {
|
|
|
63340
63434
|
if (result.crashed) console.log(C.red(` ${result.crashed}`));
|
|
63341
63435
|
for (const f of result.fails) console.log(` ${C.red("✗")} ${f.test} ${C.dim(">")} ${f.message}`);
|
|
63342
63436
|
for (const leak of result.leaks) console.log(` ${C.yellow("⚠")} ${C.yellow(leak)}`);
|
|
63437
|
+
if (result.leaks.length) {
|
|
63438
|
+
for (const lt of result.leaking_tests) console.log(` ${C.yellow("⚠")} ${lt.test} ${C.dim(`leaked ${lt.count} allocation(s)`)}`);
|
|
63439
|
+
if (!result.leaking_tests.length) console.log(` ${C.yellow("⚠")} ${C.yellow("LEAK: no per-test attribution — leaked outside a test (harness/setup/teardown) or crashed mid-test")}`);
|
|
63440
|
+
}
|
|
63343
63441
|
for (const b of result.benches) console.log(` ${C.cyan("⏱")} ${b.label} ${C.dim(`(n=${b.n})`)} ${C.dim("min")} ${fmt_ns(b.min)} ${C.dim("median")} ${fmt_ns(b.median)} ${C.dim("mean")} ${fmt_ns(Math.round(b.mean))} ${C.dim("max")} ${fmt_ns(b.max)} ${C.dim("±")} ${fmt_ns(Math.round(b.stddev))}`);
|
|
63344
63442
|
if (result.other.length) {
|
|
63345
63443
|
console.log(C.dim(" --- test stdout ---"));
|
|
@@ -63395,11 +63493,12 @@ async function runTests(root, options = {}) {
|
|
|
63395
63493
|
const totalFailed = results.reduce((a, r) => a + r.tests.reduce((x, t) => x + t.failed, 0), 0);
|
|
63396
63494
|
const totalPassed = totalTests - totalFailed;
|
|
63397
63495
|
const totalLeaks = results.reduce((a, r) => a + r.leaks.length, 0);
|
|
63496
|
+
const totalLeakingTests = results.reduce((a, r) => a + r.leaking_tests.length, 0);
|
|
63398
63497
|
const anyFailed = results.some((r) => !r.ok);
|
|
63399
63498
|
console.log("");
|
|
63400
63499
|
console.log(` ${C.dim("Files ")} ${C.green(`${totalFilesPassed} passed`)}` + (totalFilesFailed ? ` ${C.dim("|")} ${C.red(`${totalFilesFailed} failed`)}` : "") + C.dim(` (${totalFiles})`));
|
|
63401
63500
|
console.log(` ${C.dim("Tests ")} ${C.green(`${totalPassed} passed`)}` + (totalFailed ? ` ${C.dim("|")} ${C.red(`${totalFailed} failed`)}` : "") + C.dim(` (${totalTests})`));
|
|
63402
|
-
if (totalLeaks) console.log(` ${C.dim("Leaks ")} ${C.yellow(`${
|
|
63501
|
+
if (totalLeaks) console.log(` ${C.dim("Leaks ")} ${C.yellow(`${totalLeakingTests} leaking test(s)`)}` + (totalLeakingTests !== totalLeaks ? C.dim(` (${totalLeaks} leaked file(s))`) : "") + C.dim(" (--audit; leaking tests fail their file)"));
|
|
63403
63502
|
console.log(` ${C.dim(" Time ")} ${format_duration(elapsed)}`);
|
|
63404
63503
|
console.log("");
|
|
63405
63504
|
return !anyFailed;
|
|
@@ -63681,7 +63780,7 @@ function processFile(filename, config, mode, program_args) {
|
|
|
63681
63780
|
console.log(`Created ${codefile} in ${(compileTime - startTime).toFixed(2)}ms`);
|
|
63682
63781
|
console.log("");
|
|
63683
63782
|
startTime = performance.now();
|
|
63684
|
-
const audit_obj = config.audit ? compile_audit_runtime(config, resolved, buildDir) : void 0;
|
|
63783
|
+
const audit_obj = config.audit || result.code.includes("nomen_audit_count") ? compile_audit_runtime(config, resolved, buildDir) : void 0;
|
|
63685
63784
|
let link_inputs = codefile;
|
|
63686
63785
|
if (companionfile) link_inputs += ` ${companionfile}`;
|
|
63687
63786
|
if (audit_obj) link_inputs += ` ${audit_obj}`;
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -409,7 +409,14 @@ function processFile(filename: string, config: Config, mode: Mode, program_args:
|
|
|
409
409
|
// `build` links the executable but does not run it; `run` links and runs.
|
|
410
410
|
startTime = performance.now();
|
|
411
411
|
|
|
412
|
-
|
|
412
|
+
// The audit runtime is linked under --audit, and also whenever the
|
|
413
|
+
// program references it: `Tester.audit_count` (System::Test) has a raw
|
|
414
|
+
// body calling `nomen_audit_count`, so a Tester-using program run without
|
|
415
|
+
// --audit still needs the (inert) counter object at link time.
|
|
416
|
+
const needs_audit_runtime = config.audit || result.code.includes("nomen_audit_count");
|
|
417
|
+
const audit_obj = needs_audit_runtime
|
|
418
|
+
? compile_audit_runtime(config, resolved, buildDir)
|
|
419
|
+
: undefined;
|
|
413
420
|
let link_inputs = codefile;
|
|
414
421
|
if (companionfile) link_inputs += ` ${companionfile}`;
|
|
415
422
|
if (audit_obj) link_inputs += ` ${audit_obj}`;
|
package/src/test.ts
CHANGED
|
@@ -49,6 +49,10 @@ export interface TestFileResult {
|
|
|
49
49
|
// "LEAK: N allocation(s)" lines the audits runtime printed at exit. Empty
|
|
50
50
|
// unless the file was run with --audit; a non-empty list fails the file.
|
|
51
51
|
leaks: string[];
|
|
52
|
+
// Per-test leak attribution (from the harness's `\nomen|leaks` records):
|
|
53
|
+
// the tests whose allocation balance grew while they ran. Collected in
|
|
54
|
+
// every run; only --audit fails the file on them.
|
|
55
|
+
leaking_tests: LeakRecord[];
|
|
52
56
|
// Set when the compiled binary crashed before finishing (e.g. a segfault
|
|
53
57
|
// in a test). We still surface whatever records it managed to emit.
|
|
54
58
|
crashed?: string;
|
|
@@ -83,6 +87,13 @@ interface BenchRecord {
|
|
|
83
87
|
stddev: number;
|
|
84
88
|
}
|
|
85
89
|
|
|
90
|
+
// A `\nomen|leaks|<test>|<n>` record: the test's allocation balance grew by
|
|
91
|
+
// `n` between the harness's counter snapshot and the test's end.
|
|
92
|
+
interface LeakRecord {
|
|
93
|
+
test: string;
|
|
94
|
+
count: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
86
97
|
/** Recursively collect `*.test.nm` files under `root`, skipping build output. */
|
|
87
98
|
export function collect_test_files(root: string): string[] {
|
|
88
99
|
const out: string[] = [];
|
|
@@ -201,11 +212,25 @@ export function generate_harness(tests: TestFunction[], benches: BenchFunction[]
|
|
|
201
212
|
})
|
|
202
213
|
.join("\n");
|
|
203
214
|
|
|
204
|
-
let main = "\nimport System\nimport System::Test\n
|
|
215
|
+
let main = "\nimport System\nimport System::Test\n";
|
|
216
|
+
// Per-test leak attribution: the harness snapshots the audit counter
|
|
217
|
+
// (Tester.audit_count — the audit runtime is linked into every test
|
|
218
|
+
// build, see run_test_file) before every test and diffs it after. A
|
|
219
|
+
// positive delta emits a `\nomen|leaks|<test>|<n>` record, which the CLI
|
|
220
|
+
// reports as a leaking test instead of leaving the file's exit-time
|
|
221
|
+
// total unattributed.
|
|
222
|
+
main += "\npub func main = () {\n";
|
|
205
223
|
main += "\tvar Tester t = Tester()\n";
|
|
224
|
+
main += "\tvar int64 audit_mark = 0\n";
|
|
225
|
+
main += "\tvar int64 audit_leaked = 0\n";
|
|
206
226
|
for (const test of tests) {
|
|
227
|
+
main += `\taudit_mark = Tester.audit_count()\n`;
|
|
207
228
|
main += `\tt.begin_test("${escape_nm_string(test.name)}")\n`;
|
|
208
229
|
main += `\t${test.name}(ref t)\n`;
|
|
230
|
+
main += `\taudit_leaked = Tester.audit_count() - audit_mark\n`;
|
|
231
|
+
main += `\tif audit_leaked > 0 {\n`;
|
|
232
|
+
main += `\t\tConsole.write_line("\\\\nomen|leaks|" + "${escape_nm_string(test.name)}" + "|\\{audit_leaked}")\n`;
|
|
233
|
+
main += `\t}\n`;
|
|
209
234
|
main += `\tt.end_test()\n`;
|
|
210
235
|
}
|
|
211
236
|
for (const bench of benches) {
|
|
@@ -227,6 +252,7 @@ interface RunRecord {
|
|
|
227
252
|
tests: TestRecord[];
|
|
228
253
|
fails: FailRecord[];
|
|
229
254
|
benches: BenchRecord[];
|
|
255
|
+
leaks: LeakRecord[];
|
|
230
256
|
other: string[];
|
|
231
257
|
}
|
|
232
258
|
|
|
@@ -235,6 +261,7 @@ export function parse_records(stdout: string): RunRecord {
|
|
|
235
261
|
const tests: TestRecord[] = [];
|
|
236
262
|
const fails: FailRecord[] = [];
|
|
237
263
|
const benches: BenchRecord[] = [];
|
|
264
|
+
const leaks: LeakRecord[] = [];
|
|
238
265
|
const other: string[] = [];
|
|
239
266
|
for (const line of stdout.split("\n")) {
|
|
240
267
|
if (!line.startsWith(RECORD_PREFIX)) {
|
|
@@ -254,6 +281,8 @@ export function parse_records(stdout: string): RunRecord {
|
|
|
254
281
|
});
|
|
255
282
|
} else if (kind === "fail") {
|
|
256
283
|
fails.push({ test: parts[1], message: parts.slice(2).join("|") });
|
|
284
|
+
} else if (kind === "leaks") {
|
|
285
|
+
leaks.push({ test: parts[1], count: parseInt(parts[2] || "0", 10) });
|
|
257
286
|
} else if (kind === "bench") {
|
|
258
287
|
benches.push({
|
|
259
288
|
label: parts[1],
|
|
@@ -266,15 +295,21 @@ export function parse_records(stdout: string): RunRecord {
|
|
|
266
295
|
});
|
|
267
296
|
}
|
|
268
297
|
}
|
|
269
|
-
return { tests, fails, benches, other };
|
|
298
|
+
return { tests, fails, benches, leaks, other };
|
|
270
299
|
}
|
|
271
300
|
|
|
272
|
-
/**
|
|
301
|
+
/**
|
|
302
|
+
* Pull the exit-time audit lines out of stdout: "LEAK: N allocation(s)" (a
|
|
303
|
+
* nonzero malloc/free balance) and "AUDIT-STALE-FREE: N pointer(s) freed that
|
|
304
|
+
* were not live audit allocations" (a free that matched no live block — a
|
|
305
|
+
* foreign pointer or a double free; the runtime no longer passes those to
|
|
306
|
+
* libc free, so this is the only record they leave). Both fail the file.
|
|
307
|
+
*/
|
|
273
308
|
export function extract_leaks(stdout: string): string[] {
|
|
274
309
|
return stdout
|
|
275
310
|
.split("\n")
|
|
276
311
|
.map((l) => l.trim())
|
|
277
|
-
.filter((l) => l.startsWith("LEAK:"));
|
|
312
|
+
.filter((l) => l.startsWith("LEAK:") || l.startsWith("AUDIT-STALE-FREE:"));
|
|
278
313
|
}
|
|
279
314
|
|
|
280
315
|
/**
|
|
@@ -302,8 +337,9 @@ export async function run_test_file(
|
|
|
302
337
|
tests: [],
|
|
303
338
|
fails: [],
|
|
304
339
|
benches: [],
|
|
305
|
-
other: [],
|
|
306
340
|
leaks: [],
|
|
341
|
+
leaking_tests: [],
|
|
342
|
+
other: [],
|
|
307
343
|
ms: 0,
|
|
308
344
|
};
|
|
309
345
|
|
|
@@ -343,29 +379,27 @@ export async function run_test_file(
|
|
|
343
379
|
|
|
344
380
|
const buildDir = build_dir_for(resolved, true);
|
|
345
381
|
if (!fs.existsSync(buildDir)) fs.mkdirSync(buildDir, { recursive: true });
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
return result;
|
|
365
|
-
}
|
|
366
|
-
audit_obj = path.join(buildDir, "audit_runtime.o");
|
|
367
|
-
await execFile_async("clang", ["-c", runtime_src, "-o", audit_obj]);
|
|
382
|
+
// The audit runtime is linked into EVERY test build: the generated
|
|
383
|
+
// harness polls nomen_audit_count() around every test for per-test leak
|
|
384
|
+
// attribution, and under --audit the generated main additionally calls
|
|
385
|
+
// nomen_audit_check() at exit, printing "LEAK: N allocation(s)" when the
|
|
386
|
+
// balance is nonzero.
|
|
387
|
+
// Runtime discovery: explicit flag wins; else walk up from the test file
|
|
388
|
+
// (a nomen checkout's src/audit_runtime.c); else the runtime bundled with
|
|
389
|
+
// the CLI (live repo copy in the dev tree, shipped copy when published)
|
|
390
|
+
// so it works outside the nomen checkout with no flag.
|
|
391
|
+
const runtime_src = audit_runtime
|
|
392
|
+
? path.resolve(audit_runtime)
|
|
393
|
+
: (find_audit_runtime(path.dirname(resolved)) ?? find_bundled_audit_runtime());
|
|
394
|
+
if (!runtime_src || !fs.existsSync(runtime_src)) {
|
|
395
|
+
result.ok = false;
|
|
396
|
+
result.phase = "setup";
|
|
397
|
+
result.crashed = "audit_runtime.c was not found. Pass --audit-runtime <path>.";
|
|
398
|
+
result.ms = performance.now() - start;
|
|
399
|
+
return result;
|
|
368
400
|
}
|
|
401
|
+
const audit_obj = path.join(buildDir, "audit_runtime.o");
|
|
402
|
+
await execFile_async("clang", ["-c", runtime_src, "-o", audit_obj]);
|
|
369
403
|
const platform = default_platform();
|
|
370
404
|
// Match the run/build command's emission: ObjC-bearing sources (GUI code
|
|
371
405
|
// pulled in through the library graph) must land in a `.m` TU on Apple
|
|
@@ -454,15 +488,22 @@ export async function run_test_file(
|
|
|
454
488
|
result.tests = records.tests;
|
|
455
489
|
result.fails = records.fails;
|
|
456
490
|
result.benches = records.benches;
|
|
491
|
+
result.leaking_tests = records.leaks;
|
|
457
492
|
result.other = records.other;
|
|
458
493
|
// Under --audit the exit-time audit check prints "LEAK: N allocation(s)"
|
|
459
|
-
// to stdout when the malloc/free balance is nonzero
|
|
460
|
-
//
|
|
494
|
+
// to stdout when the malloc/free balance is nonzero, and
|
|
495
|
+
// "AUDIT-STALE-FREE: N ..." for frees that matched no live block. Treat
|
|
496
|
+
// any such line as a failure so a leaked (or double-freeing) test file
|
|
497
|
+
// cannot stay green. (The per-test `leaks` records are collected always —
|
|
498
|
+
// they name the leaking tests — but only --audit makes them fail the
|
|
499
|
+
// file.)
|
|
461
500
|
result.leaks = audit ? extract_leaks(runStdout) : [];
|
|
462
|
-
//
|
|
501
|
+
// Audit lines are surfaced explicitly above; drop them from the generic
|
|
463
502
|
// stdout dump so they are not printed twice.
|
|
464
503
|
if (result.leaks.length) {
|
|
465
|
-
result.other = result.other.filter(
|
|
504
|
+
result.other = result.other.filter(
|
|
505
|
+
(l) => !l.trim().startsWith("LEAK:") && !l.trim().startsWith("AUDIT-STALE-FREE:"),
|
|
506
|
+
);
|
|
466
507
|
}
|
|
467
508
|
result.crashed = crashed;
|
|
468
509
|
if (crashed) result.phase = "run";
|
|
@@ -535,6 +576,20 @@ export function report_file(result: TestFileResult): void {
|
|
|
535
576
|
for (const leak of result.leaks) {
|
|
536
577
|
console.log(` ${C.yellow("⚠")} ${C.yellow(leak)}`);
|
|
537
578
|
}
|
|
579
|
+
// Per-test attribution: which tests leaked (the exit-time LEAK line above
|
|
580
|
+
// is the file total; these name the culprits). Shown under --audit only —
|
|
581
|
+
// without it the file cannot fail on leaks, so the warning would be noise
|
|
582
|
+
// on every run.
|
|
583
|
+
if (result.leaks.length) {
|
|
584
|
+
for (const lt of result.leaking_tests) {
|
|
585
|
+
console.log(` ${C.yellow("⚠")} ${lt.test} ${C.dim(`leaked ${lt.count} allocation(s)`)}`);
|
|
586
|
+
}
|
|
587
|
+
if (!result.leaking_tests.length) {
|
|
588
|
+
console.log(
|
|
589
|
+
` ${C.yellow("⚠")} ${C.yellow("LEAK: no per-test attribution — leaked outside a test (harness/setup/teardown) or crashed mid-test")}`,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
538
593
|
for (const b of result.benches) {
|
|
539
594
|
console.log(
|
|
540
595
|
` ${C.cyan("⏱")} ${b.label} ${C.dim(`(n=${b.n})`)} ` +
|
|
@@ -641,6 +696,12 @@ export async function runTests(root: string, options: RunTestsOptions = {}): Pro
|
|
|
641
696
|
const totalFailed = results.reduce((a, r) => a + r.tests.reduce((x, t) => x + t.failed, 0), 0);
|
|
642
697
|
const totalPassed = totalTests - totalFailed;
|
|
643
698
|
const totalLeaks = results.reduce((a, r) => a + r.leaks.length, 0);
|
|
699
|
+
// Leak attribution: the count of TESTS that leaked (the old summary
|
|
700
|
+
// counted FILES — one aggregate LEAK line per file told you nothing about
|
|
701
|
+
// which test to blame). The per-file report lists the leaking tests; the
|
|
702
|
+
// files' exit-time totals can exceed the per-test sum when allocations
|
|
703
|
+
// leak outside any test (harness/setup/teardown).
|
|
704
|
+
const totalLeakingTests = results.reduce((a, r) => a + r.leaking_tests.length, 0);
|
|
644
705
|
const anyFailed = results.some((r) => !r.ok);
|
|
645
706
|
|
|
646
707
|
console.log("");
|
|
@@ -659,8 +720,9 @@ export async function runTests(root: string, options: RunTestsOptions = {}): Pro
|
|
|
659
720
|
);
|
|
660
721
|
if (totalLeaks) {
|
|
661
722
|
console.log(
|
|
662
|
-
` ${C.dim("Leaks ")} ${C.yellow(`${
|
|
663
|
-
C.dim(
|
|
723
|
+
` ${C.dim("Leaks ")} ${C.yellow(`${totalLeakingTests} leaking test(s)`)}` +
|
|
724
|
+
(totalLeakingTests !== totalLeaks ? C.dim(` (${totalLeaks} leaked file(s))`) : "") +
|
|
725
|
+
C.dim(" (--audit; leaking tests fail their file)"),
|
|
664
726
|
);
|
|
665
727
|
}
|
|
666
728
|
console.log(` ${C.dim(" Time ")} ${format_duration(elapsed)}`);
|