Skip to content

Commit eafc4fd

Browse files
committed
feat(nrepl): ADR-0170 am1 — cider/analyze-last-stacktrace (the *cider-error* buffer)
Smell-audited: 2: ADR-0170 amendment 1 with the mandatory DA fork; the DA's Alt 2 adopted over the draft's parallel-capture channel — *e becomes the single error channel (cider-nrepl's own model), closing a real F-011 gap. - *e is set for EVERY caught REPL error (JVM clojure.main parity — catalog errors previously left it nil): the engine materializes the peeked Info via allocExceptionLoc (the VM catch-path shape) + a new ExInfo.phase byte, covering the reader-error branch too - throw stamps the live call stack onto trace-less exceptions (stampTraceIfAbsent, both backends symmetric) so a user (throw (ex-info …)) carries frames like a catalog raise - the op walks *e's ex-cause chain: class/message/phase/data + frames innermost-first with clj/repl/project/dup flags + navigable file-url; phase maps to the clojure.error vocabulary so CIDER routes compile-phase errors to inline overlays, JVM style; bare alias advertised too - FIXED en route: evalImpl fed the SCRATCH-owned request code into evals whose products outlive the message (use-after-free surfacing as garbage strings under scratch-reuse pressure) — the source is now duped onto the persistent arena, mirroring the CLI REPL's per-line dupe Verified: e2e 4j (ex-info chain / catalog+reader phases / no-error) + CLI *e pin (case 7) + the real nrepl 1.3.1 Java client driving the op.
1 parent cbcd575 commit eafc4fd

9 files changed

Lines changed: 354 additions & 9 deletions

File tree

.dev/decisions/0170_nrepl_rearchitecture_cider_fidelity.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,86 @@ binding maps — the F-011 oracle; bb's global `set!` across id-tags is
235235
bb's simplification and would let tooling-session evals rotate the
236236
user's `*1`).
237237

238+
## Amendment 1 — `cider/analyze-last-stacktrace` (the `*cider-error*` buffer)
239+
240+
**Context** (user field report, 2026-07-14): CIDER's `*cider-error*`
241+
buffer renders "at [no stack trace available]" against cljw. CIDER's
242+
error handler is a 3-way decision (cider-compilation.el
243+
`cider-default-err-handler`): the op `cider/analyze-last-stacktrace`
244+
when advertised → the rich numbered-causes buffer with Show/Hide frame
245+
filters; else a `clojure.stacktrace` eval; else the bare print
246+
fallback cljw currently hits. The op's wire contract (cider-nrepl
247+
middleware/stacktrace.clj + orchard/stacktrace.clj + the
248+
cider-stacktrace.el renderer's `nrepl-dbind-response` bindings):
249+
250+
- One response dict per CAUSE: `class` (string), `message`, optional
251+
`phase` (clojure.error phase name), optional `data` (printed
252+
ex-data), `stacktrace` = list of frame dicts; then `{status [done]}`.
253+
No last error → `{status [no-error]}` then `{status [done]}`.
254+
- Frame keys the renderer binds: `name` `ns` `fn` `file` `file-url`
255+
`line` `class` `method` `var` `flags`; `flags` (strings) drive the
256+
Show/Hide filters (`clj` / `java` / `repl` / `tooling` / `project` /
257+
`dup`).
258+
259+
**Decision (DA Alt 2 — `*e` is the single channel).** cljw implements
260+
the op natively (no cider-nrepl), and the op **analyzes the session's
261+
`*e` on demand** — cider-nrepl's own model (`(@session #'*e)`) — rather
262+
than keeping a parallel capture channel:
263+
264+
- **`*e` is set for EVERY caught REPL error, catalog errors included**
265+
— closing a real F-011 gap: JVM `clojure.main/repl` sets `*e` for
266+
compiler errors too, while cljw only did for thrown Values. The
267+
engine materializes the peeked catalog Info (the existing
268+
`peekLastError()` — the VM catch path's `allocExceptionLoc` shape)
269+
into a GC-owned exception Value stamped with the error `phase`
270+
(a new byte on `ExInfo`), and `stars.setE`s it. Covers the reader-
271+
error branch too. Alloc failure leaves `*e` unset (honest
272+
no-error degradation). `(ex-message *e)` after `hoge` now works in
273+
BOTH REPLs (e2e-pinned).
274+
- **`throw` stamps the live call stack** onto a trace-less exception
275+
(`ex_info.stampTraceIfAbsent`, both backends symmetric) so a user
276+
`(throw (ex-info …))` carries frames exactly like a catalog raise.
277+
- The op walks `*e`'s `ex-cause` chain: one response dict per cause —
278+
`class` (the stored host-class name, e.g. "RuntimeException" for a
279+
name error — exactly what the JVM shows for unresolved symbols),
280+
`message`, `phase` (mapped: `parse``read-source`,
281+
`analysis``compile-syntax-check`, `macroexpand``macroexpansion`,
282+
`eval``execution` — drives CIDER's buffer-vs-overlay routing),
283+
`data` (printed at op time), `stacktrace` frames innermost-first.
284+
- Flags, cljw's take: every frame `clj` (there is no Java); `repl`
285+
for `<…>`-labelled sources; `project` + `file-url` for on-disk
286+
paths (navigable); `dup` per orchard's rule (same fn, or same
287+
REAL file+line). `java` / `tooling` never fire — the filter
288+
buttons degrade gracefully in CIDER.
289+
- The bare `analyze-last-stacktrace` alias is advertised too.
290+
- `inspect-last-exception` (the inspector) stays out of scope — and
291+
the `*e`-Value model (vs string snapshots) deliberately keeps that
292+
door open.
293+
- **Fixed en route**: the re-architecture's `evalImpl` fed the
294+
SCRATCH-owned request `code` into evals whose products (defn'd
295+
fns, forms) borrow source slices and outlive the message — a
296+
use-after-free surfacing as garbage strings under scratch-reuse
297+
pressure. The source is now duped onto the persistent arena
298+
(mirroring the CLI REPL's per-line dupe).
299+
300+
**Amendment alternatives considered** (fresh-context DA fork,
301+
2026-07-14): *Alt 1 smallest-diff* — sink-side gpa string-snapshot
302+
capture into `session.last_error`; rejected: fossilizes the `*e`
303+
F-011 divergence behind a second truth channel, forecloses the
304+
inspector, adds a free-on-close lifecycle and a two-channels-drift
305+
class. *Alt 2 (adopted)* — as above; the DA's factual corrections
306+
(peekLastError already existed; the reader branch bypassed
307+
handleEvalError; frame `name` is display-inert but `file-url`/`var`
308+
drive navigation; `phase` is routing, not decoration; the no-error
309+
shape is CIDER-safe byte-parity) are folded in. *Alt 3 wildcard*
310+
implement the analysis in bootstrap `.clj` over `*e` + small
311+
primitives; right eventual direction (the ADR's existing lookup-op
312+
forward note), premature while the Value→bencode walker doesn't
313+
exist.
314+
238315
## Revision history
239316

240317
- 2026-07-13: Proposed.
318+
- 2026-07-14: Amendment 1 — `cider/analyze-last-stacktrace` op +
319+
per-session structured last-error capture (the `*cider-error*`
320+
buffer); DA-fork alternatives reflected below.

src/app/eval_session.zig

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ const print = @import("../runtime/print.zig");
2929
const text_io = @import("../runtime/io/text_io.zig");
3030
const dispatch = @import("../runtime/dispatch.zig");
3131
const error_render = @import("error_render.zig");
32+
const error_mod = @import("../runtime/error/info.zig");
33+
const host_class = @import("../runtime/error/host_class.zig");
34+
const ex_info_mod = @import("../runtime/collection/ex_info.zig");
3235

3336
/// REPL history state (`*1` `*2` `*3` `*e`) with the GC pin
3437
/// discipline built in.
@@ -147,7 +150,7 @@ pub fn evalSource(
147150
while (true) {
148151
const form_opt = reader.read() catch |err| {
149152
try flushCaptured(out_var, err_var, sink);
150-
try renderAndEmit(scratch, opts, err, sink);
153+
try handleEvalError(rt, opts, ve, err, scratch, sink);
151154
ok = false;
152155
break;
153156
};
@@ -160,14 +163,14 @@ pub fn evalSource(
160163

161164
const node = analyzeForm(arena, rt, env, null, form, macro_table) catch |err| {
162165
try flushCaptured(out_var, err_var, sink);
163-
try handleEvalError(opts, ve, err, scratch, sink);
166+
try handleEvalError(rt, opts, ve, err, scratch, sink);
164167
ok = false;
165168
break;
166169
};
167170
var locals: [driver.MAX_LOCALS]Value = [_]Value{.nil_val} ** driver.MAX_LOCALS;
168171
const result = driver.evalForm(rt, env, &locals, arena, node) catch |err| {
169172
try flushCaptured(out_var, err_var, sink);
170-
try handleEvalError(opts, ve, err, scratch, sink);
173+
try handleEvalError(rt, opts, ve, err, scratch, sink);
171174
ok = false;
172175
break;
173176
};
@@ -225,6 +228,7 @@ fn rearmCapture(rt: *Runtime, out_var: ?*env_mod.Var, err_var: ?*env_mod.Var) !v
225228
}
226229

227230
fn handleEvalError(
231+
rt: *Runtime,
228232
opts: Options,
229233
ve: ?*env_mod.Var,
230234
err: anyerror,
@@ -239,14 +243,32 @@ fn handleEvalError(
239243
try stars.setE(t);
240244
if (ve) |v| _ = env_mod.setBinding(v, t);
241245
}
246+
} else if (opts.stars != null) {
247+
// JVM parity (ADR-0170 am1): clojure.main's REPL sets `*e` for
248+
// EVERY caught error, compiler errors included — materialize
249+
// the catalog Info into a GC-owned exception Value (the same
250+
// shape the VM's catch path synthesizes), stamped with the
251+
// phase so the nREPL stacktrace op can route compile-phase
252+
// errors JVM-style. An uncatchable Kind still materializes for
253+
// display ("Error"); an alloc failure leaves `*e` unset — the
254+
// stacktrace op then reports no-error, an honest degradation.
255+
if (error_mod.peekLastError()) |info| {
256+
const class = host_class.kindToHostClass(info.kind) orelse "Error";
257+
if (ex_info_mod.allocExceptionLoc(rt, info.message, class, info.location, info.trace)) |synth| {
258+
ex_info_mod.setPhase(synth, info.phase);
259+
if (opts.stars) |stars| {
260+
try stars.setE(synth);
261+
if (ve) |v| _ = env_mod.setBinding(v, synth);
262+
}
263+
} else |_| {
264+
// OOM-class alloc failure: *e stays unset — the
265+
// stacktrace op reports no-error, an honest degradation.
266+
}
267+
}
242268
}
243269
try renderAndEmitThrown(scratch, opts, err, thrown, sink);
244270
}
245271

246-
fn renderAndEmit(scratch: std.mem.Allocator, opts: Options, err: anyerror, sink: anytype) !void {
247-
try renderAndEmitThrown(scratch, opts, err, null, sink);
248-
}
249-
250272
fn renderAndEmitThrown(scratch: std.mem.Allocator, opts: Options, err: anyerror, thrown: ?Value, sink: anytype) !void {
251273
var aw: Writer.Allocating = .init(scratch);
252274
error_render.renderError(&aw.writer, .{ .file = opts.source_label, .text = opts.source }, err) catch {};

src/app/nrepl/ops.zig

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const Runtime = @import("../../runtime/runtime.zig").Runtime;
2626
const Env = @import("../../runtime/env.zig").Env;
2727
const env_mod = @import("../../runtime/env.zig");
2828
const print = @import("../../runtime/print.zig");
29+
const ex_info_mod = @import("../../runtime/collection/ex_info.zig");
2930

3031
/// Everything a handler needs for one request.
3132
pub const Ctx = struct {
@@ -75,6 +76,8 @@ const op_table = [_]OpEntry{
7576
.{ .name = "lookup", .handler = opLookup },
7677
.{ .name = "info", .handler = opLookup },
7778
.{ .name = "eldoc", .handler = opLookup },
79+
.{ .name = "cider/analyze-last-stacktrace", .handler = opAnalyzeLastStacktrace },
80+
.{ .name = "analyze-last-stacktrace", .handler = opAnalyzeLastStacktrace },
7881
};
7982

8083
const op_map = blk: {
@@ -197,10 +200,18 @@ const BencodeSink = struct {
197200
const Value = @import("../../runtime/value/value.zig").Value;
198201

199202
fn evalImpl(ctx: *Ctx, code_key: []const u8, emit_each: bool) anyerror!void {
200-
const code = ctx.str(code_key) orelse {
203+
const code_scratch = ctx.str(code_key) orelse {
201204
try ctx.respondStatus(&.{ "error", "no-code", "done" });
202205
return;
203206
};
207+
// The decoded request lives on the per-message SCRATCH arena, but
208+
// eval products (defn'd Functions, forms, analysis nodes) borrow
209+
// slices of the source text and outlive the message — dupe the
210+
// source onto the persistent arena (exactly like the CLI REPL's
211+
// per-line dupe). Without this, the next message's scratch reset
212+
// leaves every borrowed slice dangling (caught as garbage frame
213+
// strings in stamped traces under GC/scratch-reuse pressure).
214+
const code = try ctx.persist.dupe(u8, code_scratch);
204215
const sess = try ctx.registry.getOrDefault(ctx.str("session"));
205216

206217
// ns honoring: an explicit request `ns` must exist (spec:
@@ -359,6 +370,124 @@ fn opLookup(ctx: *Ctx) anyerror!void {
359370
}
360371
}
361372

373+
// --- cider/analyze-last-stacktrace (ADR-0170 am1: the *cider-error* buffer) ---
374+
375+
/// Analyze the session's `*e` on demand (the cider-nrepl model: the op
376+
/// reads the session's last exception, it does not keep a parallel
377+
/// error channel). One response dict per cause in the `ex-cause`
378+
/// chain, then `done`; no `*e` → `no-error` + `done`.
379+
fn opAnalyzeLastStacktrace(ctx: *Ctx) anyerror!void {
380+
const sess = try ctx.registry.getOrDefault(ctx.str("session"));
381+
const e = sess.stars.values[eval_session.StarState.idx_e];
382+
if (e.isNil()) {
383+
try ctx.respondStatus(&.{"no-error"});
384+
try ctx.respondStatus(&.{"done"});
385+
return;
386+
}
387+
var cur = e;
388+
while (!cur.isNil()) {
389+
try emitCause(ctx, cur);
390+
cur = if (cur.tag() == .ex_info) ex_info_mod.cause(cur) else Value.nil_val;
391+
}
392+
try ctx.respondStatus(&.{"done"});
393+
}
394+
395+
/// clojure.error phase vocabulary for a stamped cljw phase (drives
396+
/// CIDER's buffer-vs-overlay routing: compile phases render as an
397+
/// inline overlay, JVM style).
398+
fn phaseName(p: @import("../../runtime/error/info.zig").Phase) []const u8 {
399+
return switch (p) {
400+
.parse => "read-source",
401+
.analysis => "compile-syntax-check",
402+
.macroexpand => "macroexpansion",
403+
.eval => "execution",
404+
};
405+
}
406+
407+
fn emitCause(ctx: *Ctx, v: Value) !void {
408+
var entries: std.ArrayList(bencode.Decoded.Entry) = .empty;
409+
if (v.tag() == .ex_info) {
410+
const class = ex_info_mod.className(v) orelse "ExceptionInfo";
411+
try entries.append(ctx.scratch, .{ .key = "class", .value = .{ .str = class } });
412+
try entries.append(ctx.scratch, .{ .key = "message", .value = .{ .str = ex_info_mod.message(v) } });
413+
if (ex_info_mod.phaseOf(v)) |p| {
414+
try entries.append(ctx.scratch, .{ .key = "phase", .value = .{ .str = phaseName(p) } });
415+
}
416+
const d = ex_info_mod.data(v);
417+
if (!d.isNil()) {
418+
var aw: Writer.Allocating = .init(ctx.scratch);
419+
if (print.printResult(ctx.rt, ctx.env, &aw.writer, d)) {
420+
try entries.append(ctx.scratch, .{ .key = "data", .value = .{ .str = aw.written() } });
421+
} else |_| {
422+
// Unprintable ex-data: omit the key; the cause still renders.
423+
}
424+
}
425+
try entries.append(ctx.scratch, .{ .key = "stacktrace", .value = try emitFrames(ctx, ex_info_mod.originTrace(v)) });
426+
} else {
427+
// A raw thrown Value (`(throw 42)`): render it as the message.
428+
var aw: Writer.Allocating = .init(ctx.scratch);
429+
print.printResult(ctx.rt, ctx.env, &aw.writer, v) catch {};
430+
try entries.append(ctx.scratch, .{ .key = "class", .value = .{ .str = "Throwable" } });
431+
try entries.append(ctx.scratch, .{ .key = "message", .value = .{ .str = aw.written() } });
432+
try entries.append(ctx.scratch, .{ .key = "stacktrace", .value = .{ .list = &.{} } });
433+
}
434+
try ctx.respond(entries.items);
435+
}
436+
437+
/// Emit the trace as CIDER frame dicts, innermost-FIRST (JVM
438+
/// getStackTrace order; cljw's trace is push-order = outermost-first,
439+
/// so iterate reversed). Flags, cljw's take: every frame `clj` (there
440+
/// is no Java); `repl` for REPL-ish sources (`<repl:N>` / `<nrepl>` /
441+
/// `<stdin>` / `<-e>`); `project` for on-disk paths; `dup` when a
442+
/// frame repeats its list predecessor's fn or file+line (orchard's
443+
/// rule). `file-url` (absolute path) makes the frame navigable.
444+
fn emitFrames(ctx: *Ctx, trace: ?[]const @import("../../runtime/error/info.zig").StackFrame) !bencode.Decoded {
445+
const t = trace orelse return .{ .list = &.{} };
446+
var items: std.ArrayList(bencode.Decoded) = .empty;
447+
var prev_fn: ?[]const u8 = null;
448+
var prev_file: ?[]const u8 = null;
449+
var prev_line: u32 = 0;
450+
var i: usize = t.len;
451+
while (i > 0) {
452+
i -= 1;
453+
const fr = t[i];
454+
const fn_name = fr.fn_name orelse "unknown";
455+
const file = fr.file orelse "unknown";
456+
const name = if (fr.ns) |ns|
457+
try std.fmt.allocPrint(ctx.scratch, "{s}/{s}", .{ ns, fn_name })
458+
else
459+
fn_name;
460+
461+
var flags: std.ArrayList(bencode.Decoded) = .empty;
462+
try flags.append(ctx.scratch, .{ .str = "clj" });
463+
const replish = file.len > 0 and file[0] == '<';
464+
const on_disk = file.len > 0 and file[0] == '/';
465+
if (replish) try flags.append(ctx.scratch, .{ .str = "repl" });
466+
if (on_disk) try flags.append(ctx.scratch, .{ .str = "project" });
467+
// file+line dup only for REAL locations — "unknown"-file frames
468+
// at the same line are distinct calls, not duplicates.
469+
const dup = (prev_fn != null and std.mem.eql(u8, prev_fn.?, fn_name)) or
470+
(on_disk and prev_file != null and std.mem.eql(u8, prev_file.?, file) and prev_line == fr.line and fr.line != 0);
471+
if (dup) try flags.append(ctx.scratch, .{ .str = "dup" });
472+
prev_fn = fn_name;
473+
prev_file = file;
474+
prev_line = fr.line;
475+
476+
var fe: std.ArrayList(bencode.Decoded.Entry) = .empty;
477+
try fe.append(ctx.scratch, .{ .key = "name", .value = .{ .str = name } });
478+
try fe.append(ctx.scratch, .{ .key = "fn", .value = .{ .str = fn_name } });
479+
if (fr.ns) |ns| try fe.append(ctx.scratch, .{ .key = "ns", .value = .{ .str = ns } });
480+
try fe.append(ctx.scratch, .{ .key = "var", .value = .{ .str = name } });
481+
try fe.append(ctx.scratch, .{ .key = "file", .value = .{ .str = file } });
482+
if (on_disk) try fe.append(ctx.scratch, .{ .key = "file-url", .value = .{ .str = file } });
483+
try fe.append(ctx.scratch, .{ .key = "line", .value = .{ .int = fr.line } });
484+
try fe.append(ctx.scratch, .{ .key = "flags", .value = .{ .list = flags.items } });
485+
try fe.append(ctx.scratch, .{ .key = "type", .value = .{ .str = "clj" } });
486+
try items.append(ctx.scratch, .{ .dict = fe.items });
487+
}
488+
return .{ .list = items.items };
489+
}
490+
362491
fn lookupMiss(ctx: *Ctx, is_eldoc: bool) !void {
363492
if (is_eldoc) {
364493
try ctx.respondStatus(&.{ "done", "no-eldoc" });

src/eval/backend/tree_walk.zig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1061,6 +1061,9 @@ fn evalRecur(rt: *Runtime, env: *Env, locals: []Value, n: node_mod.RecurNode) an
10611061
fn evalThrow(rt: *Runtime, env: *Env, locals: []Value, n: node_mod.ThrowNode) anyerror!Value {
10621062
const v = try eval(rt, env, locals, n.expr);
10631063
dispatch.last_thrown_exception = v;
1064+
// Stamp the live call stack onto a trace-less exception so the
1065+
// throw carries frames like a catalog raise (ADR-0170 am1).
1066+
ex_info_collection.stampTraceIfAbsent(rt, v, error_mod.currentStack());
10641067
// Snapshot *error-context* now — the `binding` frame is still live
10651068
// here, but is popped by `defer popFrame` as this error unwinds, so
10661069
// the renderer cannot deref it later (ADR-0055 am2 / D-144).

src/eval/backend/vm.zig

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,6 +979,10 @@ inline fn stepOnce(
979979
if (sp == 0) return raiseInternal("vm: op_throw on empty stack");
980980
sp -= 1;
981981
dispatch.last_thrown_exception = stack[sp];
982+
// Stamp the live call stack onto a trace-less exception so
983+
// the throw carries frames like a catalog raise (ADR-0170
984+
// am1) — symmetric with TreeWalk's evalThrow.
985+
ex_info_mod.stampTraceIfAbsent(rt, stack[sp], error_mod.currentStack());
982986
// Snapshot *error-context* while the binding frame is
983987
// live (ADR-0055 am2 / D-144) — symmetric with TreeWalk's
984988
// evalThrow so the two backends agree at the throw edge.

0 commit comments

Comments
 (0)