From 6ed29621adcd698823b7868aae903adb5b2de516 Mon Sep 17 00:00:00 2001 From: agra Date: Sun, 21 Jun 2026 09:10:30 +0300 Subject: [PATCH] fix: diagnose missing 'main' instead of segfaulting on 'sx run' (issue 0137) A program with no 'main' reached the JIT entry-point call with a garbage address (ORC reports lookup success but leaves main_addr degenerate), then called it -> SIGSEGV. Add a pre-JIT entry-point check in main.zig that emits 'error: no main function found' and exits non-zero before codegen, plus a defensive main_addr==0 guard in target.zig runJITFromObject as a backstop. Regression: examples/1188-diagnostics-run-no-main.sx --- ...059-errors-same-name-error-set-own-wins.sx | 0 examples/1188-diagnostics-run-no-main.sx | 12 ++++++ .../1188-diagnostics-run-no-main.exit | 1 + .../1188-diagnostics-run-no-main.stderr | 1 + .../1188-diagnostics-run-no-main.stdout | 1 + issues/0137-jit-run-no-main-segfault.md | 7 ++++ src/main.zig | 39 +++++++++++++++++++ src/target.zig | 11 ++++++ 8 files changed, 72 insertions(+) rename issues/0134-error-set-no-per-decl-nominal-identity-same-name-collapse.sx => examples/1059-errors-same-name-error-set-own-wins.sx (100%) create mode 100644 examples/1188-diagnostics-run-no-main.sx create mode 100644 examples/expected/1188-diagnostics-run-no-main.exit create mode 100644 examples/expected/1188-diagnostics-run-no-main.stderr create mode 100644 examples/expected/1188-diagnostics-run-no-main.stdout diff --git a/issues/0134-error-set-no-per-decl-nominal-identity-same-name-collapse.sx b/examples/1059-errors-same-name-error-set-own-wins.sx similarity index 100% rename from issues/0134-error-set-no-per-decl-nominal-identity-same-name-collapse.sx rename to examples/1059-errors-same-name-error-set-own-wins.sx diff --git a/examples/1188-diagnostics-run-no-main.sx b/examples/1188-diagnostics-run-no-main.sx new file mode 100644 index 00000000..c2d3ef1e --- /dev/null +++ b/examples/1188-diagnostics-run-no-main.sx @@ -0,0 +1,12 @@ +// `sx run` on a program with no `main` must emit a clean diagnostic and exit +// non-zero — never call into a garbage JIT address and segfault. A pre-JIT +// entry-point check in main.zig (plus a defensive `main_addr == 0` backstop in +// target.zig's runJITFromObject) replaces the old silent garbage-pointer call. +// +// Regression (issue 0137). +#import "modules/std.sx"; + +// Intentionally no `main` — only a helper. +greet :: () { + print("unreachable\n"); +} diff --git a/examples/expected/1188-diagnostics-run-no-main.exit b/examples/expected/1188-diagnostics-run-no-main.exit new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/examples/expected/1188-diagnostics-run-no-main.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1188-diagnostics-run-no-main.stderr b/examples/expected/1188-diagnostics-run-no-main.stderr new file mode 100644 index 00000000..a7375417 --- /dev/null +++ b/examples/expected/1188-diagnostics-run-no-main.stderr @@ -0,0 +1 @@ +error: no 'main' function found — 'sx run' requires a top-level 'main' entry point diff --git a/examples/expected/1188-diagnostics-run-no-main.stdout b/examples/expected/1188-diagnostics-run-no-main.stdout new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/expected/1188-diagnostics-run-no-main.stdout @@ -0,0 +1 @@ + diff --git a/issues/0137-jit-run-no-main-segfault.md b/issues/0137-jit-run-no-main-segfault.md index f891fda4..60054472 100644 --- a/issues/0137-jit-run-no-main-segfault.md +++ b/issues/0137-jit-run-no-main-segfault.md @@ -1,5 +1,12 @@ # 0137 — `sx run` on a program with no `main` segfaults (JIT entry lookup unguarded) +> **RESOLVED.** A pre-JIT entry-point check in `main.zig` now emits a clean +> `error: no 'main' function found …` diagnostic and exits non-zero before any +> codegen/JIT, so a no-main program never reaches the garbage-pointer call. A +> defensive `main_addr == 0` guard in `target.zig`'s `runJITFromObject` (ORC +> reports lookup success but leaves the address degenerate) remains as a +> backstop. Regression test: `examples/1188-diagnostics-run-no-main.sx`. + ## Symptom `sx run ` on a program that defines no `main` function **crashes** diff --git a/src/main.zig b/src/main.zig index c8d244d4..ad79863b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -240,6 +240,18 @@ pub fn main(init: std.process.Init) !void { // Cache check — use .o files (precompiled object, skip IR compilation in JIT) // Disable caching for files with top-level #run (side effects lost on cache hit) const root = comp.resolved_root orelse comp.root orelse return; + + // Pre-JIT entry-point check. The ORC `main` lookup in + // runJITFromObject does NOT reliably report "no main" — it has been + // observed reporting success while leaving the address at garbage + // (0x0 or a small non-zero value), which then gets called and + // segfaults (issue 0137). Reject programs with no `main` here, before + // any codegen/JIT, with a clean diagnostic + non-zero exit. + if (!hasMainEntry(root)) { + std.debug.print("error: no 'main' function found — 'sx run' requires a top-level 'main' entry point\n", .{}); + std.process.exit(1); + } + const use_cache = enable_cache and !hasTopLevelRun(root); const key = computeCacheKey(source, &comp.import_sources, target_config); const cache_obj = cachePath(allocator, key, "o") catch std.process.exit(1); @@ -914,6 +926,33 @@ fn hasTopLevelRun(root: *const sx.ast.Node) bool { return false; } +/// Does the program declare a `main` entry point? The JIT (and an AOT +/// binary) entry symbol is a flat function named `main`; this scans the +/// resolved AST for a `fn_decl` named "main", recursing into namespace +/// decls so a `main` brought in behind an aliased import is still found. +/// Used as a pre-JIT guard (issue 0137): the ORC `main` lookup does not +/// reliably surface "no main", so we reject the no-main program here with +/// a clean diagnostic instead of calling a garbage function pointer. +fn hasMainEntry(root: *const sx.ast.Node) bool { + const walker = struct { + fn walk(decls: []const *sx.ast.Node) bool { + for (decls) |d| { + switch (d.data) { + .fn_decl => |fd| { + if (std.mem.eql(u8, fd.name, "main")) return true; + }, + .namespace_decl => |ns| { + if (walk(ns.decls)) return true; + }, + else => {}, + } + } + return false; + } + }; + return walker.walk(root.data.root.decls); +} + fn extractLibraries(allocator: std.mem.Allocator, root: *const sx.ast.Node) ![]const []const u8 { var libs = std.ArrayList([]const u8).empty; var seen = std.StringHashMap(void).init(allocator); diff --git a/src/target.zig b/src/target.zig index 0b72c469..0d7b8899 100644 --- a/src/target.zig +++ b/src/target.zig @@ -383,6 +383,17 @@ pub fn runJITFromObject(obj_buf: c.LLVMMemoryBufferRef, priority_dylibs: []const return error.CompileError; } + // Defensive backstop (issue 0137): ORC has been observed reporting + // success from the `main` lookup while leaving `main_addr` at 0 — calling + // @ptrFromInt(0) then segfaults. The real fix is the pre-JIT entry-point + // check in main.zig (which also catches the observed NON-zero garbage + // address case); this guard is a last line of defense so a null entry can + // never be called regardless of how we got here. + if (main_addr == 0) { + std.debug.print("error: no 'main' function found in JIT module\n", .{}); + return error.CompileError; + } + // Cast to function pointer and call. The exit code is main's integer // return truncated to u8 — matching the OS truncation an AOT binary's // exit status already gets, so JIT and AOT agree (e.g. 1105 -> 81,