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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ repld --session LABEL <exe> ... # named session, reusable across dirs
repld --fresh <exe> ... # restart targeted session first
repld --trace LEVEL <exe> ... # error traceback level: short | smart | full

repld trace [--session=L | exe] # last saved traceback
repld interrupt [--session=L | exe] # interrupt in-flight eval
repld close [--session=L | exe]
repld trace [id | exe | --session=L] # last saved traceback
repld interrupt [id | exe | --session=L] # interrupt in-flight eval
repld close [id | exe | --session=L]
repld sessions # list active sessions
repld stop # shutdown daemon
```
Expand Down
24 changes: 19 additions & 5 deletions go/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ func handleRequest(state *daemonState, req protocolRequest) response {

switch req.Action {
case "trace":
err := state.manager.lastError(req.Lang, req.Session, req.Cwd, discFor(req))
key, kerr := state.manager.targetKey(req.ID, req.Lang, req.Session, req.Cwd, discFor(req))
if kerr != nil {
return errResp(kerr.Error())
}
err := state.manager.lastError(key)
if err == nil {
return errResp("No saved traceback for this session.")
}
Expand All @@ -43,15 +47,17 @@ func handleRequest(state *daemonState, req protocolRequest) response {
sort.Slice(sessions, func(i, j int) bool {
return sessions[i].lang+sessions[i].label < sessions[j].lang+sessions[j].label
})
lines := []string{"Active sessions:"}
lines := []string{}
for _, s := range sessions {
label := s.label
if strings.HasPrefix(label, "~") {
label = "session " + strings.TrimPrefix(label, "~")
} else {
label = "dir " + label
}
line := fmt.Sprintf(" [%s] %s", s.lang, label)
line := ""
line += s.id + " "
line += fmt.Sprintf("[%s] %s", s.lang, label)
if s.project != "" {
line += " project=" + s.project
}
Expand All @@ -71,14 +77,22 @@ func handleRequest(state *daemonState, req protocolRequest) response {
return response{Output: strings.Join(lines, "\n")}

case "interrupt":
msg, err := state.manager.interrupt(req.Lang, req.Session, req.Cwd, discFor(req), 3.0)
key, kerr := state.manager.targetKey(req.ID, req.Lang, req.Session, req.Cwd, discFor(req))
if kerr != nil {
return errResp(kerr.Error())
}
msg, err := state.manager.interrupt(key, 3.0)
if err != nil {
return errResp(err.Error())
}
return response{Output: msg}

case "close":
msg, err := state.manager.close(req.Lang, req.Session, req.Cwd, discFor(req))
key, kerr := state.manager.targetKey(req.ID, req.Lang, req.Session, req.Cwd, discFor(req))
if kerr != nil {
return errResp(kerr.Error())
}
msg, err := state.manager.close(key)
if err != nil {
return errResp(err.Error())
}
Expand Down
48 changes: 44 additions & 4 deletions go/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ func TestHandleRequest_SessionsList(t *testing.T) {
// 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.id = "kqzmkqzm"
named := newSession(julia.Adapter{}, "s", nil, nil)
named.lang = "julia"
py := newSession(python.Adapter{}, "s", nil, nil)
Expand All @@ -104,10 +105,9 @@ func TestHandleRequest_SessionsList(t *testing.T) {

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

func TestInterruptUnknownSession(t *testing.T) {
Expand Down Expand Up @@ -135,6 +135,46 @@ func TestCloseSession(t *testing.T) {
require.False(t, sess.isAlive())
}

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

// Unique prefix resolves regardless of cwd.
resp := handleRequest(state, protocolRequest{Action: "close", ID: "kq", Cwd: t.TempDir()})
require.Empty(t, resp.Error)
require.Contains(t, resp.Output, "closed")
require.Empty(t, state.manager.sessions)

resp = handleRequest(state, protocolRequest{Action: "close", ID: "kq", Cwd: t.TempDir()})
require.Contains(t, resp.Error, `no session with id "kq"`)
}

func TestKeyForIDPrefix(t *testing.T) {
m := newSessionManager()
defer m.shutdown()
a := newSession(julia.Adapter{}, "s", nil, nil)
a.id = "kqzm"
b := newSession(julia.Adapter{}, "s", nil, nil)
b.id = "kxyz"
m.sessions["julia\x00/a\x00@."] = a
m.sessions["julia\x00/b\x00@."] = b

key, err := m.keyForID("kq")
require.NoError(t, err)
require.Equal(t, "julia\x00/a\x00@.", key)

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

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

func TestSessionManagerKey(t *testing.T) {
m := newSessionManager()
defer m.shutdown()
Expand Down
62 changes: 40 additions & 22 deletions go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ type response struct {

type protocolRequest struct {
Action string `json:"action"`
ID string `json:"id,omitempty"` // short session id for targeting commands
Lang string `json:"lang,omitempty"`
Code string `json:"code,omitempty"`
Cwd string `json:"cwd,omitempty"`
Expand Down Expand Up @@ -180,38 +181,40 @@ func cmdEvalFile(socketPath, lang, file, exe, session string, fresh bool, traceL
}, true)
}

func cmdInterrupt(socketPath, lang, exe, session string, fwd []string) {
func cmdInterrupt(socketPath string, tg subTarget, exe string) {
run(socketPath, protocolRequest{
Action: "interrupt",
Lang: lang,
ID: tg.id,
Lang: tg.lang,
Exe: exe,
Cwd: mustGetwd(),
Session: session,
Args: fwd,
Session: tg.session,
Args: tg.fwd,
}, false)
}

func cmdClose(socketPath, lang, exe, session string, fwd []string) {
func cmdClose(socketPath string, tg subTarget, exe string) {
run(socketPath, protocolRequest{
Action: "close",
Lang: lang,
ID: tg.id,
Lang: tg.lang,
Exe: exe,
Cwd: mustGetwd(),
Session: session,
Args: fwd,
Session: tg.session,
Args: tg.fwd,
}, false)
}

func cmdTrace(socketPath, lang, exe, session, traceLevel string, fwd []string) {
traceLevel = cmp.Or(traceLevel, "full")
func cmdTrace(socketPath string, tg subTarget, exe string) {
run(socketPath, protocolRequest{
Action: "trace",
Lang: lang,
ID: tg.id,
Lang: tg.lang,
Exe: exe,
Cwd: mustGetwd(),
Session: session,
TraceLevel: traceLevel,
Args: fwd,
Session: tg.session,
TraceLevel: cmp.Or(tg.level, "full"),
Args: tg.fwd,
}, false)
}

Expand All @@ -220,7 +223,7 @@ func usage() {

Usage:
repld <exe> [interp-args] (--<eval> CODE | [--] <file> [script-args] | -)
repld <command> [<exe>] [--session L] # target a session: trace, interrupt, close
repld <command> [<exe>|<id>] [--session L] # target a session: trace, interrupt, close
repld <command> # daemon-wide: sessions, stop, daemon

<exe> is the interpreter to run (julia, python3, R, wolframscript, .venv/bin/python, /path/...).
Expand All @@ -234,7 +237,8 @@ repld flags:
--fresh Clear the targeted session before evaluating
--trace LEVEL Error traceback level: short, smart, or full (eval default: smart)

Commands (trace/interrupt/close take an optional [exe] and/or --session to locate the session):
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)
trace Print the last saved error traceback for the session
interrupt Interrupt the in-flight eval (SIGKILL after 3s if unresponsive)
Expand Down Expand Up @@ -385,8 +389,20 @@ func resolveExeStr(exe, lang string) string {
}

type subTarget struct {
exe, lang, session, level string
fwd []string
exe, id, lang, session, level string
fwd []string
}

func looksLikeID(s string) bool {
if len(s) < 2 || len(s) > 2*idLen {
return false
}
for _, c := range s {
if !strings.ContainsRune(idAlphabet, c) {
return false
}
}
return true
}

func parseTarget(p parsed) subTarget {
Expand All @@ -395,7 +411,9 @@ func parseTarget(p parsed) subTarget {
for i := 0; i < len(a); {
s := a[i]
if !strings.HasPrefix(s, "-") {
if tg.exe == "" && s != "" {
if tg.id == "" && tg.exe == "" && looksLikeID(s) {
tg.id = s
} else if tg.exe == "" && s != "" {
tg.exe = s
} else {
tg.fwd = append(tg.fwd, s)
Expand Down Expand Up @@ -476,13 +494,13 @@ func dispatchSubcommand(p parsed) {
run(p.socket, protocolRequest{Action: "stop"}, false)
case "trace":
tg := parseTarget(p)
cmdTrace(p.socket, tg.lang, resolveExeStr(tg.exe, tg.lang), tg.session, tg.level, tg.fwd)
cmdTrace(p.socket, tg, resolveExeStr(tg.exe, tg.lang))
case "interrupt":
tg := parseTarget(p)
cmdInterrupt(p.socket, tg.lang, resolveExeStr(tg.exe, tg.lang), tg.session, tg.fwd)
cmdInterrupt(p.socket, tg, resolveExeStr(tg.exe, tg.lang))
case "close":
tg := parseTarget(p)
cmdClose(p.socket, tg.lang, resolveExeStr(tg.exe, tg.lang), tg.session, tg.fwd)
cmdClose(p.socket, tg, resolveExeStr(tg.exe, tg.lang))
case "daemon":
fs := flag.NewFlagSet("daemon", flag.ExitOnError)
idleTimeout := fs.Float64("idle-timeout", 0, "Shut down after this many idle seconds (0 = never)")
Expand Down
67 changes: 61 additions & 6 deletions go/manager.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"crypto/rand"
"fmt"
"os"
"path/filepath"
Expand Down Expand Up @@ -46,6 +47,60 @@ func (m *SessionManager) key(lang, session, cwd, disc string) string {
return lang + "\x00" + route + "\x00" + disc
}

// from k-z : IDs never look like exe names, paths
const idAlphabet = "klmnopqrstuvwxyz"
const idLen = 8

func (m *SessionManager) uniqueIDLocked() string {
for {
b := make([]byte, idLen)
rand.Read(b)
id := make([]byte, idLen)
for i, c := range b {
id[i] = idAlphabet[int(c)%len(idAlphabet)]
}
inUse := false
for _, sess := range m.sessions {
if sess.id == string(id) {
inUse = true
break
}
}
if !inUse {
return string(id)
}
}
}

func (m *SessionManager) keyForID(prefix string) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
match := ""
for key, sess := range m.sessions {
if sess.id != "" && strings.HasPrefix(sess.id, prefix) {
if match != "" {
return "", fmt.Errorf("session id %q is ambiguous", prefix)
}
match = key
}
}
return match, nil
}

func (m *SessionManager) targetKey(id, lang, session, cwd, disc string) (string, error) {
if id != "" {
key, err := m.keyForID(id)
if err != nil {
return "", err
}
if key == "" {
return "", fmt.Errorf("no session with id %q", id)
}
return key, nil
}
return m.key(lang, session, cwd, disc), nil
}

// keyLabel is the human label for a key: a "~label" session label, else the cwd
// (the lang prefix and project discriminant are shown separately).
func keyLabel(key string) string {
Expand Down Expand Up @@ -105,6 +160,7 @@ func (m *SessionManager) getOrCreate(lang, cwd, session, exe string, fwd []strin
}

m.mu.Lock()
sess.id = m.uniqueIDLocked()
m.sessions[key] = sess
m.mu.Unlock()
return sess, nil
Expand Down Expand Up @@ -134,8 +190,7 @@ func (m *SessionManager) restart(lang, session, cwd, disc string) {
}
}

func (m *SessionManager) close(lang, session, cwd, disc string) (string, error) {
key := m.key(lang, session, cwd, disc)
func (m *SessionManager) close(key string) (string, error) {
m.mu.Lock()
sess := m.sessions[key]
delete(m.sessions, key)
Expand Down Expand Up @@ -163,14 +218,14 @@ func (m *SessionManager) recordError(lang, session, cwd, disc string, err *evalE
m.mu.Unlock()
}

func (m *SessionManager) lastError(lang, session, cwd, disc string) *evalError {
key := m.key(lang, session, cwd, disc)
func (m *SessionManager) lastError(key string) *evalError {
m.mu.Lock()
defer m.mu.Unlock()
return m.lastErrors[key]
}

type sessionInfo struct {
id string
lang string
label string // session label or cwd
project string // environment discriminant (Julia --project); "" if none
Expand All @@ -187,6 +242,7 @@ func (m *SessionManager) list() []sessionInfo {
result := make([]sessionInfo, 0, len(m.sessions))
for key, sess := range m.sessions {
info := sessionInfo{
id: sess.id,
lang: sess.lang,
label: keyLabel(key),
alive: sess.isAlive(),
Expand All @@ -206,8 +262,7 @@ func (m *SessionManager) list() []sessionInfo {
return result
}

func (m *SessionManager) interrupt(lang, session, cwd, disc string, graceSecs float64) (string, error) {
key := m.key(lang, session, cwd, disc)
func (m *SessionManager) interrupt(key string, graceSecs float64) (string, error) {
m.mu.Lock()
sess := m.sessions[key]
m.mu.Unlock()
Expand Down
Loading
Loading