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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## [Unreleased]

- Julia `--project` is now startup-only, like other interpreter flags; later calls reuse cwd session without repeating it.

## [1.0.0]

Reworked into a single language-agnostic binary, `repld`, that selects interpreter from a leading exe positional.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ repld sessions # list active sessions
repld stop # shutdown daemon
```

`<exe>` is `julia`, `python3`, `R`, `wolframscript`, an absolute/relative interpreter path, etc. repld's own flags (`--session`/`--lang`/`--trace`/`--fresh`) go before `<exe>`; after it, every flag forwards verbatim to the interpreter (e.g. Julia's `--project=DIR`, `+1.11` for juliaup) except native eval/print flags (`-e`/`-c`/`-E`). Each call routes to a persistent session keyed by language + project + `--session`/cwd.
`<exe>` is `julia`, `python3`, `R`, `wolframscript`, an absolute/relative interpreter path, etc. repld's own flags (`--session`/`--lang`/`--trace`/`--fresh`) go before `<exe>`; after it, every flag forwards verbatim to the interpreter (e.g. Julia's `--project=DIR`, `+1.11` for juliaup) except native eval/print flags (`-e`/`-c`/`-E`). Each call routes to a persistent session keyed by language + interpreter + `--session`/cwd. Forwarded interpreter flags configure a session only when it starts.

A session auto-closes once the agent process that created it exits.
`repld free <id | --session=LABEL>` pins a session against auto-closing; `repld --owner-pid 0 <exe> ...` opts out at creation.
Expand Down
6 changes: 2 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,8 @@ re-execs itself as `daemon` if none is running. A relative exe path is abs-ified
client-side (`absExe`); a bare name is looked up in PATH on the daemon.

**Daemon mode** (`daemon` subcommand): one language-agnostic server
(`go/daemon.go`). The per-session `Adapter` is chosen from each request's `lang` (`SessionManager.getOrCreate`). Sessions
are keyed by `lang` + (`--session` label / project / cwd); the lang prefix keeps
same-dir sessions of different languages distinct. Runs until `stop` (or an
optional `daemon --idle-timeout SECS`; default 0 = never).
(`go/daemon.go`). The per-session `Adapter` is chosen from each request's `lang` (`SessionManager.getOrCreate`).
Runs until `stop` (or optional `daemon --idle-timeout SECS`; default 0 = never).

**Owner lease**: the daemon closes a session after its owning process exits.
Ownerless sessions persist until closed; `repld free` removes an existing lease.
Expand Down
3 changes: 0 additions & 3 deletions go/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@ type Adapter interface {

LaunchArgs(forwarded []string) []string

// SessionKey separates environments: project, interpreter, or runtime identity.
SessionKey(exe string, forwarded []string) string

// BootstrapStmt loads the embedded runtime.
BootstrapStmt() string
WrapEval(hexCode string, printResult bool) string
Expand Down
26 changes: 8 additions & 18 deletions go/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func handleRequest(state *daemonState, req protocolRequest) response {

switch req.Action {
case "trace":
key, kerr := state.manager.targetKey(req.ID, req.Lang, req.Session, req.Cwd, discFor(req))
key, kerr := state.manager.targetKey(req.ID, req.Lang, req.Session, req.Cwd, req.Exe)
if kerr != nil {
return errResp(kerr.Error())
}
Expand All @@ -53,7 +53,7 @@ func handleRequest(state *daemonState, req protocolRequest) response {
return response{Output: out}

case "interrupt":
key, kerr := state.manager.targetKey(req.ID, req.Lang, req.Session, req.Cwd, discFor(req))
key, kerr := state.manager.targetKey(req.ID, req.Lang, req.Session, req.Cwd, req.Exe)
if kerr != nil {
return errResp(kerr.Error())
}
Expand All @@ -64,7 +64,7 @@ func handleRequest(state *daemonState, req protocolRequest) response {
return response{Output: msg}

case "close":
key, kerr := state.manager.targetKey(req.ID, req.Lang, req.Session, req.Cwd, discFor(req))
key, kerr := state.manager.targetKey(req.ID, req.Lang, req.Session, req.Cwd, req.Exe)
if kerr != nil {
return errResp(kerr.Error())
}
Expand All @@ -75,7 +75,7 @@ func handleRequest(state *daemonState, req protocolRequest) response {
return response{Output: msg}

case "free":
key, kerr := state.manager.targetKey(req.ID, req.Lang, req.Session, req.Cwd, discFor(req))
key, kerr := state.manager.targetKey(req.ID, req.Lang, req.Session, req.Cwd, req.Exe)
if kerr != nil {
return errResp(kerr.Error())
}
Expand Down Expand Up @@ -119,15 +119,6 @@ func errResp(msg string) response {
return response{Error: msg}
}

// discFor is the session's environment discriminant, or "" when the language is
// unknown (label-only reuse, where the discriminant isn't part of the key).
func discFor(req protocolRequest) string {
if a := adapterFor(req.Lang); a != nil {
return a.SessionKey(req.Exe, req.Args)
}
return ""
}

func normalizedTraceLevel(level string) string {
switch strings.ToLower(level) {
case "short", "compact":
Expand Down Expand Up @@ -199,15 +190,14 @@ func handleStreamingEval(state *daemonState, req protocolRequest, conn net.Conn)
enc := json.NewEncoder(conn)
emit := func(f streamFrame) { _ = enc.Encode(f) }

disc := discFor(req)
if req.RequireExisting {
if req.Fresh || !state.manager.hasLiveSession(req.Lang, req.Session, req.Cwd, disc) {
if req.Fresh || !state.manager.hasLiveSession(req.Lang, req.Session, req.Cwd, req.Exe) {
emit(streamFrame{Done: true, Error: "no existing session for label; pass an interpreter or --lang to create one"})
return
}
}
if req.Fresh {
state.manager.restart(req.Lang, req.Session, req.Cwd, disc)
state.manager.restart(req.Lang, req.Session, req.Cwd, req.Exe)
}
sess, err := state.manager.getOrCreate(req.Lang, req.Cwd, req.Session, req.Exe, req.Args, req.OwnerPID, req.OwnerStart)
if err != nil {
Expand Down Expand Up @@ -251,10 +241,10 @@ func handleStreamingEval(state *daemonState, req protocolRequest, conn net.Conn)
err = sess.execute(ctx, code, printResult, onChunk)
if err != nil {
if !sess.isAlive() {
state.manager.remove(req.Lang, req.Session, req.Cwd, disc)
state.manager.remove(req.Lang, req.Session, req.Cwd, req.Exe)
}
if evalErr, ok := err.(*evalError); ok {
state.manager.recordError(req.Lang, req.Session, req.Cwd, disc, evalErr)
state.manager.recordError(req.Lang, req.Session, req.Cwd, req.Exe, evalErr)
emit(streamFrame{Done: true, Error: formatError(evalErr, req.TraceLevel, sess.id)})
return
}
Expand Down
39 changes: 18 additions & 21 deletions go/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ package main

import (
"net"
"path/filepath"
"testing"
"time"

"github.com/Beforerr/repld/go/python"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
Expand Down Expand Up @@ -95,21 +95,21 @@ func TestHandleRequest_Stop(t *testing.T) {

func TestHandleRequest_SessionsList(t *testing.T) {
state := newTestState()
// A julia dir session pinned to a project; a global labeled session; a dead
// A Julia cwd session with startup args; a global labeled session; a dead
// python dir session. The key is lang-prefixed; --session labels are global.
jl := newSession("julia", "s", []string{"--project=/env"}, nil)
jl.id = "kqzmkqzm"
named := newSession("julia", "s", nil, nil)
py := newSession("python", "s", nil, nil)
py.dead.Store(true)
state.manager.sessions[sessionKey{lang: "julia", route: "/work", disc: "/env"}] = jl
state.manager.sessions[sessionKey{lang: "julia", cwd: "/work", exe: "julia"}] = jl
state.manager.sessions[sessionKey{label: "scratch"}] = named
state.manager.sessions[sessionKey{lang: "python", route: "/work"}] = py
state.manager.sessions[sessionKey{lang: "python", cwd: "/work", exe: "python3"}] = py

resp := handleRequest(state, protocolRequest{Action: "sessions"})
require.Empty(t, resp.Error)
require.Equal(t, `- {id: kqzmkqzm, lang: julia, dir: /work, args: [--project=/env]}
- {lang: julia, session: scratch, args: [--project=@.]}
- {lang: julia, session: scratch}
- {lang: python, dir: /work, status: dead}
`, resp.Output)
}
Expand All @@ -119,7 +119,7 @@ func TestHandleRequest_SessionsParseableYAML(t *testing.T) {
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{lang: "julia", cwd: "/work", exe: "julia"}] = jl
state.manager.sessions[sessionKey{label: "scratch"}] = named

resp := handleRequest(state, protocolRequest{Action: "sessions"})
Expand Down Expand Up @@ -161,7 +161,7 @@ func TestCloseSessionByID(t *testing.T) {
state := newTestState()
sess := newSession("julia", "s", nil, nil)
sess.id = "kqzm"
state.manager.sessions[sessionKey{lang: "julia", route: "/work", disc: "@."}] = sess
state.manager.sessions[sessionKey{lang: "julia", cwd: "/work", exe: "julia"}] = sess

// Unique prefix resolves regardless of cwd.
resp := handleRequest(state, protocolRequest{Action: "close", ID: "kq", Cwd: t.TempDir()})
Expand All @@ -180,13 +180,13 @@ func TestKeyForIDPrefix(t *testing.T) {
a.id = "kqzm"
b := newSession("julia", "s", nil, nil)
b.id = "kxyz"
m.sessions[sessionKey{lang: "julia", route: "/a", disc: "@."}] = a
m.sessions[sessionKey{lang: "julia", route: "/b", disc: "@."}] = b
m.sessions[sessionKey{lang: "julia", cwd: "/a", exe: "julia"}] = a
m.sessions[sessionKey{lang: "julia", cwd: "/b", exe: "julia"}] = b

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

_, _, err = m.keyForID("k") // shared prefix → ambiguous
require.Error(t, err)
Expand All @@ -201,16 +201,13 @@ func TestSessionManagerKey(t *testing.T) {
m := newSessionManager()
defer m.shutdown()

// key = lang + cwd + discriminant (project); label keys are global.
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, sessionKey{label: "scratch"}, m.key("julia", "scratch", "/w", "@."))
// key = language + cwd + interpreter; labels are global.
require.Equal(t, sessionKey{lang: "julia", cwd: "/w", exe: "julia"}, m.key("julia", "", "/w", ""))
require.Equal(t, sessionKey{lang: "python", cwd: "/w", exe: python.Adapter{}.DefaultExe()}, m.key("python", "", "/w", ""))
require.NotEqual(t, m.key("julia", "", "/w", "julia"), m.key("python", "", "/w", "python3"))
require.NotEqual(t, m.key("julia", "", "/w", "julia"), m.key("julia", "", "/w", "/opt/julia"))
require.NotEqual(t, m.key("julia", "", "/a", "julia"), m.key("julia", "", "/b", "julia"))
// a --session label is global: same key regardless of language/interpreter.
require.Equal(t, sessionKey{label: "scratch"}, m.key("julia", "scratch", "/w", "julia"))
require.Equal(t, sessionKey{label: "scratch"}, m.key("python", "scratch", "/other", ""))
}
8 changes: 0 additions & 8 deletions go/julia/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,6 @@ func hasProject(args []string) bool {
return projectOf(args) != ""
}

// each project is its own session.
func (Adapter) SessionKey(_ string, forwarded []string) string {
if p := projectOf(forwarded); p != "" {
return p
}
return "@."
}

func projectOf(args []string) string {
for i, a := range args {
if v, ok := strings.CutPrefix(a, "--project="); ok {
Expand Down
3 changes: 1 addition & 2 deletions go/julia_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -373,8 +373,7 @@ func TestJuliaWorldAgeDisplay(t *testing.T) {
// Fresh pkg content forces a cache-miss precompile during this eval (the world bump).
res := repldOK(t, socketPath, pkgDir, "julia", "--project="+pkgDir, "-E", "using WAEnum; WAEnum.mkval()")
require.Equal(t, "Dark::Shade = 0\n", res.stdout)
require.NotContains(t, res.stderr, "world age")
require.NotContains(t, res.stderr, "too new")
require.Equal(t, "Dark::Shade = 0\n", repldOK(t, socketPath, pkgDir, "julia", "-E", "WAEnum.mkval()").stdout)
}

// TestKillRunsAtexitHooks: a graceful shutdown must let Julia run its atexit
Expand Down
Loading
Loading