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
11 changes: 5 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,11 @@ The interpreter is an explicit leading positional. The language is resolved
*before* `<exe>`; anything after forwards verbatim to the interpreter, except the
eval flag (`-e`/`-c`/`-E`), which repld captures.

**File mode**: with no eval flag captured, the first non-flag positional after
`<exe>` naming an existing regular file (no ext check; missing → launch arg;
subcommand-named → `./` prefix) evals in-session; the rest of argv is its script
args. Sent abs-ified as `protocolRequest.File`/`FileArgs`, never read client-side
and never a launch arg: the daemon wraps it via `Adapter.EvalFileStmt` through
the normal eval path, so it re-evals in the warm session on every call.
**File mode** (mirrors `<exe> [switches] [--] programfile args...`): with no eval flag captured, the first non-flag positional after `<exe>` naming an existing regular file (missing → launch arg) evals in-session; the rest of argv is its script args. `--` forces
the split when a space-form flag value names a file (`-L setup.jl`). Sent
abs-ified as `protocolRequest.File`/`FileArgs`, never read client-side and
never a launch arg: the daemon wraps it via `Adapter.EvalFileStmt` through the
normal eval path, so it re-evals in the warm session on every call.

The engine in `go/` is language-agnostic; per-language specifics live behind
`Adapter` (`go/adapter.go`), implemented in `go/julia/`, `go/python/`, `go/r/`, and `go/wolfram/`. `langs`
Expand Down
41 changes: 15 additions & 26 deletions go/julia_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ func TestJuliaWarmSession(t *testing.T) {
require.Equal(t, expected, run("testdata/compute.jl"), "relative path resolved against session cwd")
})

t.Run("missing script", func(t *testing.T) {
res := repldErr(t, socketPath, cwd, "julia", "nonexistent.jl")
require.Contains(t, res.stderr, "No such file")
})

t.Run("trace saved", func(t *testing.T) {
res := repldErr(t, socketPath, cwd, "julia", "-e", `let f = () -> error("boom"); g = () -> f(); g(); end`)
require.Empty(t, res.stdout)
Expand Down Expand Up @@ -269,18 +274,16 @@ func TestInterruptIdleSession(t *testing.T) {
// covers `timeout 30 repld ...` scenario
func TestClientDisconnectInterruptsEval(t *testing.T) {
socketPath := sharedDaemon(t)
cwd := sharedJuliaCwd(t)

cwd, err := os.Getwd()
require.NoError(t, err)

// Prime the session and set state that must survive the disconnect-interrupt.
repldOK(t, socketPath, cwd, "--session", "disc", "julia", "-e", "marker = 5678")
// Set state that must survive the disconnect-interrupt.
repldOK(t, socketPath, cwd, "julia", "-e", "marker = 5678")

// Start a long eval on its own connection.
conn, err := net.Dial("unix", socketPath)
require.NoError(t, err)
require.NoError(t, json.NewEncoder(conn).Encode(protocolRequest{
Action: "eval", Lang: "julia", Session: "disc", Code: "sleep(60)", Cwd: cwd,
Action: "eval", Lang: "julia", Code: "sleep(60)", Cwd: cwd,
}))

// Wait until the session reports busy.
Expand All @@ -304,7 +307,7 @@ func TestClientDisconnectInterruptsEval(t *testing.T) {
// Session must be usable again AND have survived with state intact
doneCh := make(chan cliResult, 1)
go func() {
doneCh <- repldCLI(t, socketPath, cwd, "--session", "disc", "julia", "-e", "print(marker)")
doneCh <- repldCLI(t, socketPath, cwd, "julia", "-e", "print(marker)")
}()
select {
case r := <-doneCh:
Expand Down Expand Up @@ -388,18 +391,6 @@ func TestKillRunsAtexitHooks(t *testing.T) {
require.FileExists(t, marker, "graceful shutdown should run atexit hooks, not SIGKILL")
}

func TestCLIJuliaMissingScriptBehavesLikeInteractiveInterpreter(t *testing.T) {
if _, err := exec.LookPath(julia.Adapter{}.DefaultExe()); err != nil {
t.Skipf("%s not installed", julia.Adapter{}.DefaultExe())
}
socketPath := sharedDaemon(t)

// Own cwd → a cold session, so `x=1` reaches a fresh julia as a launch arg
// (a warm session would eval it as code).
res := repldOK(t, socketPath, sessionCwd(t), "julia", "x=1")
require.Contains(t, res.stderr, "No such file")
require.NotContains(t, res.stderr, "repld: persistent REPL daemon")
}

func TestJuliaFileEvalArgsAndState(t *testing.T) {
if _, err := exec.LookPath(julia.Adapter{}.DefaultExe()); err != nil {
Expand Down Expand Up @@ -427,18 +418,16 @@ func TestJuliaFileEvalArgsAndState(t *testing.T) {
// A request queued behind another eval must not interrupt running eval when client disconnects.
func TestQueuedDisconnectDoesNotInterruptRunningEval(t *testing.T) {
socketPath := sharedDaemon(t)

cwd, err := os.Getwd()
require.NoError(t, err)
repldOK(t, socketPath, cwd, "--session", "q", "julia", "-e", "1")
cwd := sharedJuliaCwd(t)
repldOK(t, socketPath, cwd, "julia", "-e", "1")

// conn1: long enough to still be busy when conn2 queues and disconnects.
conn1, err := net.Dial("unix", socketPath)
require.NoError(t, err)
defer conn1.Close()
require.NoError(t, json.NewEncoder(conn1).Encode(protocolRequest{
Action: "eval", Lang: "julia", Session: "q", Cwd: cwd,
Code: "sleep(4); 4321", PrintResult: true,
Action: "eval", Lang: "julia", Cwd: cwd,
Code: "sleep(1.5); 4321", PrintResult: true,
}))

require.Eventually(t, func() bool {
Expand All @@ -449,7 +438,7 @@ func TestQueuedDisconnectDoesNotInterruptRunningEval(t *testing.T) {
conn2, err := net.Dial("unix", socketPath)
require.NoError(t, err)
require.NoError(t, json.NewEncoder(conn2).Encode(protocolRequest{
Action: "eval", Lang: "julia", Session: "q", Cwd: cwd, Code: "1+1",
Action: "eval", Lang: "julia", Cwd: cwd, Code: "1+1",
}))
time.Sleep(300 * time.Millisecond)
require.NoError(t, conn2.Close())
Expand Down
26 changes: 19 additions & 7 deletions go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ func usage() {
fmt.Fprintf(os.Stderr, `repld: persistent REPL daemon for multiple interpreters

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

Expand Down Expand Up @@ -296,6 +296,21 @@ func parseArgs(args []string) parsed {
i++
continue
}
// "--" marks the next token as the eval file, mirroring
// `<exe> [switches] -- [programfile] [args...]`.
if t == "--" && p.exe != "" && p.evalMode == "" && p.sub == "" {
if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "missing file after --")
usage()
}
f := args[i+1]
if fi, err := os.Stat(f); err != nil || !fi.Mode().IsRegular() {
fmt.Fprintf(os.Stderr, "file not found: %s\n", f)
os.Exit(1)
}
p.file, p.fileArgs = f, args[i+2:]
break
}
if strings.HasPrefix(t, "-") {
name := flagName(t)
dst, isRepld := repld[name]
Expand Down Expand Up @@ -323,12 +338,9 @@ func parseArgs(args []string) parsed {
i++
continue
}
// File mode: the first interpreter token naming an existing regular file
// evals in-session; the rest of argv is its script args. No ext check
// (shebang/justfile temp scripts are extensionless); a missing path
// forwards as a launch arg; subcommand names need a ./ prefix.
if p.exe != "" && p.evalMode == "" && len(p.fwd) == 0 && !subcommands[t] {
if fi, err := os.Stat(t); err == nil && fi.Mode().IsRegular() {
// File mode (mirrors `<exe> [options] programfile args...`)
if p.exe != "" && p.evalMode == "" && !subcommands[t] {
if fi, err := os.Stat(t); (err == nil && fi.Mode().IsRegular()) || (len(p.fwd) == 0 && !strings.HasPrefix(t, "+")) {
p.file = t
i++
continue
Expand Down
18 changes: 14 additions & 4 deletions go/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,21 @@ func TestParseArgsFileMode(t *testing.T) {
require.NoError(t, os.WriteFile(recipe, []byte("#!/usr/bin/env -S repld julia\n1\n"), 0755))
require.Equal(t, recipe, parseCLI([]string{"julia", recipe, "x"}).file)

// An eval flag disables detection; a missing path forwards as a launch arg.
// An eval flag disables detection; a missing path still enters file mode.
got = parseCLI([]string{"julia", "-e", "1", jl})
require.Equal(t, "", got.file)
require.Equal(t, []string{jl}, got.fwd)
require.Equal(t, "", parseCLI([]string{"julia", filepath.Join(dir, "nope.jl")}).file)
require.Equal(t, filepath.Join(dir, "nope.jl"), parseCLI([]string{"julia", filepath.Join(dir, "nope.jl")}).file)

for _, argv := range [][]string{
{"julia", "--project=test", jl, "a"},
{"julia", "--project=test", "--", jl, "a"},
} {
got = parseCLI(argv)
require.Equal(t, []string{"--project=test"}, got.fwd)
require.Equal(t, jl, got.file)
require.Equal(t, []string{"a"}, got.fileArgs)
}
}

func TestParseArgs(t *testing.T) {
Expand All @@ -142,8 +152,8 @@ func TestParseArgs(t *testing.T) {
parsed{fwd: []string{"--startup-file=no"}}},
{"juliaup channel forwards after exe", []string{"julia", "+1.11", "-e", "c"},
parsed{exe: "julia", evalMode: "eval", code: "c", fwd: []string{"+1.11"}}},
// Positionals after launch args are never file mode.
{"no file mode once forwarding starts", []string{"julia", "--project", "/env", "script.jl", "arg1"},
// Missing files forward as launch args even after flags.
{"missing file forwards after launch flags", []string{"julia", "--project", "/env", "script.jl", "arg1"},
parsed{exe: "julia", fwd: []string{"--project", "/env", "script.jl", "arg1"}}},
{"subcommand", []string{"sessions"}, parsed{sub: "sessions"}},
{"flags before subcommand", []string{"--socket", "x", "sessions"},
Expand Down
11 changes: 5 additions & 6 deletions go/python_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,12 @@ func TestCLIPythonEvalDoesNotLeakInteractivePrompts(t *testing.T) {

func TestCLIPythonMissingScriptBehavesLikeInteractiveInterpreter(t *testing.T) {
socketPath, _ := pythonTestDaemon(t)
cwd := sessionCwd(t)

// Own cwd → a cold session, so `x=1` is passed as a launch arg to a fresh
// python that fails to open it (a warm session would eval it as code).
res := repldOK(t, socketPath, sessionCwd(t), "python3", "x=1")
require.Contains(t, res.stderr, "can't open file")
require.NotContains(t, res.stderr, ">>>")
require.NotContains(t, res.stderr, "repld: persistent REPL daemon")
repldOK(t, socketPath, cwd, "python3", "-c", "pass")
res := repldErr(t, socketPath, cwd, "python3", "x=1")
require.Contains(t, res.stderr, "FileNotFoundError")
require.Contains(t, res.stderr, "No such file")
}

func TestPythonTracebackLineNumbers(t *testing.T) {
Expand Down
Loading