nomen-lang 0.6.9 → 0.6.11
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/Console.nm +26 -0
- package/core/System/Test.nm +18 -0
- package/dist/index.mjs +395 -182
- package/package.json +1 -1
- package/src/index.ts +8 -1
- package/src/test.ts +166 -53
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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
@@ -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,30 @@ 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";
|
|
223
|
+
// Unbuffered stdout: a binary that crashes mid-suite still leaves every
|
|
224
|
+
// record it printed (the CLI reads them from the run's .out file), and a
|
|
225
|
+
// crash is diagnosed with the tests that PASSED before it instead of
|
|
226
|
+
// "(crashed before records)".
|
|
227
|
+
main += `\tConsole.unbuffered()\n`;
|
|
205
228
|
main += "\tvar Tester t = Tester()\n";
|
|
229
|
+
main += "\tvar int64 audit_mark = 0\n";
|
|
230
|
+
main += "\tvar int64 audit_leaked = 0\n";
|
|
206
231
|
for (const test of tests) {
|
|
232
|
+
main += `\taudit_mark = Tester.audit_count()\n`;
|
|
207
233
|
main += `\tt.begin_test("${escape_nm_string(test.name)}")\n`;
|
|
208
234
|
main += `\t${test.name}(ref t)\n`;
|
|
235
|
+
main += `\taudit_leaked = Tester.audit_count() - audit_mark\n`;
|
|
236
|
+
main += `\tif audit_leaked > 0 {\n`;
|
|
237
|
+
main += `\t\tConsole.write_line("\\\\nomen|leaks|" + "${escape_nm_string(test.name)}" + "|\\{audit_leaked}")\n`;
|
|
238
|
+
main += `\t}\n`;
|
|
209
239
|
main += `\tt.end_test()\n`;
|
|
210
240
|
}
|
|
211
241
|
for (const bench of benches) {
|
|
@@ -227,6 +257,7 @@ interface RunRecord {
|
|
|
227
257
|
tests: TestRecord[];
|
|
228
258
|
fails: FailRecord[];
|
|
229
259
|
benches: BenchRecord[];
|
|
260
|
+
leaks: LeakRecord[];
|
|
230
261
|
other: string[];
|
|
231
262
|
}
|
|
232
263
|
|
|
@@ -235,6 +266,7 @@ export function parse_records(stdout: string): RunRecord {
|
|
|
235
266
|
const tests: TestRecord[] = [];
|
|
236
267
|
const fails: FailRecord[] = [];
|
|
237
268
|
const benches: BenchRecord[] = [];
|
|
269
|
+
const leaks: LeakRecord[] = [];
|
|
238
270
|
const other: string[] = [];
|
|
239
271
|
for (const line of stdout.split("\n")) {
|
|
240
272
|
if (!line.startsWith(RECORD_PREFIX)) {
|
|
@@ -254,6 +286,8 @@ export function parse_records(stdout: string): RunRecord {
|
|
|
254
286
|
});
|
|
255
287
|
} else if (kind === "fail") {
|
|
256
288
|
fails.push({ test: parts[1], message: parts.slice(2).join("|") });
|
|
289
|
+
} else if (kind === "leaks") {
|
|
290
|
+
leaks.push({ test: parts[1], count: parseInt(parts[2] || "0", 10) });
|
|
257
291
|
} else if (kind === "bench") {
|
|
258
292
|
benches.push({
|
|
259
293
|
label: parts[1],
|
|
@@ -266,15 +300,21 @@ export function parse_records(stdout: string): RunRecord {
|
|
|
266
300
|
});
|
|
267
301
|
}
|
|
268
302
|
}
|
|
269
|
-
return { tests, fails, benches, other };
|
|
303
|
+
return { tests, fails, benches, leaks, other };
|
|
270
304
|
}
|
|
271
305
|
|
|
272
|
-
/**
|
|
306
|
+
/**
|
|
307
|
+
* Pull the exit-time audit lines out of stdout: "LEAK: N allocation(s)" (a
|
|
308
|
+
* nonzero malloc/free balance) and "AUDIT-STALE-FREE: N pointer(s) freed that
|
|
309
|
+
* were not live audit allocations" (a free that matched no live block — a
|
|
310
|
+
* foreign pointer or a double free; the runtime no longer passes those to
|
|
311
|
+
* libc free, so this is the only record they leave). Both fail the file.
|
|
312
|
+
*/
|
|
273
313
|
export function extract_leaks(stdout: string): string[] {
|
|
274
314
|
return stdout
|
|
275
315
|
.split("\n")
|
|
276
316
|
.map((l) => l.trim())
|
|
277
|
-
.filter((l) => l.startsWith("LEAK:"));
|
|
317
|
+
.filter((l) => l.startsWith("LEAK:") || l.startsWith("AUDIT-STALE-FREE:"));
|
|
278
318
|
}
|
|
279
319
|
|
|
280
320
|
/**
|
|
@@ -302,8 +342,9 @@ export async function run_test_file(
|
|
|
302
342
|
tests: [],
|
|
303
343
|
fails: [],
|
|
304
344
|
benches: [],
|
|
305
|
-
other: [],
|
|
306
345
|
leaks: [],
|
|
346
|
+
leaking_tests: [],
|
|
347
|
+
other: [],
|
|
307
348
|
ms: 0,
|
|
308
349
|
};
|
|
309
350
|
|
|
@@ -343,29 +384,31 @@ export async function run_test_file(
|
|
|
343
384
|
|
|
344
385
|
const buildDir = build_dir_for(resolved, true);
|
|
345
386
|
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]);
|
|
387
|
+
// The audit runtime is linked into EVERY test build: the generated
|
|
388
|
+
// harness polls nomen_audit_count() around every test for per-test leak
|
|
389
|
+
// attribution, and under --audit the generated main additionally calls
|
|
390
|
+
// nomen_audit_check() at exit, printing "LEAK: N allocation(s)" when the
|
|
391
|
+
// balance is nonzero.
|
|
392
|
+
// Runtime discovery: explicit flag wins; else walk up from the test file
|
|
393
|
+
// (a nomen checkout's src/audit_runtime.c); else the runtime bundled with
|
|
394
|
+
// the CLI (live repo copy in the dev tree, shipped copy when published)
|
|
395
|
+
// so it works outside the nomen checkout with no flag.
|
|
396
|
+
const runtime_src = audit_runtime
|
|
397
|
+
? path.resolve(audit_runtime)
|
|
398
|
+
: (find_audit_runtime(path.dirname(resolved)) ?? find_bundled_audit_runtime());
|
|
399
|
+
if (!runtime_src || !fs.existsSync(runtime_src)) {
|
|
400
|
+
result.ok = false;
|
|
401
|
+
result.phase = "setup";
|
|
402
|
+
result.crashed = "audit_runtime.c was not found. Pass --audit-runtime <path>.";
|
|
403
|
+
result.ms = performance.now() - start;
|
|
404
|
+
return result;
|
|
368
405
|
}
|
|
406
|
+
// Per-file audit object: two concurrent workers compiling to the SHARED
|
|
407
|
+
// `audit_runtime.o` raced — a link could read the object mid-rewrite
|
|
408
|
+
// (concurrent `clang -c` open the output with O_TRUNC) and fail, or link
|
|
409
|
+
// a stale slab. The runtime is tiny; recompiling per file costs ~0.1s.
|
|
410
|
+
const audit_obj = path.join(buildDir, path.basename(entry_path, ".nm") + "_audit.o");
|
|
411
|
+
await execFile_async("clang", ["-c", runtime_src, "-o", audit_obj]);
|
|
369
412
|
const platform = default_platform();
|
|
370
413
|
// Match the run/build command's emission: ObjC-bearing sources (GUI code
|
|
371
414
|
// pulled in through the library graph) must land in a `.m` TU on Apple
|
|
@@ -374,10 +417,19 @@ export async function run_test_file(
|
|
|
374
417
|
const ext = arch === "aarch64" ? ".s" : platform === "macos" || platform === "ios" ? ".m" : ".c";
|
|
375
418
|
const codefile = path.join(buildDir, path.basename(entry_path, ".nm") + ext);
|
|
376
419
|
const outfile = path.join(buildDir, path.basename(entry_path, ".nm"));
|
|
377
|
-
// The C backend's generated source does `#include "main.h"`, so
|
|
378
|
-
// header next to the code. (aarch64 inlines everything into the
|
|
379
|
-
|
|
380
|
-
|
|
420
|
+
// The C backend's generated source does `#include "main.h"`, so the
|
|
421
|
+
// header lands next to the code. (aarch64 inlines everything into the
|
|
422
|
+
// .s file and ignores it.) Per-file header name: every test file shares
|
|
423
|
+
// one build folder, and two concurrent C builds writing the SAME
|
|
424
|
+
// `main.h` raced — a compile could read another file's header between
|
|
425
|
+
// the write and the clang read and fail (or mis-declare) spuriously.
|
|
426
|
+
const header_name = path.basename(entry_path, ".nm") + ".main.h";
|
|
427
|
+
fs.writeFileSync(path.join(buildDir, header_name), buildResult.headers ?? "");
|
|
428
|
+
let code_text: string = buildResult.code;
|
|
429
|
+
if (arch !== "aarch64") {
|
|
430
|
+
code_text = code_text.replace('#include "main.h"', `#include "${header_name}"`);
|
|
431
|
+
}
|
|
432
|
+
fs.writeFileSync(codefile, code_text);
|
|
381
433
|
|
|
382
434
|
// Companion C (raw-block functions + the pool/fiber/file-scope C infra):
|
|
383
435
|
// `run`/`build` compile and link it next to the main TU. The aarch64 .s
|
|
@@ -424,45 +476,85 @@ export async function run_test_file(
|
|
|
424
476
|
return result;
|
|
425
477
|
}
|
|
426
478
|
|
|
427
|
-
// Run the binary
|
|
479
|
+
// Run the binary with its stdout/stderr redirected to per-file FILES
|
|
480
|
+
// (next to the binary, doubling as debugging artifacts) instead of node
|
|
481
|
+
// pipes, then read them. (Why not pipes: this runner's parse/build
|
|
482
|
+
// phases block the event loop for tens of seconds at a time. A binary
|
|
483
|
+
// that finished inside such a block exited 0 long before its records
|
|
484
|
+
// were read, but the 30s execFile timeout that became due during the
|
|
485
|
+
// block still fired at loop-resume — and the kill path destroyed the
|
|
486
|
+
// child's unread pipe, silently dropping EVERY record: the file
|
|
487
|
+
// reported "(0 tests)" and passed. Records on disk are immune to
|
|
488
|
+
// anything the parent does after the fact.) The documented CWD
|
|
489
|
+
// contract: the binary runs in the `--in` root (the folder `nomen test`
|
|
490
|
+
// scanned for *.test.nm files), so fixture paths in tests resolve
|
|
491
|
+
// against that folder regardless of where the CLI was invoked from. A
|
|
428
492
|
// non-zero exit (crash, abort) still surfaces whatever records were
|
|
429
|
-
// emitted before the crash
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
|
|
493
|
+
// emitted before the crash (the harness runs its stdout unbuffered, so
|
|
494
|
+
// a crashing binary keeps the records it printed); a timeout is treated
|
|
495
|
+
// as a crash.
|
|
496
|
+
const run_out = outfile + ".out";
|
|
497
|
+
const run_err = outfile + ".err";
|
|
434
498
|
let runStdout = "";
|
|
499
|
+
let runStderr = "";
|
|
435
500
|
let crashed: string | undefined;
|
|
501
|
+
const out_fd = fs.openSync(run_out, "w");
|
|
502
|
+
const err_fd = fs.openSync(run_err, "w");
|
|
436
503
|
try {
|
|
437
|
-
const
|
|
438
|
-
encoding: "utf8",
|
|
504
|
+
const child = spawn(outfile, [], {
|
|
439
505
|
timeout: 30_000,
|
|
440
|
-
|
|
506
|
+
stdio: ["ignore", out_fd, err_fd],
|
|
441
507
|
cwd: cwd ?? process.cwd(),
|
|
442
508
|
});
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
509
|
+
const run = await new Promise<{ code: number | null; signal: string | null }>((resolve) => {
|
|
510
|
+
child.on("error", () => resolve({ code: null, signal: null }));
|
|
511
|
+
child.on("close", (code, signal) => resolve({ code, signal }));
|
|
512
|
+
});
|
|
513
|
+
if (run.signal === "SIGTERM") {
|
|
447
514
|
crashed = "test binary timed out after 30s";
|
|
448
|
-
} else {
|
|
449
|
-
crashed = `test binary exited abnormally (signal ${
|
|
515
|
+
} else if (run.signal !== null) {
|
|
516
|
+
crashed = `test binary exited abnormally (signal ${run.signal})`;
|
|
517
|
+
} else if (run.code === null) {
|
|
518
|
+
crashed = "test binary could not be run";
|
|
519
|
+
} else if (run.code !== 0) {
|
|
520
|
+
crashed = `test binary exited abnormally (signal ${run.code})`;
|
|
450
521
|
}
|
|
522
|
+
} finally {
|
|
523
|
+
fs.closeSync(out_fd);
|
|
524
|
+
fs.closeSync(err_fd);
|
|
525
|
+
}
|
|
526
|
+
runStdout = fs.readFileSync(run_out, "utf8");
|
|
527
|
+
try {
|
|
528
|
+
runStderr = fs.readFileSync(run_err, "utf8");
|
|
529
|
+
} catch {
|
|
530
|
+
runStderr = "";
|
|
531
|
+
}
|
|
532
|
+
if (crashed && runStderr.trim()) {
|
|
533
|
+
// A crashed binary's own diagnosis (a libmalloc abort message, a
|
|
534
|
+
// panic dump) goes to stderr — surface it with the crash record.
|
|
535
|
+
crashed += `\n${runStderr.trim()}`;
|
|
451
536
|
}
|
|
452
537
|
|
|
453
538
|
const records = parse_records(runStdout);
|
|
454
539
|
result.tests = records.tests;
|
|
455
540
|
result.fails = records.fails;
|
|
456
541
|
result.benches = records.benches;
|
|
542
|
+
result.leaking_tests = records.leaks;
|
|
457
543
|
result.other = records.other;
|
|
458
544
|
// Under --audit the exit-time audit check prints "LEAK: N allocation(s)"
|
|
459
|
-
// to stdout when the malloc/free balance is nonzero
|
|
460
|
-
//
|
|
545
|
+
// to stdout when the malloc/free balance is nonzero, and
|
|
546
|
+
// "AUDIT-STALE-FREE: N ..." for frees that matched no live block. Treat
|
|
547
|
+
// any such line as a failure so a leaked (or double-freeing) test file
|
|
548
|
+
// cannot stay green. (The per-test `leaks` records are collected always —
|
|
549
|
+
// they name the leaking tests — but only --audit makes them fail the
|
|
550
|
+
// file.)
|
|
461
551
|
result.leaks = audit ? extract_leaks(runStdout) : [];
|
|
462
|
-
//
|
|
552
|
+
// Audit lines are surfaced explicitly above; drop them from the generic
|
|
463
553
|
// stdout dump so they are not printed twice.
|
|
464
554
|
if (result.leaks.length) {
|
|
465
|
-
result.other = result.other.filter(
|
|
555
|
+
result.other = result.other.filter(
|
|
556
|
+
(l) => !l.trim().startsWith("LEAK:") && !l.trim().startsWith("AUDIT-STALE-FREE:"),
|
|
557
|
+
);
|
|
466
558
|
}
|
|
467
559
|
result.crashed = crashed;
|
|
468
560
|
if (crashed) result.phase = "run";
|
|
@@ -535,6 +627,20 @@ export function report_file(result: TestFileResult): void {
|
|
|
535
627
|
for (const leak of result.leaks) {
|
|
536
628
|
console.log(` ${C.yellow("⚠")} ${C.yellow(leak)}`);
|
|
537
629
|
}
|
|
630
|
+
// Per-test attribution: which tests leaked (the exit-time LEAK line above
|
|
631
|
+
// is the file total; these name the culprits). Shown under --audit only —
|
|
632
|
+
// without it the file cannot fail on leaks, so the warning would be noise
|
|
633
|
+
// on every run.
|
|
634
|
+
if (result.leaks.length) {
|
|
635
|
+
for (const lt of result.leaking_tests) {
|
|
636
|
+
console.log(` ${C.yellow("⚠")} ${lt.test} ${C.dim(`leaked ${lt.count} allocation(s)`)}`);
|
|
637
|
+
}
|
|
638
|
+
if (!result.leaking_tests.length) {
|
|
639
|
+
console.log(
|
|
640
|
+
` ${C.yellow("⚠")} ${C.yellow("LEAK: no per-test attribution — leaked outside a test (harness/setup/teardown) or crashed mid-test")}`,
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
538
644
|
for (const b of result.benches) {
|
|
539
645
|
console.log(
|
|
540
646
|
` ${C.cyan("⏱")} ${b.label} ${C.dim(`(n=${b.n})`)} ` +
|
|
@@ -641,6 +747,12 @@ export async function runTests(root: string, options: RunTestsOptions = {}): Pro
|
|
|
641
747
|
const totalFailed = results.reduce((a, r) => a + r.tests.reduce((x, t) => x + t.failed, 0), 0);
|
|
642
748
|
const totalPassed = totalTests - totalFailed;
|
|
643
749
|
const totalLeaks = results.reduce((a, r) => a + r.leaks.length, 0);
|
|
750
|
+
// Leak attribution: the count of TESTS that leaked (the old summary
|
|
751
|
+
// counted FILES — one aggregate LEAK line per file told you nothing about
|
|
752
|
+
// which test to blame). The per-file report lists the leaking tests; the
|
|
753
|
+
// files' exit-time totals can exceed the per-test sum when allocations
|
|
754
|
+
// leak outside any test (harness/setup/teardown).
|
|
755
|
+
const totalLeakingTests = results.reduce((a, r) => a + r.leaking_tests.length, 0);
|
|
644
756
|
const anyFailed = results.some((r) => !r.ok);
|
|
645
757
|
|
|
646
758
|
console.log("");
|
|
@@ -659,8 +771,9 @@ export async function runTests(root: string, options: RunTestsOptions = {}): Pro
|
|
|
659
771
|
);
|
|
660
772
|
if (totalLeaks) {
|
|
661
773
|
console.log(
|
|
662
|
-
` ${C.dim("Leaks ")} ${C.yellow(`${
|
|
663
|
-
C.dim(
|
|
774
|
+
` ${C.dim("Leaks ")} ${C.yellow(`${totalLeakingTests} leaking test(s)`)}` +
|
|
775
|
+
(totalLeakingTests !== totalLeaks ? C.dim(` (${totalLeaks} leaked file(s))`) : "") +
|
|
776
|
+
C.dim(" (--audit; leaking tests fail their file)"),
|
|
664
777
|
);
|
|
665
778
|
}
|
|
666
779
|
console.log(` ${C.dim(" Time ")} ${format_duration(elapsed)}`);
|