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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ 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.

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.

## Architecture

One Go binary is both the CLI client and the background daemon (auto-started on first use, stopped with `repld stop`). For design details, wire protocol, and key-file map, see [docs/architecture.md](docs/architecture.md).
Expand Down
6 changes: 5 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ 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).

**Owner lease**: the daemon closes a session after its owning process exits.
Ownerless sessions persist until closed; `repld free` removes an existing lease.

**Session** (`go/session.go`): wraps one interpreter subprocess. The adapter
supplies the launch argv, the embedded runtime source + its load statement, the
per-eval wrapper, and the sentinel statement. Code is hex-encoded and eval'd via
Expand Down Expand Up @@ -97,7 +100,8 @@ session is a no-op.
- `go/adapter.go` — `Adapter` interface: the language-execution seam (launch argv, runtime, eval wrap, sentinel, `EvalFileStmt` for in-session file eval)
- `go/daemon.go` — request dispatch, idle watchdog, client-disconnect→interrupt watcher
- `go/session.go` — `Session`: subprocess lifecycle, `executeRaw` (sentinel + control protocol), `execute`, interrupt, graceful `kill`
- `go/manager.go` — `SessionManager`: session map, routing/keying, per-session logs
- `go/manager.go` — `SessionManager`: session map, routing/keying, per-session logs, owner-lease reaper
- `go/owner.go` — owner-pid resolution (client) + liveness/pid-reuse check (daemon)
- `go/julia/`, `go/python/`, `go/r/`, `go/wolfram/` — per-language `Adapter` + embedded runtime: dials the control socket, evals code and writes the control frame, handles errors

## Adding a language
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.25.0
require (
github.com/stretchr/testify v1.11.1
golang.org/x/sync v0.20.0
golang.org/x/sys v0.47.0
gopkg.in/yaml.v3 v3.0.1
)

Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
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=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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=
Expand Down
15 changes: 14 additions & 1 deletion go/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,17 @@ 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))
if kerr != nil {
return errResp(kerr.Error())
}
msg, err := state.manager.free(key)
if err != nil {
return errResp(err.Error())
}
return response{Output: msg}

case "stop":
state.stopOnce.Do(func() { close(state.stopCh) })
return response{Output: "Daemon stopping."}
Expand Down Expand Up @@ -198,7 +209,7 @@ func handleStreamingEval(state *daemonState, req protocolRequest, conn net.Conn)
if req.Fresh {
state.manager.restart(req.Lang, req.Session, req.Cwd, disc)
}
sess, err := state.manager.getOrCreate(req.Lang, req.Cwd, req.Session, req.Exe, req.Args)
sess, err := state.manager.getOrCreate(req.Lang, req.Cwd, req.Session, req.Exe, req.Args, req.OwnerPID, req.OwnerStart)
if err != nil {
emit(streamFrame{Done: true, Error: err.Error()})
return
Expand Down Expand Up @@ -311,6 +322,8 @@ func serveDaemon(socketPath string, idleTimeout time.Duration) error {
}
state.lastRequest.Store(time.Now().UnixNano())

go state.manager.reapLoop(state.stopCh)

// Idle watchdog: closes listener when idle or stop requested
go func() {
defer ln.Close()
Expand Down
1 change: 0 additions & 1 deletion go/julia_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,6 @@ func TestKillRunsAtexitHooks(t *testing.T) {
require.FileExists(t, marker, "graceful shutdown should run atexit hooks, not SIGKILL")
}


func TestJuliaFileEvalArgsAndState(t *testing.T) {
if _, err := exec.LookPath(julia.Adapter{}.DefaultExe()); err != nil {
t.Skipf("%s not installed", julia.Adapter{}.DefaultExe())
Expand Down
40 changes: 33 additions & 7 deletions go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ type protocolRequest struct {
RequireExisting bool `json:"require_existing,omitempty"`
File string `json:"file,omitempty"`
FileArgs []string `json:"file_args,omitempty"`
OwnerPID int `json:"owner_pid,omitempty"` // session owner; its exit auto-closes the session
OwnerStart int64 `json:"owner_start,omitempty"` // owner start time, guards against pid reuse
}

type streamFrame struct {
Expand Down Expand Up @@ -134,7 +136,7 @@ func mustGetwd() string {
return cwd
}

func cmdEval(socketPath, lang, code, exe, session string, printResult, fresh bool, traceLevel string, args []string) {
func cmdEval(socketPath, lang, code, exe, session string, printResult, fresh bool, traceLevel string, args []string, ownerPID int, ownerStart int64) {
if code == "-" {
b, err := io.ReadAll(os.Stdin)
if err != nil {
Expand All @@ -155,13 +157,15 @@ func cmdEval(socketPath, lang, code, exe, session string, printResult, fresh boo
PrintResult: printResult,
Fresh: fresh,
RequireExisting: lang == "" && exe == "" && session != "",
OwnerPID: ownerPID,
OwnerStart: ownerStart,
}
run(socketPath, req, true)
}

// cmdEvalFile sends an in-session file eval: path abs-ified but not read here —
// the interpreter reads it at eval time, so edits between calls take effect.
func cmdEvalFile(socketPath, lang, file, exe, session string, fresh bool, traceLevel string, fileArgs, fwd []string) {
func cmdEvalFile(socketPath, lang, file, exe, session string, fresh bool, traceLevel string, fileArgs, fwd []string, ownerPID int, ownerStart int64) {
abs, err := filepath.Abs(file)
if err != nil {
fmt.Fprintln(os.Stderr, err)
Expand All @@ -178,6 +182,8 @@ func cmdEvalFile(socketPath, lang, file, exe, session string, fresh bool, traceL
Fresh: fresh,
File: abs,
FileArgs: fileArgs,
OwnerPID: ownerPID,
OwnerStart: ownerStart,
}, true)
}

Expand Down Expand Up @@ -205,6 +211,18 @@ func cmdClose(socketPath string, tg subTarget, exe string) {
}, false)
}

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

func cmdTrace(socketPath string, tg subTarget, exe string) {
run(socketPath, protocolRequest{
Action: "trace",
Expand Down Expand Up @@ -236,13 +254,15 @@ repld flags:
--session LABEL Named session, reusable without re-specifying the exe
--fresh Clear the targeted session before evaluating
--trace LEVEL Error traceback level: short, smart, or full (eval default: smart)
--owner-pid N Auto-close the session when process N exits (0 = never; opt out)

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 (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
free Clear the session's owner so it is never auto-closed
stop Stop the daemon
daemon Run the daemon in the foreground (normally auto-started)
--idle-timeout SECS Shut down after idle (default: 0 = never; use 'stop')
Expand All @@ -251,7 +271,7 @@ short id shown by 'sessions'; an id prefix works when unambiguous):
}

var subcommands = map[string]bool{
"sessions": true, "trace": true, "interrupt": true, "close": true, "stop": true, "daemon": true,
"sessions": true, "trace": true, "interrupt": true, "close": true, "free": true, "stop": true, "daemon": true,
}

type parsed struct {
Expand All @@ -260,6 +280,7 @@ type parsed struct {
lang string
session string
trace string
ownerPID string
fresh bool
evalMode string
code string
Expand Down Expand Up @@ -290,6 +311,7 @@ func parseArgs(args []string) parsed {
p := parsed{socket: defaultSocket}
repld := map[string]*string{
"socket": &p.socket, "lang": &p.lang, "session": &p.session, "trace": &p.trace,
"owner-pid": &p.ownerPID,
}
evalModeFor := func(name string) string {
evalNames, printNames := evalPrintFlags(resolveLang(p))
Expand Down Expand Up @@ -461,20 +483,21 @@ func main() {
usage(0)
}
exe := resolveExeStr(p.exe, lang)
ownerPID, ownerStart := resolveOwner(p.ownerPID)

switch {
case p.evalMode != "":
cmdEval(p.socket, lang, p.code, exe, p.session, p.evalMode == "print", p.fresh, p.trace, p.fwd)
cmdEval(p.socket, lang, p.code, exe, p.session, p.evalMode == "print", p.fresh, p.trace, p.fwd, ownerPID, ownerStart)
case p.file != "":
cmdEvalFile(p.socket, lang, p.file, exe, p.session, p.fresh, p.trace, p.fileArgs, p.fwd)
cmdEvalFile(p.socket, lang, p.file, exe, p.session, p.fresh, p.trace, p.fileArgs, p.fwd, ownerPID, ownerStart)
case len(p.fwd) > 0:
cmdEval(p.socket, lang, "", exe, p.session, false, p.fresh, p.trace, p.fwd)
cmdEval(p.socket, lang, "", exe, p.session, false, p.fresh, p.trace, p.fwd, ownerPID, ownerStart)
default:
fi, err := os.Stdin.Stat()
if err != nil || fi.Mode()&os.ModeCharDevice != 0 {
usage(2)
}
cmdEval(p.socket, lang, "-", exe, p.session, false, p.fresh, p.trace, p.fwd)
cmdEval(p.socket, lang, "-", exe, p.session, false, p.fresh, p.trace, p.fwd, ownerPID, ownerStart)
}
}

Expand All @@ -497,6 +520,9 @@ func dispatchSubcommand(p parsed) {
case "close":
tg := parseTarget(p)
cmdClose(p.socket, tg, resolveExeStr(tg.exe, tg.lang))
case "free":
tg := parseTarget(p)
cmdFree(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
51 changes: 47 additions & 4 deletions go/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ func (m *SessionManager) openLogFile(key sessionKey) *os.File {

// forwarded args apply only when a session is first created;
// a live session for the key is reused as-is.
func (m *SessionManager) getOrCreate(lang, cwd, session, exe string, fwd []string) (*Session, error) {
func (m *SessionManager) getOrCreate(lang, cwd, session, exe string, fwd []string, ownerPID int, ownerStart int64) (*Session, error) {
lc, known := langs[lang]
disc := ""
if known {
Expand Down Expand Up @@ -185,6 +185,7 @@ func (m *SessionManager) getOrCreate(lang, cwd, session, exe string, fwd []strin

m.mu.Lock()
sess.id = m.uniqueIDLocked()
sess.ownerPID, sess.ownerStart = ownerPID, ownerStart
m.sessions[key] = sess
m.mu.Unlock()
return sess, nil
Expand Down Expand Up @@ -227,6 +228,46 @@ func (m *SessionManager) close(key sessionKey) (string, error) {
return fmt.Sprintf("Session %s closed.", key), nil
}

func (m *SessionManager) free(key sessionKey) (string, error) {
m.mu.Lock()
sess := m.sessions[key]
if sess != nil {
sess.ownerPID, sess.ownerStart = 0, 0
}
m.mu.Unlock()
if sess == nil {
return "", fmt.Errorf("no session for %s", key)
}
return fmt.Sprintf("Session %s freed; it will not auto-close.", key), nil
}

func (m *SessionManager) reapDeadOwners() {
m.mu.Lock()
var dead []sessionKey
for key, sess := range m.sessions {
if ownerDead(sess.ownerPID, sess.ownerStart) {
dead = append(dead, key)
}
}
m.mu.Unlock()
for _, key := range dead {
m.close(key)
}
}

func (m *SessionManager) reapLoop(stop <-chan struct{}) {
t := time.NewTicker(60 * time.Second)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
m.reapDeadOwners()
}
}
}

func (m *SessionManager) hasLiveSession(lang, session, cwd, disc string) bool {
key := m.key(lang, session, cwd, disc)
m.mu.Lock()
Expand Down Expand Up @@ -256,6 +297,7 @@ type sessionInfo struct {
Session string `yaml:"session,omitempty"` // named-session label; "" for cwd-keyed sessions
Status string `yaml:"status,omitempty"` // "dead" or "busy"; "" when idle and alive
Busy float64 `yaml:"busy,omitempty"` // seconds in-flight; set only while busy
Owner int `yaml:"owner,omitempty"` // owner pid whose exit auto-closes the session; 0 = pinned
Args []string `yaml:"args,omitempty"` // effective launch args (Julia's implicit --project is made explicit)
Log string `yaml:"log,omitempty"`
}
Expand All @@ -276,9 +318,10 @@ 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,
Args: sess.fwd,
ID: sess.id,
Lang: sess.lang,
Args: sess.fwd,
Owner: sess.ownerPID,
}
if key.label != "" {
info.Session = key.label
Expand Down
76 changes: 76 additions & 0 deletions go/owner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package main

import (
"os"
"path/filepath"
"strconv"
"strings"
)

func ownerDead(pid int, startTime int64) bool {
if pid <= 0 {
return false
}
st, alive := procInfo(pid)
if !alive {
return true
}
return startTime != 0 && st != 0 && st != startTime
}

func resolveOwner(explicit string) (int, int64) {
if explicit != "" {
return ownerFrom(explicit)
}
if env := os.Getenv("REPLD_OWNER_PID"); env != "" {
return ownerFrom(env)
}
if os.Getenv("CLAUDECODE") != "" {
// The immediate parent is the per-call shell; the harness sits some
// variable number of levels up (Claude Code wraps Bash as a compound
// `zsh -c`, and users add timeout/env/xargs wrappers), so walk the
// ancestry rather than assuming a fixed depth.
if h := harnessPID(os.Getppid(), ppidOf, procIdent); h > 0 {
st, _ := procInfo(h)
return h, st
}
}
return 0, 0
}

const maxAncestorWalk = 10

func harnessPID(start int, parent func(int) (int, bool), ident func(int) string) int {
pid := start
for depth := 0; depth < maxAncestorWalk && pid > 1; depth++ {
if identifiesHarness(ident(pid)) {
return pid
}
p, ok := parent(pid)
if !ok {
break
}
pid = p
}
return 0
}

// Shell argv may mention ~/.claude; match executable basenames only.
func identifiesHarness(ident string) bool {
sep := func(r rune) bool { return r == 0 || r == ' ' || r == '\t' || r == '\n' || r == '\r' }
for _, tok := range strings.FieldsFunc(ident, sep) {
if filepath.Base(tok) == "claude" {
return true
}
}
return false
}

func ownerFrom(s string) (int, int64) {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n <= 0 {
return 0, 0
}
st, _ := procInfo(n)
return n, st
}
Loading
Loading