Skip to content

Commit 234eb1a

Browse files
committed
watcher: surface startup failures to user terminal instead of dying silently
Until now, `codescan watcher start` daemonized with stdin/stdout/stderr closed, so any startup error — model/dim mismatch, unreachable embedding server, db open failure — vanished into the void. The user saw "Started background watcher (PID …)" and then nothing; `status` later reported "stopped" with no explanation. Move preflight into the parent (foreground) process so errors land on the user's TTY *and* syslog before we spawn. New `preflight.zig` module returns a structured `PreflightFailure` union (server_unreachable, db_open_failed, schema_mismatch); the formatter emits actionable fixes ("run 'codescan index' to rebuild the index with the current model"). For dirtree-style mismatch scenarios (Ollama bge-large/1024 → oMLX jina/1536), the user now sees both errors and the fix recommendation inline at the terminal, plus a structured syslog entry. 4 inline unit tests (1 happy path + 3 mismatch variants including the dirtree dual-mismatch); TDD-verified RED→GREEN by stubbing the check.
1 parent 5ccf069 commit 234eb1a

4 files changed

Lines changed: 288 additions & 23 deletions

File tree

build.zig

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,18 @@ pub fn build(b: *std.Build) void {
151151
linkCommon(storage_tests, sqlite3_lib, vec_static_lib, pcre2_lib, ts_lib, &ts_langs);
152152
test_step.dependOn(&b.addRunArtifact(storage_tests).step);
153153

154+
const preflight_tests = b.addTest(.{
155+
.root_module = b.createModule(.{
156+
.root_source_file = b.path("src/preflight.zig"),
157+
.target = target,
158+
.optimize = optimize,
159+
}),
160+
});
161+
addTreeSitterIncludes(b, preflight_tests.root_module);
162+
addPcre2Includes(preflight_tests.root_module, pcre2_lib);
163+
linkCommon(preflight_tests, sqlite3_lib, vec_static_lib, pcre2_lib, ts_lib, &ts_langs);
164+
test_step.dependOn(&b.addRunArtifact(preflight_tests).step);
165+
154166
const embedding_http_tests = b.addTest(.{
155167
.root_module = b.createModule(.{
156168
.root_source_file = b.path("src/embedding_http.zig"),

src/all_tests.zig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const _output = @import("output.zig");
3636
const _pcre2 = @import("pcre2.zig");
3737
const _pidfile = @import("pidfile.zig");
3838
const _plugin = @import("plugin.zig");
39+
const _preflight = @import("preflight.zig");
3940
const _scan = @import("scan.zig");
4041
const _search = @import("search.zig");
4142
const _simd = @import("simd.zig");
@@ -88,6 +89,7 @@ test {
8889
_ = _pcre2;
8990
_ = _pidfile;
9091
_ = _plugin;
92+
_ = _preflight;
9193
_ = _scan;
9294
_ = _search;
9395
_ = _simd;

src/main.zig

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const weights = @import("weights.zig");
2828
const diagnostics = @import("diagnostics.zig");
2929
const syslog = @import("syslog.zig");
3030
const setup_model_text = @import("setup_model_text.zig");
31+
const preflight = @import("preflight.zig");
3132
const io_singleton = @import("io_singleton.zig");
3233

3334
/// File-scope atomic flag for POSIX signal handlers (which cannot capture closures).
@@ -2058,34 +2059,56 @@ fn maybeStartWatcher(allocator: std.mem.Allocator, settings: Settings, stderr: *
20582059
// Clean up stale PID file if it exists (process is dead)
20592060
pidfile.removePid(allocator, codescan_dir);
20602061

2061-
// Preflight: refuse to spawn if the embedding server is unreachable. The
2062-
// daemon would otherwise die silently — its stderr is closed, and the
2063-
// failure happens before syslog.init in some paths.
2062+
// Preflight checks — the daemon would otherwise die silently because its
2063+
// stdin/stdout/stderr are all .close, so any startup error vanishes.
2064+
// Run all checks in the parent so we can print actionable messages to the
2065+
// user's terminal AND log via syslog.
2066+
const dialect_name: []const u8 = switch (settings.embedding_dialect) {
2067+
.ollama => "ollama",
2068+
.openai => "openai (oMLX / OpenAI-compatible)",
2069+
};
2070+
2071+
var failure_opt: ?preflight.PreflightFailure = null;
20642072
if (!canConnectToEmbeddingServer(allocator, settings.embedding_url)) {
2065-
const dialect_name: []const u8 = switch (settings.embedding_dialect) {
2066-
.ollama => "ollama",
2067-
.openai => "openai (oMLX / OpenAI-compatible)",
2068-
};
2069-
_ = stderr.print(
2070-
"\x1b[31merror: cannot reach embedding server at {s}\x1b[0m\n" ++
2071-
" configured dialect: {s}\n" ++
2072-
" configured model: {s}\n" ++
2073-
" fix one of:\n" ++
2074-
" - start the embedding server\n" ++
2075-
" - update embedding_url / embedding_api in .codescan/config\n" ++
2076-
" - run 'codescan setup-model' for setup instructions\n" ++
2077-
" watcher NOT started.\n",
2078-
.{ settings.embedding_url, dialect_name, settings.embedding_model },
2079-
) catch {};
2073+
failure_opt = preflight.PreflightFailure{ .server_unreachable = .{
2074+
.url = settings.embedding_url,
2075+
.dialect = dialect_name,
2076+
} };
2077+
} else {
2078+
failure_opt = preflight.checkIndexConsistency(
2079+
allocator,
2080+
settings.db_path,
2081+
settings.embedding_model,
2082+
settings.embedding_dim,
2083+
) catch null;
2084+
}
2085+
2086+
if (failure_opt) |*failure| {
2087+
defer failure.deinit(allocator);
2088+
_ = stderr.writeAll("\x1b[31m") catch {};
2089+
preflight.formatActionable(failure.*, stderr) catch {};
2090+
_ = stderr.writeAll("\x1b[0m") catch {};
20802091
_ = stderr.flush() catch {};
20812092
syslog.init("codescan");
20822093
defer syslog.deinit();
20832094
var msg_buf: [512]u8 = undefined;
2084-
const msg = std.fmt.bufPrint(
2085-
&msg_buf,
2086-
"failed to start watcher: cannot reach embedding server at {s} (dialect={s})",
2087-
.{ settings.embedding_url, dialect_name },
2088-
) catch "failed to start watcher: cannot reach embedding server";
2095+
const msg = switch (failure.*) {
2096+
.server_unreachable => |s| std.fmt.bufPrint(
2097+
&msg_buf,
2098+
"failed to start watcher: cannot reach embedding server at {s} (dialect={s})",
2099+
.{ s.url, s.dialect },
2100+
) catch "failed to start watcher: cannot reach embedding server",
2101+
.db_open_failed => |s| std.fmt.bufPrint(
2102+
&msg_buf,
2103+
"failed to start watcher: cannot open index db at {s} ({s})",
2104+
.{ s.path, s.err_name },
2105+
) catch "failed to start watcher: cannot open index db",
2106+
.schema_mismatch => |m| std.fmt.bufPrint(
2107+
&msg_buf,
2108+
"failed to start watcher: schema mismatch (stored model='{s}' dim={d}, current model='{s}' dim={d}); run 'codescan index'",
2109+
.{ m.stored_model orelse "unknown", m.stored_dim orelse 0, m.current_model, m.current_dim },
2110+
) catch "failed to start watcher: schema mismatch; run 'codescan index'",
2111+
};
20892112
syslog.logWithRoot(syslog.LOG_ERR, settings.root_path, msg);
20902113
return;
20912114
}

src/preflight.zig

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
const std = @import("std");
2+
const storage = @import("storage.zig");
3+
4+
pub const PreflightFailure = union(enum) {
5+
server_unreachable: struct {
6+
url: []const u8,
7+
dialect: []const u8,
8+
},
9+
db_open_failed: struct {
10+
path: []const u8,
11+
err_name: []const u8,
12+
},
13+
schema_mismatch: struct {
14+
model_mismatch: bool,
15+
dim_mismatch: bool,
16+
stored_model: ?[]u8,
17+
current_model: []const u8,
18+
stored_dim: ?usize,
19+
current_dim: usize,
20+
},
21+
22+
pub fn deinit(self: *PreflightFailure, allocator: std.mem.Allocator) void {
23+
switch (self.*) {
24+
.schema_mismatch => |*m| {
25+
if (m.stored_model) |s| allocator.free(s);
26+
m.stored_model = null;
27+
},
28+
else => {},
29+
}
30+
}
31+
};
32+
33+
pub fn checkIndexConsistency(
34+
allocator: std.mem.Allocator,
35+
db_path: []const u8,
36+
embedding_model: []const u8,
37+
embedding_dim: usize,
38+
) !?PreflightFailure {
39+
const db = storage.openFileWithVec(allocator, db_path) catch |err| {
40+
return PreflightFailure{ .db_open_failed = .{
41+
.path = db_path,
42+
.err_name = @errorName(err),
43+
} };
44+
};
45+
defer storage.close(db);
46+
47+
var schema_result = storage.initSchema(allocator, db, .{
48+
.embedding_dim = embedding_dim,
49+
.embedding_model = embedding_model,
50+
}) catch |err| {
51+
return PreflightFailure{ .db_open_failed = .{
52+
.path = db_path,
53+
.err_name = @errorName(err),
54+
} };
55+
};
56+
57+
if (!schema_result.embedding_model_mismatch and !schema_result.embedding_dim_mismatch) {
58+
schema_result.deinit(allocator);
59+
return null;
60+
}
61+
62+
const stored_model = schema_result.stored_embedding_model;
63+
schema_result.stored_embedding_model = null;
64+
65+
return PreflightFailure{ .schema_mismatch = .{
66+
.model_mismatch = schema_result.embedding_model_mismatch,
67+
.dim_mismatch = schema_result.embedding_dim_mismatch,
68+
.stored_model = stored_model,
69+
.current_model = embedding_model,
70+
.stored_dim = schema_result.stored_embedding_dim,
71+
.current_dim = embedding_dim,
72+
} };
73+
}
74+
75+
pub fn formatActionable(failure: PreflightFailure, writer: *std.Io.Writer) !void {
76+
switch (failure) {
77+
.server_unreachable => |s| {
78+
try writer.print("error: cannot reach embedding server at {s}\n", .{s.url});
79+
try writer.print(" configured dialect: {s}\n", .{s.dialect});
80+
try writer.writeAll(" fix one of:\n");
81+
try writer.writeAll(" - start the embedding server\n");
82+
try writer.writeAll(" - update embedding_url / embedding_api in .codescan/config\n");
83+
try writer.writeAll(" - run 'codescan setup-model' for setup instructions\n");
84+
try writer.writeAll(" watcher NOT started.\n");
85+
},
86+
.db_open_failed => |s| {
87+
try writer.print("error: cannot open index database at {s} ({s})\n", .{ s.path, s.err_name });
88+
try writer.writeAll(" fix: run 'codescan index' to (re)create the database.\n");
89+
try writer.writeAll(" watcher NOT started.\n");
90+
},
91+
.schema_mismatch => |m| {
92+
if (m.model_mismatch) {
93+
try writer.print(
94+
"error: Embedding model mismatch. Index was built with '{s}', but current model is '{s}'.\n",
95+
.{ m.stored_model orelse "unknown", m.current_model },
96+
);
97+
}
98+
if (m.dim_mismatch) {
99+
try writer.print(
100+
"error: Embedding dimension mismatch. Index was built with {d}, but current setting is {d}.\n",
101+
.{ m.stored_dim orelse 0, m.current_dim },
102+
);
103+
}
104+
try writer.writeAll(" fix: run 'codescan index' to rebuild the index with the current model.\n");
105+
try writer.writeAll(" watcher NOT started.\n");
106+
},
107+
}
108+
}
109+
110+
// ============================================================================
111+
// Tests
112+
// ============================================================================
113+
114+
const model = @import("model.zig");
115+
const io_singleton = @import("io_singleton.zig");
116+
117+
fn writeTempDbWithSchema(
118+
allocator: std.mem.Allocator,
119+
dir: std.Io.Dir,
120+
path: []const u8,
121+
embedding_model: []const u8,
122+
embedding_dim: usize,
123+
) ![]u8 {
124+
const abs_path = try dir.realPathFileAlloc(io_singleton.getOrInit(), ".", allocator);
125+
defer allocator.free(abs_path);
126+
const full_path = try std.fs.path.join(allocator, &.{ abs_path, path });
127+
128+
const db = try storage.openFileWithVec(allocator, full_path);
129+
defer storage.close(db);
130+
131+
var schema_result = try storage.initSchema(allocator, db, .{
132+
.embedding_dim = embedding_dim,
133+
.embedding_model = embedding_model,
134+
});
135+
defer schema_result.deinit(allocator);
136+
137+
// Insert a symbol to mark index as populated — required for mismatch detection
138+
var sym = model.Symbol{
139+
.language = try allocator.dupe(u8, "zig"),
140+
.file_path = try allocator.dupe(u8, "src/a.zig"),
141+
.name = try allocator.dupe(u8, "a"),
142+
.signature = try allocator.dupe(u8, "fn a() void"),
143+
.doc_comment = null,
144+
.start_line = 1,
145+
.end_line = 1,
146+
};
147+
defer sym.deinit(allocator);
148+
_ = try storage.insertSymbol(db, sym);
149+
150+
return full_path;
151+
}
152+
153+
test "checkIndexConsistency returns null when model and dim match" {
154+
const allocator = std.testing.allocator;
155+
var tmp = std.testing.tmpDir(.{});
156+
defer tmp.cleanup();
157+
158+
const path = try writeTempDbWithSchema(allocator, tmp.dir, "ok.sqlite3", "bge-large", 1024);
159+
defer allocator.free(path);
160+
161+
const failure = try checkIndexConsistency(allocator, path, "bge-large", 1024);
162+
try std.testing.expect(failure == null);
163+
}
164+
165+
test "checkIndexConsistency detects model mismatch on populated index" {
166+
const allocator = std.testing.allocator;
167+
var tmp = std.testing.tmpDir(.{});
168+
defer tmp.cleanup();
169+
170+
const path = try writeTempDbWithSchema(allocator, tmp.dir, "mm.sqlite3", "bge-large", 1024);
171+
defer allocator.free(path);
172+
173+
var failure = (try checkIndexConsistency(allocator, path, "jina-code-embeddings-1.5b-mlx", 1024)) orelse {
174+
try std.testing.expect(false);
175+
return;
176+
};
177+
defer failure.deinit(allocator);
178+
179+
try std.testing.expect(failure == .schema_mismatch);
180+
try std.testing.expect(failure.schema_mismatch.model_mismatch);
181+
try std.testing.expect(!failure.schema_mismatch.dim_mismatch);
182+
try std.testing.expect(failure.schema_mismatch.stored_model != null);
183+
try std.testing.expectEqualStrings("bge-large", failure.schema_mismatch.stored_model.?);
184+
try std.testing.expectEqualStrings("jina-code-embeddings-1.5b-mlx", failure.schema_mismatch.current_model);
185+
}
186+
187+
test "checkIndexConsistency detects both model and dim mismatch (dirtree scenario)" {
188+
const allocator = std.testing.allocator;
189+
var tmp = std.testing.tmpDir(.{});
190+
defer tmp.cleanup();
191+
192+
const path = try writeTempDbWithSchema(allocator, tmp.dir, "dual.sqlite3", "bge-large", 1024);
193+
defer allocator.free(path);
194+
195+
var failure = (try checkIndexConsistency(allocator, path, "jina-code-embeddings-1.5b-mlx", 1536)) orelse {
196+
try std.testing.expect(false);
197+
return;
198+
};
199+
defer failure.deinit(allocator);
200+
201+
try std.testing.expect(failure == .schema_mismatch);
202+
try std.testing.expect(failure.schema_mismatch.model_mismatch);
203+
try std.testing.expect(failure.schema_mismatch.dim_mismatch);
204+
try std.testing.expectEqual(@as(?usize, 1024), failure.schema_mismatch.stored_dim);
205+
try std.testing.expectEqual(@as(usize, 1536), failure.schema_mismatch.current_dim);
206+
}
207+
208+
test "formatActionable for schema_mismatch includes both errors and fix" {
209+
const allocator = std.testing.allocator;
210+
var aw: std.Io.Writer.Allocating = .init(allocator);
211+
defer aw.deinit();
212+
213+
const failure = PreflightFailure{ .schema_mismatch = .{
214+
.model_mismatch = true,
215+
.dim_mismatch = true,
216+
.stored_model = null,
217+
.current_model = "jina-code-embeddings-1.5b-mlx",
218+
.stored_dim = 1024,
219+
.current_dim = 1536,
220+
} };
221+
try formatActionable(failure, &aw.writer);
222+
223+
const out = aw.written();
224+
try std.testing.expect(std.mem.indexOf(u8, out, "Embedding model mismatch") != null);
225+
try std.testing.expect(std.mem.indexOf(u8, out, "Embedding dimension mismatch") != null);
226+
try std.testing.expect(std.mem.indexOf(u8, out, "codescan index") != null);
227+
try std.testing.expect(std.mem.indexOf(u8, out, "watcher NOT started") != null);
228+
}

0 commit comments

Comments
 (0)