From b49fe268b517ad31014fe65536e3136624c7395a Mon Sep 17 00:00:00 2001 From: Beforerr Date: Thu, 30 Apr 2026 00:08:47 -0700 Subject: [PATCH] feat: add traceback level control and trace command Introduce three traceback levels (short/smart/full) for error output, a new `trace` command to inspect saved errors without rerunning code, and automatic error capture per session. Smart level filters internal frames to show user/project code plus boundary context. --- README.md | 11 +++ go/client_test.go | 45 +++++++++ go/daemon.go | 52 ++++++++++ go/julia_client_runtime.jl | 180 +++++++++++++++++++++++++++++++++++ go/main.go | 62 ++++++++---- go/session.go | 125 +++++++++++++++++++----- skills/julia-client/SKILL.md | 4 +- 7 files changed, 437 insertions(+), 42 deletions(-) create mode 100644 go/julia_client_runtime.jl diff --git a/README.md b/README.md index 766b89d..d672324 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,20 @@ echo 'println("hello")' | julia-client # Session management julia-client sessions # list active sessions +julia-client trace # show the last saved Julia traceback without rerunning julia-client stop # shut down the daemon + +# Traceback levels +julia-client --trace full -e 'error("boom")' +julia-client trace --trace smart ``` +Traceback levels: + +- `short`: exception message only. +- `smart`: default eval output; user/project frames plus nearby boundary frames, hiding Julia/client internals. +- `full`: Julia's full traceback, including runtime and REPL frames. + ## Architecture A single `julia-client` binary serves as both client and daemon: diff --git a/go/client_test.go b/go/client_test.go index 690c4d5..d6d18e1 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -228,6 +228,51 @@ func TestPrintResult(t *testing.T) { require.Equal(t, "2\n", resp.Output) } +func TestEvalErrorTraceSaved(t *testing.T) { + socketPath, stop, _ := startTestDaemon(t) + defer stop() + + cwd, err := os.Getwd() + require.NoError(t, err) + + resp := sendRequest(t, socketPath, protocolRequest{ + Action: "eval", + Code: `let f = () -> error("boom"); g = () -> f(); g(); end`, + Cwd: cwd, + }) + require.Empty(t, resp.Output) + require.Contains(t, resp.Error, "boom") + require.Contains(t, resp.Error, "Stacktrace:") + require.Contains(t, resp.Error, "julia-client trace") + require.NotContains(t, resp.Error, "eval_user_input") + + trace := sendRequest(t, socketPath, protocolRequest{ + Action: "trace", + Cwd: cwd, + TraceLevel: "smart", + }) + require.Empty(t, trace.Error) + require.Contains(t, trace.Output, "Stacktrace:") + require.Contains(t, trace.Output, "julia-client-eval") + require.NotContains(t, trace.Output, "eval_user_input") + + trace = sendRequest(t, socketPath, protocolRequest{ + Action: "trace", + Cwd: cwd, + TraceLevel: "full", + }) + require.Empty(t, trace.Error) + require.Contains(t, trace.Output, "eval_user_input") + + resp = sendRequest(t, socketPath, protocolRequest{ + Action: "eval", + Code: `let f = () -> error("boom"); g = () -> f(); g(); end`, + Cwd: cwd, + TraceLevel: "full", + }) + require.Contains(t, resp.Error, "Stacktrace:") +} + func TestRevisePicksUpPackageChanges(t *testing.T) { socketPath, stop, _ := startTestDaemon(t) defer stop() diff --git a/go/daemon.go b/go/daemon.go index d3534c4..728bf51 100644 --- a/go/daemon.go +++ b/go/daemon.go @@ -53,10 +53,24 @@ func handleRequest(state *daemonState, req protocolRequest) response { if !sess.isAlive() { state.manager.remove(req.Session, req.Project, req.Cwd) } + if juliaErr, ok := err.(*juliaEvalError); ok { + state.manager.recordError(req.Session, req.Project, req.Cwd, juliaErr) + return response{ + Output: output, + Error: formatJuliaError(juliaErr, req.TraceLevel), + } + } return errResp(err.Error()) } return response{Output: output} + case "trace": + err := state.manager.lastError(req.Session, req.Project, req.Cwd) + if err == nil { + return errResp("No saved Julia traceback for this session.") + } + return response{Output: formatTraceOutput(err, req.TraceLevel)} + case "sessions": sessions := state.manager.list() if len(sessions) == 0 { @@ -95,6 +109,44 @@ func errResp(msg string) response { return response{Error: msg} } +func normalizedTraceLevel(level string) string { + switch strings.ToLower(level) { + case "short", "compact": + return "short" + case "", "smart", "default": + return "smart" + case "full", "long", "verbose": + return "full" + default: + return "smart" + } +} + +func formatJuliaError(err *juliaEvalError, level string) string { + switch normalizedTraceLevel(level) { + case "short": + return err.short + "\n\nTrace saved: run `julia-client trace --trace [smart|full]` to inspect" + case "full": + return err.full + default: + return err.smart + "Trace saved: run `julia-client trace` to inspect" + } +} + +func formatTraceOutput(err *juliaEvalError, level string) string { + if level == "" { + level = "full" + } + switch normalizedTraceLevel(level) { + case "short": + return err.short + "\n" + case "full": + return err.full + "\n" + default: + return err.smart + } +} + func handleConn(conn net.Conn, state *daemonState) { defer conn.Close() diff --git a/go/julia_client_runtime.jl b/go/julia_client_runtime.jl new file mode 100644 index 0000000..3fbced9 --- /dev/null +++ b/go/julia_client_runtime.jl @@ -0,0 +1,180 @@ +using InteractiveUtils + +try + using Revise +catch +end + +module JuliaClientRuntime + +function _file(frame) + return replace(String(frame.file), '\\' => '/') +end + +function _module_name(frame) + mod = parentmodule(frame) + return mod === nothing ? "Unknown" : string(mod) +end + +function _roots() + roots = String[pwd()] + try + active = Base.active_project() + active === nothing || push!(roots, dirname(active)) + catch + end + try + push!(roots, joinpath(homedir(), ".julia", "dev")) + catch + end + return unique!(replace.(abspath.(roots), '\\' => '/')) +end + +function _under(path, root) + root == "" && return false + return path == root || startswith(path, root * "/") +end + +function _is_julia_internal(path) + startswith(path, "./") && return true + contains(path, "/base/") && return true + contains(path, "/julia/stdlib/") && return true + contains(path, "/share/julia/stdlib/") && return true + return false +end + +function _is_pkg_cache(path) + return contains(path, "/.julia/packages/") +end + +function _debug_entries() + entries = split(get(ENV, "JULIA_DEBUG", ""), ",") + return strip.(filter(x -> !isempty(x), entries)) +end + +function _user_frame(frame, roots, debug_entries) + path = _file(frame) + pseudo_path = startswith(path, "./") ? path[3:end] : path + pseudo_path == "julia-client-eval" && return true + startswith(path, "REPL") && return true + + base = splitext(basename(path))[1] + mod = _module_name(frame) + for entry in debug_entries + startswith(entry, "!") && continue + if entry == base || entry == mod + return true + end + end + + if !(startswith(path, "/")) + return false + end + apath = replace(abspath(path), '\\' => '/') + any(root -> _under(apath, root), roots) && return true + contains(apath, "/.julia/dev/") && return true + _is_julia_internal(apath) && return false + _is_pkg_cache(apath) && return false + return false +end + +function _visible_indices(frames) + roots = _roots() + debug_entries = _debug_entries() + user = findall(frame -> _user_frame(frame, roots, debug_entries), frames) + visible = Set{Int}() + for i in user + push!(visible, i) + i > 1 && push!(visible, i - 1) + end + isempty(visible) && !isempty(frames) && push!(visible, 1) + return sort!(collect(visible)) +end + +function _omitted_modules(frames, first_i, last_i) + first_i > last_i && return "" + modules = String[] + for frame in @view frames[first_i:last_i] + name = _module_name(frame) + name in ("Core", "Main.Unknown") && continue + push!(modules, name) + end + unique!(modules) + length(modules) > 6 && (modules = vcat(modules[1:6], ["..."])) + isempty(modules) && push!(modules, "Unknown") + return " ... internal @ " * join(modules, ", ") +end + +function _frame_line(i, frame) + sig = split(sprint(show, frame), " at "; limit=2)[1] + file = String(frame.file) + line = frame.line == 0 ? "?" : string(frame.line) + return " [" * string(i) * "] " * sig * " @ " * file * ":" * line +end + +function _render_selected(frames; include_omitted) + io = IOBuffer() + visible = _visible_indices(frames) + isempty(frames) && return "" + println(io, "Stacktrace:") + last_i = 0 + for i in visible + if include_omitted && i > last_i + 1 + println(io, _omitted_modules(frames, last_i + 1, i - 1)) + end + println(io, _frame_line(i, frames[i])) + last_i = i + end + if include_omitted && last_i < length(frames) + println(io, _omitted_modules(frames, last_i + 1, length(frames))) + end + return String(take!(io)) +end + +function _display_error(err) + while err isa LoadError + err = err.error + end + return err +end + +function _render_error(err, bt) + frames = stacktrace(bt) + cut = findfirst(frame -> frame.func === :include_string || String(frame.file) == "none", frames) + cut === nothing || (frames = frames[1:max(cut - 2, 1)]) + display_err = _display_error(err) + short = "ERROR: " * sprint(showerror, display_err) + smart = short * "\n" * _render_selected(frames; include_omitted=true) + full = sprint(showerror, err, bt) + return short, smart, full +end + +function _write_error(start_marker, end_marker, short, smart, full) + write(stdout, "\n") + println(stdout, start_marker) + println(stdout, bytes2hex(Vector{UInt8}(codeunits(short)))) + println(stdout, bytes2hex(Vector{UInt8}(codeunits(smart)))) + println(stdout, bytes2hex(Vector{UInt8}(codeunits(full)))) + println(stdout, end_marker) +end + +function run(hex_code, print_result, start_marker, end_marker) + code = String(hex2bytes(hex_code)) + try + try + isdefined(Main, :Revise) && Main.Revise.revise() + catch + end + value = include_string(Main, code, "julia-client-eval") + if print_result + show(IOContext(stdout, :limit => true), MIME("text/plain"), value) + println(stdout) + end + catch err + short, smart, full = _render_error(err, catch_backtrace()) + _write_error(start_marker, end_marker, short, smart, full) + end + return nothing +end + +end diff --git a/go/main.go b/go/main.go index 122a8e3..5754438 100644 --- a/go/main.go +++ b/go/main.go @@ -61,6 +61,7 @@ type protocolRequest struct { JuliaCmd string `json:"julia_cmd,omitempty"` PrintResult bool `json:"print_result,omitempty"` Fresh bool `json:"fresh,omitempty"` + TraceLevel string `json:"trace_level,omitempty"` } func request(socketPath string, req protocolRequest, startIfNeeded bool) (response, error) { @@ -91,13 +92,13 @@ func run(socketPath string, req protocolRequest, startIfNeeded bool) { fmt.Fprintln(os.Stderr, err) os.Exit(1) } + if resp.Output != "" { + fmt.Print(resp.Output) + } if resp.Error != "" { fmt.Fprintln(os.Stderr, resp.Error) os.Exit(1) } - if resp.Output != "" { - fmt.Print(resp.Output) - } } func mustGetwd() string { @@ -109,7 +110,15 @@ func mustGetwd() string { return cwd } -func cmdEval(socketPath, code, project, session string, timeout float64, juliaCmd string, printResult, fresh bool) { +func normalizeProjectArg(project string) string { + if project == "" || project == "@." { + return project + } + projectArg, _ := filepath.Abs(project) + return projectArg +} + +func cmdEval(socketPath, code, project, session string, timeout float64, juliaCmd string, printResult, fresh bool, traceLevel string) { if code == "-" { b, err := io.ReadAll(os.Stdin) if err != nil { @@ -118,16 +127,13 @@ func cmdEval(socketPath, code, project, session string, timeout float64, juliaCm } code = string(b) } - projectArg := project - if project != "@." { - projectArg, _ = filepath.Abs(project) - } req := protocolRequest{ - Action: "eval", - Code: code, - Cwd: mustGetwd(), - Project: projectArg, - Session: session, + Action: "eval", + Code: code, + Cwd: mustGetwd(), + Project: normalizeProjectArg(project), + Session: session, + TraceLevel: traceLevel, } if timeout != -1 { req.Timeout = &timeout @@ -138,6 +144,19 @@ func cmdEval(socketPath, code, project, session string, timeout float64, juliaCm run(socketPath, req, true) } +func cmdTrace(socketPath, project, session, traceLevel string) { + if traceLevel == "" { + traceLevel = "full" + } + run(socketPath, protocolRequest{ + Action: "trace", + Cwd: mustGetwd(), + Project: normalizeProjectArg(project), + Session: session, + TraceLevel: traceLevel, + }, false) +} + // first returns the first non-empty string. func first(vals ...string) string { for _, v := range vals { @@ -163,6 +182,7 @@ Eval flags: --fresh Clear the targeted session before evaluating --timeout SECS Timeout in seconds (0 = no timeout, default: 60) --julia-cmd CMD Custom Julia binary, e.g. "julia +1.11" + --trace LEVEL Error traceback level: short, smart, or full (eval default: smart) Session routing (priority order): --session LABEL Shared by label, regardless of directory @@ -171,6 +191,7 @@ Session routing (priority order): Commands: sessions List active Julia sessions + trace Print the last saved Julia error traceback for this session stop Stop the daemon daemon Run the daemon in the foreground (normally auto-started) --idle-timeout SECS Shut down after idle (default: 1800) @@ -192,19 +213,20 @@ func main() { freshFlag := flag.Bool("fresh", false, "Clear the targeted session before evaluating") timeoutFlag := flag.Float64("timeout", -1, "Timeout in seconds") juliaCmdFlag := flag.String("julia-cmd", "", "Custom Julia binary") + traceFlag := flag.String("trace", "", "Error traceback level: short, smart, or full") flag.Usage = usage flag.Parse() // -E / --print: evaluate and display result if code := first(*printShort, *printLong); code != "" { - cmdEval(*socketFlag, code, *projectFlag, *sessionFlag, *timeoutFlag, *juliaCmdFlag, true, *freshFlag) + cmdEval(*socketFlag, code, *projectFlag, *sessionFlag, *timeoutFlag, *juliaCmdFlag, true, *freshFlag, *traceFlag) return } // -e / --eval: evaluate mode code := first(*evalShort, *evalLong) if code != "" { - cmdEval(*socketFlag, code, *projectFlag, *sessionFlag, *timeoutFlag, *juliaCmdFlag, false, *freshFlag) + cmdEval(*socketFlag, code, *projectFlag, *sessionFlag, *timeoutFlag, *juliaCmdFlag, false, *freshFlag, *traceFlag) return } @@ -216,7 +238,7 @@ func main() { if err != nil || fi.Mode()&os.ModeCharDevice != 0 { usage() } - cmdEval(*socketFlag, "-", *projectFlag, *sessionFlag, *timeoutFlag, *juliaCmdFlag, false, *freshFlag) + cmdEval(*socketFlag, "-", *projectFlag, *sessionFlag, *timeoutFlag, *juliaCmdFlag, false, *freshFlag, *traceFlag) return } @@ -224,6 +246,12 @@ func main() { case "sessions": run(*socketFlag, protocolRequest{Action: "sessions"}, false) + case "trace": + fs := flag.NewFlagSet("trace", flag.ExitOnError) + traceLevel := fs.String("trace", first(*traceFlag, "full"), "Error traceback level: short, smart, or full") + fs.Parse(args[1:]) + cmdTrace(*socketFlag, *projectFlag, *sessionFlag, *traceLevel) + case "stop": run(*socketFlag, protocolRequest{Action: "stop"}, false) @@ -243,7 +271,7 @@ func main() { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - cmdEval(*socketFlag, string(b), *projectFlag, *sessionFlag, *timeoutFlag, *juliaCmdFlag, false, *freshFlag) + cmdEval(*socketFlag, string(b), *projectFlag, *sessionFlag, *timeoutFlag, *juliaCmdFlag, false, *freshFlag, *traceFlag) return } fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0]) diff --git a/go/session.go b/go/session.go index 708ed28..bffbd20 100644 --- a/go/session.go +++ b/go/session.go @@ -3,6 +3,7 @@ package main import ( "bufio" "crypto/rand" + _ "embed" "encoding/hex" "fmt" "io" @@ -17,6 +18,9 @@ import ( "golang.org/x/sync/singleflight" ) +//go:embed julia_client_runtime.jl +var juliaClientRuntime string + const ( defaultEvalTimeout = 60.0 startupTimeout = 120.0 @@ -111,12 +115,9 @@ func (s *JuliaSession) start(workDir string) error { if _, err := s.executeRaw("", startupTimeout); err != nil { return fmt.Errorf("Julia startup failed: %w", err) } - // Mirror the Julia REPL: load InteractiveUtils so subtypes, @which, etc. work - if _, err := s.executeRaw("using InteractiveUtils", startupTimeout); err != nil { - return fmt.Errorf("failed to load InteractiveUtils: %w", err) - } - if _, err := s.executeRaw("try; using Revise; catch; end", startupTimeout); err != nil { - return fmt.Errorf("failed to initialize Revise: %w", err) + runtimeHex := hex.EncodeToString([]byte(juliaClientRuntime)) + if _, err := s.executeRaw(fmt.Sprintf(`include_string(Main, String(hex2bytes("%s")), "julia-client runtime")`, runtimeHex), startupTimeout); err != nil { + return fmt.Errorf("failed to load julia-client runtime: %w", err) } return nil } @@ -130,6 +131,67 @@ type readResult struct { err error } +type juliaEvalError struct { + short string + smart string + full string +} + +func (e *juliaEvalError) Error() string { + return e.short +} + +func (s *JuliaSession) errorStartMarker() string { + return s.sentinel + "_ERROR_START" +} + +func (s *JuliaSession) errorEndMarker() string { + return s.sentinel + "_ERROR_END" +} + +func decodeHexString(s string) (string, error) { + b, err := hex.DecodeString(strings.TrimSpace(s)) + if err != nil { + return "", err + } + return string(b), nil +} + +func (s *JuliaSession) parseJuliaError(output string) (string, *juliaEvalError) { + start := s.errorStartMarker() + idx := strings.Index(output, start+"\n") + if idx < 0 { + return output, nil + } + + prefix := output[:idx] + if len(prefix) > 0 && prefix[len(prefix)-1] == '\n' { + prefix = prefix[:len(prefix)-1] + } + + rest := output[idx+len(start)+1:] + parts := strings.SplitN(rest, "\n", 4) + if len(parts) < 4 { + return output, nil + } + decoded := make([]string, 3) + for i := range decoded { + var err error + decoded[i], err = decodeHexString(parts[i]) + if err != nil { + return output, nil + } + } + if !strings.HasPrefix(parts[3], s.errorEndMarker()) { + return output, nil + } + return prefix, &juliaEvalError{ + short: decoded[0], + smart: decoded[1], + full: decoded[2], + } +} + func (s *JuliaSession) executeRaw(code string, timeoutSecs float64) (string, error) { // The sentinel command writes an extra "\n" before the marker so it always // starts on its own line even when the user code didn't end with a newline. @@ -198,18 +260,9 @@ func (s *JuliaSession) execute(code string, timeoutSecs float64, printResult boo } hexCode := hex.EncodeToString([]byte(code)) - var wrapped string - if printResult { - wrapped = fmt.Sprintf( - `try; Revise.revise(); catch; end; show(IOContext(stdout, :limit => true), MIME("text/plain"), include_string(Main, String(hex2bytes("%s"))));println(stdout)`, - hexCode, - ) - } else { - wrapped = fmt.Sprintf( - `try; Revise.revise(); catch; end; include_string(Main, String(hex2bytes("%s")));nothing`, - hexCode, - ) - } + wrapped := fmt.Sprintf(`Main.JuliaClientRuntime.run("%s", %t, "%s", "%s")`, + hexCode, printResult, s.errorStartMarker(), s.errorEndMarker(), + ) if s.logFile != nil { fmt.Fprintf(s.logFile, "[%s] julia> %s\n", time.Now().Format("15:04:05"), code) @@ -219,9 +272,16 @@ func (s *JuliaSession) execute(code string, timeoutSecs float64, printResult boo if err != nil { return "", err } + output, juliaErr := s.parseJuliaError(output) if s.logFile != nil && output != "" { fmt.Fprintf(s.logFile, "%s\n\n", output) } + if juliaErr != nil { + if s.logFile != nil { + fmt.Fprintf(s.logFile, "%s\n\n", juliaErr.full) + } + return output, juliaErr + } return output, nil } @@ -238,17 +298,19 @@ func (s *JuliaSession) kill() { // SessionManager tracks multiple named Julia sessions. type SessionManager struct { - mu sync.Mutex - sessions map[string]*JuliaSession - sf singleflight.Group - logDir string + mu sync.Mutex + sessions map[string]*JuliaSession + lastErrors map[string]*juliaEvalError + sf singleflight.Group + logDir string } func newSessionManager() *SessionManager { logDir, _ := os.MkdirTemp("", "julia-client-logs-") return &SessionManager{ - sessions: make(map[string]*JuliaSession), - logDir: logDir, + sessions: make(map[string]*JuliaSession), + lastErrors: make(map[string]*juliaEvalError), + logDir: logDir, } } @@ -332,12 +394,27 @@ func (m *SessionManager) restart(session, project, cwd string) { m.mu.Lock() sess := m.sessions[key] delete(m.sessions, key) + delete(m.lastErrors, key) m.mu.Unlock() if sess != nil { sess.kill() } } +func (m *SessionManager) recordError(session, project, cwd string, err *juliaEvalError) { + key := m.key(session, project, cwd) + m.mu.Lock() + m.lastErrors[key] = err + m.mu.Unlock() +} + +func (m *SessionManager) lastError(session, project, cwd string) *juliaEvalError { + key := m.key(session, project, cwd) + m.mu.Lock() + defer m.mu.Unlock() + return m.lastErrors[key] +} + type sessionInfo struct { project string alive bool diff --git a/skills/julia-client/SKILL.md b/skills/julia-client/SKILL.md index 5f00bfc..3d4d94b 100644 --- a/skills/julia-client/SKILL.md +++ b/skills/julia-client/SKILL.md @@ -11,12 +11,14 @@ julia-client -E 'x' # Evaluate and display # Long-running tasks (pkg install, compile, plot, heavy compute): set longer timeout or disable timeout (0) julia-client --timeout 300 heavy_script.jl + +julia-client trace --trace full # show the last saved Julia traceback without rerunning ``` ## Tips - Run setup (e.g. `Pkg.activate`, `using PackageOnce`) once per session. -- Prefer `Revise` for automatically updating function definitions: only use `--fresh` flag when clean state is must. +- Prefer relying on `Revise` for automatically updating function definitions: only use `--fresh` flag when clean state is required. ## Session management