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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ go 1.25.0
require (
github.com/stretchr/testify v1.11.1
golang.org/x/sync v0.20.0
gopkg.in/yaml.v3 v3.0.1
)

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
)
58 changes: 26 additions & 32 deletions go/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import (
"sync"
"sync/atomic"
"time"

"gopkg.in/yaml.v3"
)

type daemonState struct {
Expand All @@ -40,41 +42,15 @@ func handleRequest(state *daemonState, req protocolRequest) response {
return response{Output: formatTraceOutput(err, req.TraceLevel)}

case "sessions":
sessions := state.manager.list()
if len(sessions) == 0 {
items := state.manager.list()
if len(items) == 0 {
return response{Output: "No active sessions."}
}
sort.Slice(sessions, func(i, j int) bool {
return sessions[i].lang+sessions[i].label < sessions[j].lang+sessions[j].label
})
lines := []string{}
for _, s := range sessions {
label := s.label
if strings.HasPrefix(label, "~") {
label = "session " + strings.TrimPrefix(label, "~")
} else {
label = "dir " + label
}
line := ""
line += s.id + " "
line += fmt.Sprintf("[%s] %s", s.lang, label)
if s.project != "" {
line += " project=" + s.project
}
if !s.alive {
line += " status=dead"
} else if s.busyFor > 0 {
line += fmt.Sprintf(" busy=%.1fs", s.busyFor.Seconds())
}
if len(s.args) > 0 {
line += " args=" + strings.Join(s.args, " ")
}
if s.logFile != "" {
line += " log=" + s.logFile
}
lines = append(lines, line)
out, err := formatSessions(items)
if err != nil {
return errResp(err.Error())
}
return response{Output: strings.Join(lines, "\n")}
return response{Output: out}

case "interrupt":
key, kerr := state.manager.targetKey(req.ID, req.Lang, req.Session, req.Cwd, discFor(req))
Expand Down Expand Up @@ -110,6 +86,24 @@ func handleRequest(state *daemonState, req protocolRequest) response {
}
}

// formatSessions renders sessions as a YAML sequence with one flow-style
// mapping per line: compact and scannable, yet a single parseable document.
func formatSessions(items []sessionInfo) (string, error) {
sort.Slice(items, func(i, j int) bool {
return items[i].Lang+items[i].Session+items[i].Dir <
items[j].Lang+items[j].Session+items[j].Dir
})
var root yaml.Node
if err := root.Encode(items); err != nil {
return "", err
}
for _, item := range root.Content {
item.Style = yaml.FlowStyle
}
b, err := yaml.Marshal(&root)
return string(b), err
}

func errResp(msg string) response {
return response{Error: msg}
}
Expand Down
83 changes: 49 additions & 34 deletions go/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ import (
"testing"
"time"

"github.com/Beforerr/repld/go/julia"
"github.com/Beforerr/repld/go/python"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)

func TestAcceptControl(t *testing.T) {
Expand Down Expand Up @@ -91,23 +90,40 @@ func TestHandleRequest_SessionsList(t *testing.T) {
state := newTestState()
// A julia dir session pinned to a project; a global labeled session; a dead
// python dir session. The key is lang-prefixed; --session labels are global.
jl := newSession(julia.Adapter{}, "s", []string{"--project=/env"}, nil)
jl.lang = "julia"
jl := newSession("julia", "s", []string{"--project=/env"}, nil)
jl.id = "kqzmkqzm"
named := newSession(julia.Adapter{}, "s", nil, nil)
named.lang = "julia"
py := newSession(python.Adapter{}, "s", nil, nil)
py.lang = "python"
named := newSession("julia", "s", nil, nil)
py := newSession("python", "s", nil, nil)
py.dead.Store(true)
state.manager.sessions["julia\x00/work\x00/env"] = jl
state.manager.sessions["~scratch"] = named
state.manager.sessions["python\x00/work\x00"] = py
state.manager.sessions[sessionKey{lang: "julia", route: "/work", disc: "/env"}] = jl
state.manager.sessions[sessionKey{label: "scratch"}] = named
state.manager.sessions[sessionKey{lang: "python", route: "/work"}] = py

resp := handleRequest(state, protocolRequest{Action: "sessions"})
require.Empty(t, resp.Error)
require.Equal(t, `kqzmkqzm [julia] dir /work project=/env args=--project=/env
[julia] session scratch project=@.
[python] dir /work status=dead`, resp.Output)
require.Equal(t, `- {id: kqzmkqzm, lang: julia, dir: /work, args: [--project=/env]}
- {lang: julia, session: scratch, args: [--project=@.]}
- {lang: python, dir: /work, status: dead}
`, resp.Output)
}

func TestHandleRequest_SessionsParseableYAML(t *testing.T) {
state := newTestState()
jl := newSession("julia", "s", []string{"--project=/env"}, nil)
jl.id = "kqzmkqzm"
named := newSession("julia", "s", nil, nil)
state.manager.sessions[sessionKey{lang: "julia", route: "/work", disc: "/env"}] = jl
state.manager.sessions[sessionKey{label: "scratch"}] = named

resp := handleRequest(state, protocolRequest{Action: "sessions"})
require.Empty(t, resp.Error)

var items []sessionInfo
require.NoError(t, yaml.Unmarshal([]byte(resp.Output), &items))
require.Len(t, items, 2)
require.Equal(t, "kqzmkqzm", items[0].ID)
require.Equal(t, "julia", items[0].Lang)
require.Equal(t, "scratch", items[1].Session)
}

func TestInterruptUnknownSession(t *testing.T) {
Expand All @@ -124,9 +140,8 @@ func TestCloseUnknownSession(t *testing.T) {

func TestCloseSession(t *testing.T) {
state := newTestState()
sess := newSession(julia.Adapter{}, "s", nil, nil)
sess.lang = "julia"
state.manager.sessions["~scratch"] = sess
sess := newSession("julia", "s", nil, nil)
state.manager.sessions[sessionKey{label: "scratch"}] = sess

resp := handleRequest(state, protocolRequest{Action: "close", Session: "scratch", Cwd: t.TempDir()})
require.Empty(t, resp.Error)
Expand All @@ -137,10 +152,9 @@ func TestCloseSession(t *testing.T) {

func TestCloseSessionByID(t *testing.T) {
state := newTestState()
sess := newSession(julia.Adapter{}, "s", nil, nil)
sess.lang = "julia"
sess := newSession("julia", "s", nil, nil)
sess.id = "kqzm"
state.manager.sessions["julia\x00/work\x00@."] = sess
state.manager.sessions[sessionKey{lang: "julia", route: "/work", disc: "@."}] = sess

// Unique prefix resolves regardless of cwd.
resp := handleRequest(state, protocolRequest{Action: "close", ID: "kq", Cwd: t.TempDir()})
Expand All @@ -155,40 +169,41 @@ func TestCloseSessionByID(t *testing.T) {
func TestKeyForIDPrefix(t *testing.T) {
m := newSessionManager()
defer m.shutdown()
a := newSession(julia.Adapter{}, "s", nil, nil)
a := newSession("julia", "s", nil, nil)
a.id = "kqzm"
b := newSession(julia.Adapter{}, "s", nil, nil)
b := newSession("julia", "s", nil, nil)
b.id = "kxyz"
m.sessions["julia\x00/a\x00@."] = a
m.sessions["julia\x00/b\x00@."] = b
m.sessions[sessionKey{lang: "julia", route: "/a", disc: "@."}] = a
m.sessions[sessionKey{lang: "julia", route: "/b", disc: "@."}] = b

key, err := m.keyForID("kq")
key, ok, err := m.keyForID("kq")
require.NoError(t, err)
require.Equal(t, "julia\x00/a\x00@.", key)
require.True(t, ok)
require.Equal(t, sessionKey{lang: "julia", route: "/a", disc: "@."}, key)

_, err = m.keyForID("k") // shared prefix → ambiguous
_, _, err = m.keyForID("k") // shared prefix → ambiguous
require.Error(t, err)
require.Contains(t, err.Error(), "ambiguous")

key, err = m.keyForID("zz") // no match
_, ok, err = m.keyForID("zz") // no match
require.NoError(t, err)
require.Equal(t, "", key)
require.False(t, ok)
}

func TestSessionManagerKey(t *testing.T) {
m := newSessionManager()
defer m.shutdown()

// key = lang + cwd + discriminant (project); label keys are global.
require.Equal(t, "julia\x00/w\x00@.", m.key("julia", "", "/w", "@."))
require.Equal(t, "python\x00/w\x00", m.key("python", "", "/w", ""))
require.Equal(t, sessionKey{lang: "julia", route: "/w", disc: "@."}, m.key("julia", "", "/w", "@."))
require.Equal(t, sessionKey{lang: "python", route: "/w"}, m.key("python", "", "/w", ""))
// same dir, distinct by language or by project → distinct sessions.
require.NotEqual(t, m.key("julia", "", "/w", "@."), m.key("python", "", "/w", ""))
require.NotEqual(t, m.key("julia", "", "/w", "@."), m.key("julia", "", "/w", "/env"))
absProject := filepath.Join(t.TempDir(), "env")
require.Equal(t, m.key("julia", "", "/a", absProject), m.key("julia", "", "/b", absProject))
require.NotEqual(t, m.key("julia", "", "/a", "@."), m.key("julia", "", "/b", "@."))
// a --session label is global: same key regardless of language/project.
require.Equal(t, "~scratch", m.key("julia", "scratch", "/w", "@."))
require.Equal(t, "~scratch", m.key("python", "scratch", "/other", ""))
}
require.Equal(t, sessionKey{label: "scratch"}, m.key("julia", "scratch", "/w", "@."))
require.Equal(t, sessionKey{label: "scratch"}, m.key("python", "scratch", "/other", ""))
}
3 changes: 1 addition & 2 deletions go/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import (
"testing"
"time"

"github.com/Beforerr/repld/go/julia"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -323,7 +322,7 @@ func TestCLINoInterpreterDoesNotCreateMissingNamedSession(t *testing.T) {
}

func TestExecuteRawWithoutControlDoesNotPanic(t *testing.T) {
sess := newSession(julia.Adapter{}, "SENTINEL", nil, nil)
sess := newSession("julia", "SENTINEL", nil, nil)
sess.stdin = &nopWriteCloser{}
sess.stdout = bufio.NewReader(strings.NewReader("SENTINEL\n"))
sess.stderr = bufio.NewReader(strings.NewReader("SENTINEL\n"))
Expand Down
12 changes: 6 additions & 6 deletions go/julia_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,12 +235,12 @@ func TestInterruptBusyAndUnblocks(t *testing.T) {
var sessOut string
for time.Now().Before(deadline) {
sessOut = repldOK(t, socketPath, cwd, "sessions").stdout
if strings.Contains(sessOut, "busy=") {
if strings.Contains(sessOut, "status: busy") {
break
}
time.Sleep(50 * time.Millisecond)
}
require.Contains(t, sessOut, "busy=", "sessions listing should show busy= while a call is in flight")
require.Contains(t, sessOut, "status: busy", "sessions listing should show status: busy while a call is in flight")

irq := repldOK(t, socketPath, cwd, "interrupt", "julia")
require.Contains(t, irq.stdout, "interrupted", "sleep must survive the interrupt, got: %q", irq.stdout)
Expand Down Expand Up @@ -289,7 +289,7 @@ func TestClientDisconnectInterruptsEval(t *testing.T) {
// Wait until the session reports busy.
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if strings.Contains(sendRequest(t, socketPath, protocolRequest{Action: "sessions"}).Output, "busy=") {
if strings.Contains(sendRequest(t, socketPath, protocolRequest{Action: "sessions"}).Output, "status: busy") {
break
}
time.Sleep(50 * time.Millisecond)
Expand All @@ -301,7 +301,7 @@ func TestClientDisconnectInterruptsEval(t *testing.T) {
// The session must stop being busy promptly. Without disconnect handling,
// the orphaned sleep(60) would keep the session busy for ~60s.
require.Eventually(t, func() bool {
return !strings.Contains(sendRequest(t, socketPath, protocolRequest{Action: "sessions"}).Output, "busy=")
return !strings.Contains(sendRequest(t, socketPath, protocolRequest{Action: "sessions"}).Output, "status: busy")
}, 10*time.Second, 100*time.Millisecond, "client disconnect should have interrupted the orphaned eval")

// Session must be usable again AND have survived with state intact
Expand Down Expand Up @@ -381,7 +381,7 @@ func TestJuliaWorldAgeDisplay(t *testing.T) {
func TestKillRunsAtexitHooks(t *testing.T) {
cwd, err := os.Getwd()
require.NoError(t, err)
sess := newSession(julia.Adapter{}, newSentinel(), nil, nil)
sess := newSession("julia", newSentinel(), nil, nil)
require.NoError(t, sess.start("", cwd))

marker := filepath.Join(t.TempDir(), "atexit.marker")
Expand Down Expand Up @@ -431,7 +431,7 @@ func TestQueuedDisconnectDoesNotInterruptRunningEval(t *testing.T) {
}))

require.Eventually(t, func() bool {
return strings.Contains(sendRequest(t, socketPath, protocolRequest{Action: "sessions"}).Output, "busy=")
return strings.Contains(sendRequest(t, socketPath, protocolRequest{Action: "sessions"}).Output, "status: busy")
}, 5*time.Second, 50*time.Millisecond, "conn1 eval should be running")

// conn2: same session, queues behind conn1, then disconnects abruptly.
Expand Down
2 changes: 1 addition & 1 deletion go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ repld flags:

Commands (trace/interrupt/close locate a session by [exe], --session, or the
short id shown by 'sessions'; an id prefix works when unambiguous):
sessions List active sessions (all languages)
sessions List active sessions (one per line)
trace Print the last saved error traceback for the session
interrupt Interrupt the in-flight eval (SIGKILL after 3s if unresponsive)
close Kill the session's interpreter and discard its state
Expand Down
Loading
Loading