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
This commit is contained in:
agra
2026-06-21 09:10:30 +03:00
parent 11dc6a3299
commit 6ed29621ad
8 changed files with 72 additions and 0 deletions

View File

@@ -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);

View File

@@ -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,