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
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ jobs:
with:
go-version-file: go.mod
- uses: julia-actions/setup-julia@v2
- run: julia -e 'using Pkg; Pkg.add("Revise")'
- run: go test -C go -v -timeout 300s
11 changes: 10 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,13 @@ module github.com/Beforerr/julia-client

go 1.25.0

require golang.org/x/sync v0.20.0 // indirect
require (
github.com/stretchr/testify v1.11.1
golang.org/x/sync v0.20.0
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,2 +1,12 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
152 changes: 86 additions & 66 deletions go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"sync"
"testing"
"time"

"github.com/stretchr/testify/require"
)

// TestMain allows the test binary to act as the CLI when TEST_CLI=1,
Expand All @@ -35,14 +37,10 @@ func TestPkgPattern(t *testing.T) {
"# no package ops here",
}
for _, s := range hits {
if !pkgPattern.MatchString(s) {
t.Errorf("pkgPattern should match %q", s)
}
require.Truef(t, pkgPattern.MatchString(s), "pkgPattern should match %q", s)
}
for _, s := range misses {
if pkgPattern.MatchString(s) {
t.Errorf("pkgPattern should not match %q", s)
}
require.Falsef(t, pkgPattern.MatchString(s), "pkgPattern should not match %q", s)
}
}

Expand All @@ -60,38 +58,30 @@ func newTestState() *daemonState {
func TestHandleRequest_Ping(t *testing.T) {
state := newTestState()
resp := handleRequest(state, protocolRequest{Action: "ping"})
if resp.Output != "pong" {
t.Errorf("ping response = %v, want pong", resp.Output)
}
require.Equal(t, "pong", resp.Output)
}

func TestHandleRequest_SessionsEmpty(t *testing.T) {
state := newTestState()
resp := handleRequest(state, protocolRequest{Action: "sessions"})
if resp.Output != "No active Julia sessions." {
t.Errorf("sessions response = %q", resp.Output)
}
require.Equal(t, "No active Julia sessions.", resp.Output)
}

func TestHandleRequest_UnknownAction(t *testing.T) {
state := newTestState()
resp := handleRequest(state, protocolRequest{Action: "bogus"})
if resp.Error == "" {
t.Error("expected error for unknown action")
}
require.NotEmpty(t, resp.Error)
}

func TestHandleRequest_Stop(t *testing.T) {
state := newTestState()
resp := handleRequest(state, protocolRequest{Action: "stop"})
if resp.Output != "Daemon stopping." {
t.Errorf("stop response = %v", resp.Output)
}
require.Equal(t, "Daemon stopping.", resp.Output)
select {
case <-state.stopCh:
// closed as expected
default:
t.Error("stopCh not closed after stop action")
require.Fail(t, "stopCh not closed after stop action")
}
}

Expand All @@ -101,14 +91,18 @@ func TestHandleRequest_Stop(t *testing.T) {
// The returned WaitGroup is done when the daemon exits.
func startTestDaemon(t *testing.T) (socketPath string, stop func(), wg *sync.WaitGroup) {
t.Helper()
socketPath = filepath.Join(t.TempDir(), "test.sock")
socketDir, err := os.MkdirTemp("/tmp", "julia-client-test-")
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(socketDir) })
socketPath = filepath.Join(socketDir, "test.sock")
errCh := make(chan error, 1)
wg = &sync.WaitGroup{}
wg.Add(1)
go func() {
defer wg.Done()
serveDaemon(socketPath, time.Hour)
errCh <- serveDaemon(socketPath, time.Hour)
}()
waitForSocket(t, socketPath)
waitForSocket(t, socketPath, errCh)
stop = func() {
conn, _ := net.Dial("unix", socketPath)
if conn != nil {
Expand All @@ -120,28 +114,31 @@ func startTestDaemon(t *testing.T) (socketPath string, stop func(), wg *sync.Wai
return
}

func waitForSocket(t *testing.T, socketPath string) {
func waitForSocket(t *testing.T, socketPath string, errCh <-chan error) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
select {
case err := <-errCh:
require.NoError(t, err)
default:
}
if _, err := os.Stat(socketPath); err == nil {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("daemon socket did not appear in time")
require.Fail(t, "daemon socket did not appear in time")
}

func sendRequest(t *testing.T, socketPath string, req protocolRequest) response {
t.Helper()
conn, err := net.Dial("unix", socketPath)
if err != nil {
t.Fatalf("dial: %v", err)
}
require.NoError(t, err)
defer conn.Close()
json.NewEncoder(conn).Encode(req)
require.NoError(t, json.NewEncoder(conn).Encode(req))
var resp response
json.NewDecoder(conn).Decode(&resp)
require.NoError(t, json.NewDecoder(conn).Decode(&resp))
return resp
}

Expand All @@ -152,9 +149,7 @@ func TestDaemonPingOverSocket(t *testing.T) {
defer stop()

resp := sendRequest(t, socketPath, protocolRequest{Action: "ping"})
if resp.Output != "pong" {
t.Errorf("ping over socket = %v, want pong", resp.Output)
}
require.Equal(t, "pong", resp.Output)
}

// ---- Julia integration ----
Expand All @@ -163,7 +158,8 @@ func TestEvalBasic(t *testing.T) {
socketPath, stop, _ := startTestDaemon(t)
defer stop()

cwd, _ := os.Getwd()
cwd, err := os.Getwd()
require.NoError(t, err)
send := func(req protocolRequest) response {
if req.Cwd == "" {
req.Cwd = cwd
Expand All @@ -173,38 +169,28 @@ func TestEvalBasic(t *testing.T) {

// Eval basic expression
resp := send(protocolRequest{Action: "eval", Code: `println("hello world")`})
if resp.Error != "" {
t.Fatalf("eval error: %v", resp.Error)
}
out := resp.Output
if out != "hello world\n" {
t.Errorf("eval output = %q, want %q", out, "hello world\n")
}
require.Empty(t, resp.Error)
require.Equal(t, "hello world\n", resp.Output)

// State persists across calls
send(protocolRequest{Action: "eval", Code: "x = 42"})
resp = send(protocolRequest{Action: "eval", Code: "x = 42"})
require.Empty(t, resp.Error)
resp2 := send(protocolRequest{Action: "eval", Code: "println(x)"})
out2 := resp2.Output
if out2 != "42\n" {
t.Errorf("state not persisted: x = %q, want %q", out2, "42\n")
}
require.Empty(t, resp2.Error)
require.Equal(t, "42\n", resp2.Output)

// Fresh eval clears state before running code.
resp3 := send(protocolRequest{Action: "eval", Code: "println(isdefined(Main, :x))", Fresh: true})
out3 := resp3.Output
if out3 != "false\n" {
t.Errorf("after fresh eval x should be undefined, got %q", out3)
}
require.Empty(t, resp3.Error)
require.Equal(t, "false\n", resp3.Output)

// println adds trailing newline; print does not
resp4 := send(protocolRequest{Action: "eval", Code: `print("no-nl")`})
if resp4.Output != "no-nl" {
t.Errorf("print output = %q, want %q", resp4.Output, "no-nl")
}
require.Empty(t, resp4.Error)
require.Equal(t, "no-nl", resp4.Output)
resp5 := send(protocolRequest{Action: "eval", Code: `println("with-nl")`})
if resp5.Output != "with-nl\n" {
t.Errorf("println output = %q, want %q", resp5.Output, "with-nl\n")
}
require.Empty(t, resp5.Error)
require.Equal(t, "with-nl\n", resp5.Output)
}

// TestScriptFile exercises the full main() routing: julia-client script.jl
Expand All @@ -216,33 +202,67 @@ func TestScriptFile(t *testing.T) {
cmd := exec.Command(os.Args[0], "--socket", socketPath, "testdata/compute.jl")
cmd.Env = append(os.Environ(), "TEST_CLI=1")
out, err := cmd.Output()
stderr := ""
if err != nil {
stderr := ""
if e, ok := err.(*exec.ExitError); ok {
stderr = string(e.Stderr)
}
t.Fatalf("script run failed: %v\n%s", err, stderr)
}
if got := string(out); got != "42\n" {
t.Errorf("script output = %q, want %q", got, "42\n")
}
require.NoErrorf(t, err, "script stderr:\n%s", stderr)
require.Equal(t, "42\n", string(out))
}

func TestPrintResult(t *testing.T) {
socketPath, stop, _ := startTestDaemon(t)
defer stop()

cwd, _ := os.Getwd()
cwd, err := os.Getwd()
require.NoError(t, err)
resp := sendRequest(t, socketPath, protocolRequest{
Action: "eval",
Code: "1 + 1",
Cwd: cwd,
PrintResult: true,
})
if resp.Error != "" {
t.Fatalf("print_result error: %v", resp.Error)
require.Empty(t, resp.Error)
require.Equal(t, "2\n", resp.Output)
}

func TestRevisePicksUpPackageChanges(t *testing.T) {
socketPath, stop, _ := startTestDaemon(t)
defer stop()

pkgDir := t.TempDir()
srcDir := filepath.Join(pkgDir, "src")
require.NoError(t, os.Mkdir(srcDir, 0755))
projectToml, err := os.ReadFile(filepath.Join("testdata", "TestRevPkg", "Project.toml"))
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(pkgDir, "Project.toml"), projectToml, 0644))

srcFile := filepath.Join(srcDir, "TestRevPkg.jl")
writePackage := func(greeting string) {
t.Helper()
err := os.WriteFile(srcFile, []byte("module TestRevPkg\ngreet() = "+greeting+"\nend\n"), 0644)
require.NoError(t, err)
}
if resp.Output != "2\n" {
t.Errorf("print_result output = %q, want %q", resp.Output, "2\n")
writePackage(`"hello"`)

send := func(code string) response {
t.Helper()
resp := sendRequest(t, socketPath, protocolRequest{
Action: "eval",
Code: code,
Cwd: pkgDir,
})
require.Empty(t, resp.Error, "eval %q failed", code)
return resp
}

send("using TestRevPkg")
resp := send("println(TestRevPkg.greet())")
require.Equal(t, "hello\n", resp.Output)

writePackage(`"goodbye"`)
resp = send("println(TestRevPkg.greet())")
require.Equal(t, "goodbye\n", resp.Output)
}
8 changes: 5 additions & 3 deletions go/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ func newJuliaSession(projectVal, sentinel, juliaCmd string, logFile *os.File) *J
}
}


func (s *JuliaSession) start(workDir string) error {
exe := "julia"
var channelArgs, extraFlags []string
Expand Down Expand Up @@ -116,6 +115,9 @@ func (s *JuliaSession) start(workDir string) error {
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)
}
return nil
}

Expand Down Expand Up @@ -199,12 +201,12 @@ func (s *JuliaSession) execute(code string, timeoutSecs float64, printResult boo
var wrapped string
if printResult {
wrapped = fmt.Sprintf(
`show(IOContext(stdout, :limit => true), MIME("text/plain"), include_string(Main, String(hex2bytes("%s"))));println(stdout)`,
`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(
`include_string(Main, String(hex2bytes("%s")));nothing`,
`try; Revise.revise(); catch; end; include_string(Main, String(hex2bytes("%s")));nothing`,
hexCode,
)
}
Expand Down
3 changes: 3 additions & 0 deletions go/testdata/TestRevPkg/Project.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
name = "TestRevPkg"
uuid = "12345678-1234-1234-1234-123456789abc"
version = "0.1.0"
10 changes: 5 additions & 5 deletions skills/julia-client/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
---
name: julia-client
description: "Run Julia code with session state persistence, project env auto-detection and timeout handling. Use for efficient Julia code execution."
description: "Run Julia code with session state persistence, project env auto-detection. Use for efficient Julia code execution, testing, and development."
---

## Running code

```bash
julia-client -e 'const x=1' # Evaluate
julia-client -e 'x=1' # Evaluate
julia-client -E 'x' # Evaluate and display
julia-client --fresh -E 'x=2' # Run with clean session state

# Long-running tasks (pkg install, compile, heavy compute): set longer timeout or disable (0)
# Long-running tasks (pkg install, compile, plot, heavy compute): set longer timeout or disable timeout (0)
julia-client --timeout 300 heavy_script.jl
```

## Tips

- Only run setup (e.g. `Pkg.activate`, `using`) once per session.
- 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.

## Session management

Expand Down
Loading