Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
45 changes: 45 additions & 0 deletions go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
52 changes: 52 additions & 0 deletions go/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()

Expand Down
180 changes: 180 additions & 0 deletions go/julia_client_runtime.jl
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading