From 07a42aa4605726764ab86ea103c31fc4b2461404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Fri, 16 Jan 2026 20:08:44 +0800 Subject: [PATCH 01/37] feat(components/execd): modify bash runtime by pty --- .github/workflows/execd-test.yml | 2 +- components/execd/pkg/runtime/bash_session.go | 286 ++++++++++++++++++ .../execd/pkg/runtime/bash_session_test.go | 183 +++++++++++ .../execd/pkg/runtime/bash_session_windows.go | 67 ++++ components/execd/pkg/runtime/context.go | 27 +- components/execd/pkg/runtime/context_test.go | 10 +- components/execd/pkg/runtime/ctrl.go | 28 +- components/execd/pkg/runtime/interrupt.go | 2 + components/execd/pkg/runtime/jupyter.go | 6 +- components/execd/pkg/runtime/types.go | 33 ++ 10 files changed, 612 insertions(+), 32 deletions(-) create mode 100644 components/execd/pkg/runtime/bash_session.go create mode 100644 components/execd/pkg/runtime/bash_session_test.go create mode 100644 components/execd/pkg/runtime/bash_session_windows.go diff --git a/.github/workflows/execd-test.yml b/.github/workflows/execd-test.yml index e30044293..43a7fc3d9 100644 --- a/.github/workflows/execd-test.yml +++ b/.github/workflows/execd-test.yml @@ -25,7 +25,7 @@ jobs: - name: Run golint run: | cd components/execd - make golint + # make golint - name: Build (Multi platform compile) run: | diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go new file mode 100644 index 000000000..01f583f19 --- /dev/null +++ b/components/execd/pkg/runtime/bash_session.go @@ -0,0 +1,286 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows +// +build !windows + +package runtime + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/alibaba/opensandbox/execd/pkg/log" +) + +func (c *Controller) createBashSession(_ *CreateContextRequest) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + + session := newBashSession(nil) + if err := session.start(); err != nil { + return "", fmt.Errorf("failed to start bash session: %w", err) + } + + c.bashSessionClientMap[session.config.Session] = session + log.Info("created bash session %s", session.config.Session) + return session.config.Session, nil +} + +func (c *Controller) runBashSession(_ context.Context, request *ExecuteCodeRequest) error { + if request.Context == "" { + if _, exists := c.defaultLanguageSessions[request.Language]; !exists { + err := c.createDefaultBashSession() + if err != nil { + return err + } + } + } + + targetSessionID := request.Context + if targetSessionID == "" { + targetSessionID = c.defaultLanguageSessions[request.Language] + } + + session := c.getBashSession(targetSessionID) + if session == nil { + return ErrContextNotFound + } + + return session.run(request.Code, request.Timeout, &request.Hooks) +} + +func (c *Controller) createDefaultBashSession() error { + session, err := c.createBashSession(&CreateContextRequest{}) + if err != nil { + return err + } + + c.defaultLanguageSessions[Bash] = session + return nil +} + +func (c *Controller) getBashSession(sessionId string) *bashSession { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.bashSessionClientMap[sessionId] +} + +func (c *Controller) closeBashSession(sessionId string) error { + session := c.getBashSession(sessionId) + if session == nil { + return ErrContextNotFound + } + + c.mu.Lock() + defer c.mu.Unlock() + err := session.close() + if err != nil { + return err + } + + delete(c.bashSessionClientMap, sessionId) + return nil +} + +func (c *Controller) listBashSessions() []string { + c.mu.RLock() + defer c.mu.RUnlock() + + sessions := make([]string, 0, len(c.bashSessionClientMap)) + for sessionID := range c.bashSessionClientMap { + sessions = append(sessions, sessionID) + } + + return sessions +} + +// Session implementation (pipe-based, no PTY) +func newBashSession(config *bashSessionConfig) *bashSession { + if config == nil { + config = &bashSessionConfig{ + Session: uuidString(), + StartupTimeout: 5 * time.Second, + } + } + return &bashSession{ + config: config, + stdoutLines: make(chan string, 256), + stdoutErr: make(chan error, 1), + } +} + +func (s *bashSession) start() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.started { + return errors.New("session already started") + } + + cmd := exec.Command("bash", "--noprofile", "--norc", "-s") + cmd.Env = os.Environ() + + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("stdin pipe: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start bash: %w", err) + } + + s.cmd = cmd + s.stdin = stdin + s.stdout = stdout + s.stderr = stderr + s.started = true + + // drain stdout/stderr into channel + go s.readStdout(stdout) + go s.discardStderr(stderr) + return nil +} + +func (s *bashSession) readStdout(r io.Reader) { + reader := bufio.NewReader(r) + for { + line, err := reader.ReadString('\n') + if len(line) > 0 { + s.stdoutLines <- strings.TrimRight(line, "\r\n") + } + if err != nil { + if !errors.Is(err, io.EOF) { + s.stdoutErr <- err + } + close(s.stdoutLines) + return + } + } +} + +func (s *bashSession) discardStderr(r io.Reader) { + _, _ = io.Copy(io.Discard, r) +} + +func (s *bashSession) run(command string, timeout time.Duration, hooks *ExecuteResultHook) error { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.started { + return errors.New("session not started") + } + + startAt := time.Now() + + if hooks != nil && hooks.OnExecuteInit != nil { + hooks.OnExecuteInit(s.config.Session) + } + + waitSeconds := timeout + if waitSeconds <= 0 { + waitSeconds = 30 * time.Second + } + + cleanCmd := strings.ReplaceAll(command, "\n", " ; ") + + // send command + marker + cmdText := fmt.Sprintf("%s\nprintf \"%s$?%s\\n\"\n", cleanCmd, exitCodePrefix, exitCodeSuffix) + if _, err := fmt.Fprint(s.stdin, cmdText); err != nil { + return fmt.Errorf("write command: %w", err) + } + + // collect output until marker + timer := time.NewTimer(waitSeconds) + defer timer.Stop() + + for { + select { + case <-timer.C: + return fmt.Errorf("timeout after %s while running command %q", waitSeconds, command) + case err := <-s.stdoutErr: + if err != nil { + return err + } + case line, ok := <-s.stdoutLines: + if !ok { + return errors.New("stdout closed unexpectedly") + } + if _, ok := parseExitCodeLine(line); ok { + if hooks != nil && hooks.OnExecuteComplete != nil { + hooks.OnExecuteComplete(time.Since(startAt)) + } + return nil + } + if hooks != nil && hooks.OnExecuteStdout != nil { + hooks.OnExecuteStdout(line) + } + } + } +} + +func parseExitCodeLine(line string) (int, bool) { + p := strings.Index(line, exitCodePrefix) + q := strings.Index(line, exitCodeSuffix) + if p < 0 || q <= p { + return 0, false + } + text := strings.TrimSpace(line[p+len(exitCodePrefix) : q]) + code, err := strconv.Atoi(text) + if err != nil { + return 0, false + } + return code, true +} + +func (s *bashSession) close() error { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.started { + return nil + } + s.started = false + + if s.stdin != nil { + _ = s.stdin.Close() + } + if s.cmd != nil && s.cmd.Process != nil { + _ = s.cmd.Process.Kill() + } + return nil +} + +func uuidString() string { + return uuid.New().String() +} diff --git a/components/execd/pkg/runtime/bash_session_test.go b/components/execd/pkg/runtime/bash_session_test.go new file mode 100644 index 000000000..ac66ca0a3 --- /dev/null +++ b/components/execd/pkg/runtime/bash_session_test.go @@ -0,0 +1,183 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows +// +build !windows + +package runtime + +import ( + "strings" + "testing" + "time" +) + +func TestBashSessionEnvAndExitCode(t *testing.T) { + session := newBashSession(nil) + t.Cleanup(func() { _ = session.close() }) + + if err := session.start(); err != nil { + t.Fatalf("Start() error = %v", err) + } + + var ( + initCalls int + completeCalls int + stdoutLines []string + ) + + hooks := ExecuteResultHook{ + OnExecuteInit: func(ctx string) { + if ctx != session.config.Session { + t.Fatalf("unexpected session in OnExecuteInit: %s", ctx) + } + initCalls++ + }, + OnExecuteStdout: func(text string) { + t.Log(text) + stdoutLines = append(stdoutLines, text) + }, + OnExecuteComplete: func(_ time.Duration) { + completeCalls++ + }, + } + + // 1) export an env var + if err := session.run("export FOO=hello", 3*time.Second, &hooks); err != nil { + t.Fatalf("runCommand(export) error = %v", err) + } + exportStdoutCount := len(stdoutLines) + + // 2) verify env is persisted + if err := session.run("echo $FOO", 3*time.Second, &hooks); err != nil { + t.Fatalf("runCommand(echo) error = %v", err) + } + echoLines := stdoutLines[exportStdoutCount:] + foundHello := false + for _, line := range echoLines { + if strings.TrimSpace(line) == "hello" { + foundHello = true + break + } + } + if !foundHello { + t.Fatalf("expected echo $FOO to output 'hello', got %v", echoLines) + } + + // 3) ensure exit code of previous command is reflected in shell state + prevCount := len(stdoutLines) + if err := session.run("false; echo EXIT:$?", 3*time.Second, &hooks); err != nil { + t.Fatalf("runCommand(exitcode) error = %v", err) + } + exitLines := stdoutLines[prevCount:] + foundExit := false + for _, line := range exitLines { + if strings.Contains(line, "EXIT:1") { + foundExit = true + break + } + } + if !foundExit { + t.Fatalf("expected exit code output 'EXIT:1', got %v", exitLines) + } + + if initCalls != 3 { + t.Fatalf("OnExecuteInit expected 3 calls, got %d", initCalls) + } + if completeCalls != 3 { + t.Fatalf("OnExecuteComplete expected 3 calls, got %d", completeCalls) + } +} + +func TestBashSessionEnvLargeOutputChained(t *testing.T) { + session := newBashSession(nil) + t.Cleanup(func() { _ = session.close() }) + + if err := session.start(); err != nil { + t.Fatalf("Start() error = %v", err) + } + + var ( + initCalls int + completeCalls int + stdoutLines []string + ) + + hooks := ExecuteResultHook{ + OnExecuteInit: func(ctx string) { + if ctx != session.config.Session { + t.Fatalf("unexpected session in OnExecuteInit: %s", ctx) + } + initCalls++ + }, + OnExecuteStdout: func(text string) { + t.Log(text) + stdoutLines = append(stdoutLines, text) + }, + OnExecuteComplete: func(_ time.Duration) { + completeCalls++ + }, + } + + runAndCollect := func(cmd string) []string { + start := len(stdoutLines) + if err := session.run(cmd, 10*time.Second, &hooks); err != nil { + t.Fatalf("runCommand(%q) error = %v", cmd, err) + } + return append([]string(nil), stdoutLines[start:]...) + } + + lines1 := runAndCollect("export FOO=hello1; for i in $(seq 1 60); do echo A${i}:$FOO; done") + if len(lines1) < 60 { + t.Fatalf("expected >=60 lines for cmd1, got %d", len(lines1)) + } + if !containsLine(lines1, "A1:hello1") || !containsLine(lines1, "A60:hello1") { + t.Fatalf("env not reflected in cmd1 output, got %v", lines1[:3]) + } + + lines2 := runAndCollect("export FOO=${FOO}_next; export BAR=bar1; for i in $(seq 1 60); do echo B${i}:$FOO:$BAR; done") + if len(lines2) < 60 { + t.Fatalf("expected >=60 lines for cmd2, got %d", len(lines2)) + } + if !containsLine(lines2, "B1:hello1_next:bar1") || !containsLine(lines2, "B60:hello1_next:bar1") { + t.Fatalf("env not propagated to cmd2 output, sample %v", lines2[:3]) + } + + lines3 := runAndCollect("export BAR=${BAR}_last; for i in $(seq 1 60); do echo C${i}:$FOO:$BAR; done; echo FINAL_FOO=$FOO; echo FINAL_BAR=$BAR") + if len(lines3) < 62 { // 60 lines + 2 finals + t.Fatalf("expected >=62 lines for cmd3, got %d", len(lines3)) + } + if !containsLine(lines3, "C1:hello1_next:bar1_last") || !containsLine(lines3, "C60:hello1_next:bar1_last") { + t.Fatalf("env not propagated to cmd3 output, sample %v", lines3[:3]) + } + if !containsLine(lines3, "FINAL_FOO=hello1_next") || !containsLine(lines3, "FINAL_BAR=bar1_last") { + t.Fatalf("final env lines missing, got %v", lines3[len(lines3)-5:]) + } + + if initCalls != 3 { + t.Fatalf("OnExecuteInit expected 3 calls, got %d", initCalls) + } + if completeCalls != 3 { + t.Fatalf("OnExecuteComplete expected 3 calls, got %d", completeCalls) + } +} + +func containsLine(lines []string, target string) bool { + for _, l := range lines { + if strings.TrimSpace(l) == target { + return true + } + } + return false +} diff --git a/components/execd/pkg/runtime/bash_session_windows.go b/components/execd/pkg/runtime/bash_session_windows.go new file mode 100644 index 000000000..8b65db812 --- /dev/null +++ b/components/execd/pkg/runtime/bash_session_windows.go @@ -0,0 +1,67 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows +// +build windows + +package runtime + +import ( + "context" + "errors" + "time" +) + +var errBashSessionNotSupported = errors.New("bash session is not supported on windows") + +func (c *Controller) createBashSession(_ *CreateContextRequest) (string, error) { + return "", errBashSessionNotSupported +} + +func (c *Controller) runBashSession(_ context.Context, _ *ExecuteCodeRequest) error { //nolint:revive + return errBashSessionNotSupported +} + +func (c *Controller) createDefaultBashSession() error { //nolint:revive + return errBashSessionNotSupported +} + +func (c *Controller) getBashSession(_ string) (*bashSession, error) { //nolint:revive + return nil, errBashSessionNotSupported +} + +func (c *Controller) closeBashSession(_ string) error { //nolint:revive + return errBashSessionNotSupported +} + +func (c *Controller) listBashSessions() []string { //nolint:revive + return nil +} + +// Stub methods on bashSession to satisfy interfaces on non-Linux platforms. +func newBashSession(config *bashSessionConfig) *bashSession { + return &bashSession{config: config} +} + +func (s *bashSession) start() (string, error) { + return "", errBashSessionNotSupported +} + +func (s *bashSession) run(_ string, _ time.Duration, _ *ExecuteResultHook) error { + return errBashSessionNotSupported +} + +func (s *bashSession) close() error { + return nil +} diff --git a/components/execd/pkg/runtime/context.go b/components/execd/pkg/runtime/context.go index a11355072..0f9abadcd 100644 --- a/components/execd/pkg/runtime/context.go +++ b/components/execd/pkg/runtime/context.go @@ -32,6 +32,11 @@ import ( // CreateContext provisions a kernel-backed session and returns its ID. func (c *Controller) CreateContext(req *CreateContextRequest) (string, error) { + if req.Language == Bash { + return c.createBashSession(req) + } + + // Create a new Jupyter session. var ( client *jupyter.Client session *jupytersession.Session @@ -42,7 +47,7 @@ func (c *Controller) CreateContext(req *CreateContextRequest) (string, error) { log.Error("failed to create session, retrying: %v", err) return err != nil }, func() error { - client, session, err = c.createContext(*req) + client, session, err = c.createJupyterContext(*req) return err }) if err != nil { @@ -120,9 +125,9 @@ func (c *Controller) deleteSessionAndCleanup(session string) error { defer c.mu.Unlock() delete(c.jupyterClientMap, session) - for lang, id := range c.defaultLanguageJupyterSessions { + for lang, id := range c.defaultLanguageSessions { if id == session { - delete(c.defaultLanguageJupyterSessions, lang) + delete(c.defaultLanguageSessions, lang) } } return nil @@ -143,8 +148,8 @@ func (c *Controller) newIpynbPath(sessionID, cwd string) (string, error) { return filepath.Join(cwd, fmt.Sprintf("%s.ipynb", sessionID)), nil } -// createDefaultLanguageContext prewarms a session for stateless execution. -func (c *Controller) createDefaultLanguageContext(language Language) error { +// createDefaultLanguageJupyterContext prewarms a session for stateless execution. +func (c *Controller) createDefaultLanguageJupyterContext(language Language) error { var ( client *jupyter.Client session *jupytersession.Session @@ -154,7 +159,7 @@ func (c *Controller) createDefaultLanguageContext(language Language) error { log.Error("failed to create context, retrying: %v", err) return err != nil }, func() error { - client, session, err = c.createContext(CreateContextRequest{ + client, session, err = c.createJupyterContext(CreateContextRequest{ Language: language, Cwd: "", }) @@ -167,7 +172,7 @@ func (c *Controller) createDefaultLanguageContext(language Language) error { c.mu.Lock() defer c.mu.Unlock() - c.defaultLanguageJupyterSessions[language] = session.ID + c.defaultLanguageSessions[language] = session.ID c.jupyterClientMap[session.ID] = &jupyterKernel{ kernelID: session.Kernel.ID, client: client, @@ -176,8 +181,8 @@ func (c *Controller) createDefaultLanguageContext(language Language) error { return nil } -// createContext performs the actual context creation workflow. -func (c *Controller) createContext(request CreateContextRequest) (*jupyter.Client, *jupytersession.Session, error) { +// createJupyterContext performs the actual context creation workflow. +func (c *Controller) createJupyterContext(request CreateContextRequest) (*jupyter.Client, *jupytersession.Session, error) { client := c.jupyterClient() kernel, err := c.searchKernel(client, request.Language) @@ -250,7 +255,7 @@ func (c *Controller) listAllContexts() ([]CodeContext, error) { } } - for language, defaultContext := range c.defaultLanguageJupyterSessions { + for language, defaultContext := range c.defaultLanguageSessions { contexts = append(contexts, CodeContext{ ID: defaultContext, Language: language, @@ -274,7 +279,7 @@ func (c *Controller) listLanguageContexts(language Language) ([]CodeContext, err } } - if defaultContext := c.defaultLanguageJupyterSessions[language]; defaultContext != "" { + if defaultContext := c.defaultLanguageSessions[language]; defaultContext != "" { contexts = append(contexts, CodeContext{ ID: defaultContext, Language: language, diff --git a/components/execd/pkg/runtime/context_test.go b/components/execd/pkg/runtime/context_test.go index 6a27ad18b..07ee6da35 100644 --- a/components/execd/pkg/runtime/context_test.go +++ b/components/execd/pkg/runtime/context_test.go @@ -27,7 +27,7 @@ import ( func TestListContextsAndNewIpynbPath(t *testing.T) { c := NewController("http://example", "token") c.jupyterClientMap["session-python"] = &jupyterKernel{language: Python} - c.defaultLanguageJupyterSessions[Go] = "session-go-default" + c.defaultLanguageSessions[Go] = "session-go-default" pyContexts, err := c.listLanguageContexts(Python) if err != nil { @@ -129,7 +129,7 @@ func TestDeleteContext_RemovesCacheOnSuccess(t *testing.T) { c := NewController(server.URL, "token") c.jupyterClientMap[sessionID] = &jupyterKernel{language: Python} - c.defaultLanguageJupyterSessions[Python] = sessionID + c.defaultLanguageSessions[Python] = sessionID if err := c.DeleteContext(sessionID); err != nil { t.Fatalf("DeleteContext returned error: %v", err) @@ -138,7 +138,7 @@ func TestDeleteContext_RemovesCacheOnSuccess(t *testing.T) { if kernel := c.getJupyterKernel(sessionID); kernel != nil { t.Fatalf("expected cache to be cleared, found: %+v", kernel) } - if _, ok := c.defaultLanguageJupyterSessions[Python]; ok { + if _, ok := c.defaultLanguageSessions[Python]; ok { t.Fatalf("expected default session entry to be removed") } } @@ -168,7 +168,7 @@ func TestDeleteLanguageContext_RemovesCacheOnSuccess(t *testing.T) { c := NewController(server.URL, "token") c.jupyterClientMap[session1] = &jupyterKernel{language: lang} c.jupyterClientMap[session2] = &jupyterKernel{language: lang} - c.defaultLanguageJupyterSessions[lang] = session2 + c.defaultLanguageSessions[lang] = session2 if err := c.DeleteLanguageContext(lang); err != nil { t.Fatalf("DeleteLanguageContext returned error: %v", err) @@ -180,7 +180,7 @@ func TestDeleteLanguageContext_RemovesCacheOnSuccess(t *testing.T) { if _, ok := c.jupyterClientMap[session2]; ok { t.Fatalf("expected session2 removed from cache") } - if _, ok := c.defaultLanguageJupyterSessions[lang]; ok { + if _, ok := c.defaultLanguageSessions[lang]; ok { t.Fatalf("expected default entry removed") } if deleteCalls[session1] != 1 || deleteCalls[session2] != 1 { diff --git a/components/execd/pkg/runtime/ctrl.go b/components/execd/pkg/runtime/ctrl.go index 20bbecc62..81332afc7 100644 --- a/components/execd/pkg/runtime/ctrl.go +++ b/components/execd/pkg/runtime/ctrl.go @@ -35,14 +35,15 @@ var kernelWaitingBackoff = wait.Backoff{ // Controller manages code execution across runtimes. type Controller struct { - baseURL string - token string - mu sync.RWMutex - jupyterClientMap map[string]*jupyterKernel - defaultLanguageJupyterSessions map[Language]string - commandClientMap map[string]*commandKernel - db *sql.DB - dbOnce sync.Once + baseURL string + token string + mu sync.RWMutex + jupyterClientMap map[string]*jupyterKernel + defaultLanguageSessions map[Language]string + commandClientMap map[string]*commandKernel + bashSessionClientMap map[string]*bashSession + db *sql.DB + dbOnce sync.Once } type jupyterKernel struct { @@ -71,9 +72,10 @@ func NewController(baseURL, token string) *Controller { baseURL: baseURL, token: token, - jupyterClientMap: make(map[string]*jupyterKernel), - defaultLanguageJupyterSessions: make(map[Language]string), - commandClientMap: make(map[string]*commandKernel), + jupyterClientMap: make(map[string]*jupyterKernel), + defaultLanguageSessions: make(map[Language]string), + commandClientMap: make(map[string]*commandKernel), + bashSessionClientMap: make(map[string]*bashSession), } } @@ -93,10 +95,12 @@ func (c *Controller) Execute(request *ExecuteCodeRequest) error { return c.runCommand(ctx, request) case BackgroundCommand: return c.runBackgroundCommand(ctx, request) - case Bash, Python, Java, JavaScript, TypeScript, Go: + case Python, Java, JavaScript, TypeScript, Go: return c.runJupyter(ctx, request) case SQL: return c.runSQL(ctx, request) + case Bash: + return c.runBashSession(ctx, request) default: return fmt.Errorf("unknown language: %s", request.Language) } diff --git a/components/execd/pkg/runtime/interrupt.go b/components/execd/pkg/runtime/interrupt.go index 1a9515fa1..67902a3d6 100644 --- a/components/execd/pkg/runtime/interrupt.go +++ b/components/execd/pkg/runtime/interrupt.go @@ -38,6 +38,8 @@ func (c *Controller) Interrupt(sessionID string) error { case c.getCommandKernel(sessionID) != nil: kernel := c.getCommandKernel(sessionID) return c.killPid(kernel.pid) + case c.getBashSession(sessionID) != nil: + return c.closeBashSession(sessionID) default: return errors.New("no such session") } diff --git a/components/execd/pkg/runtime/jupyter.go b/components/execd/pkg/runtime/jupyter.go index cdc0a6cc5..ba53abafd 100644 --- a/components/execd/pkg/runtime/jupyter.go +++ b/components/execd/pkg/runtime/jupyter.go @@ -29,8 +29,8 @@ func (c *Controller) runJupyter(ctx context.Context, request *ExecuteCodeRequest return errors.New("language runtime server not configured, please check your image runtime") } if request.Context == "" { - if _, exists := c.defaultLanguageJupyterSessions[request.Language]; !exists { - err := c.createDefaultLanguageContext(request.Language) + if _, exists := c.defaultLanguageSessions[request.Language]; !exists { + err := c.createDefaultLanguageJupyterContext(request.Language) if err != nil { return err } @@ -39,7 +39,7 @@ func (c *Controller) runJupyter(ctx context.Context, request *ExecuteCodeRequest var targetSessionID string if request.Context == "" { - targetSessionID = c.defaultLanguageJupyterSessions[request.Language] + targetSessionID = c.defaultLanguageSessions[request.Language] } else { targetSessionID = request.Context } diff --git a/components/execd/pkg/runtime/types.go b/components/execd/pkg/runtime/types.go index cb82a11bc..5cd5addae 100644 --- a/components/execd/pkg/runtime/types.go +++ b/components/execd/pkg/runtime/types.go @@ -16,6 +16,9 @@ package runtime import ( "fmt" + "io" + "os/exec" + "sync" "time" "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" @@ -80,3 +83,33 @@ type CodeContext struct { ID string `json:"id,omitempty"` Language Language `json:"language"` } + +// bashSessionConfig holds bash session configuration. +type bashSessionConfig struct { + // StartupSource is a list of scripts sourced on startup. + StartupSource []string + // Session is the session identifier. + Session string + // StartupTimeout is the startup timeout. + StartupTimeout time.Duration +} + +const ( + // exitCodePrefix marks the beginning of exit code output. + exitCodePrefix = "EXITCODESTART" + // exitCodeSuffix marks the end of exit code output. + exitCodeSuffix = "EXITCODEEND" +) + +// bashSession represents a bash session. +type bashSession struct { + config *bashSessionConfig + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + stderr io.ReadCloser + stdoutLines chan string + stdoutErr chan error + mu sync.Mutex + started bool +} From d9c20ab153a2e6c7c007c724f8e690c476d70659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Sat, 17 Jan 2026 12:12:10 +0800 Subject: [PATCH 02/37] feat(components/execd): use concurrent-safe maps to avoid single point of dependency on global locks. --- .github/workflows/execd-test.yml | 2 +- components/execd/pkg/runtime/bash_session.go | 40 +++---- .../execd/pkg/runtime/command_common.go | 15 ++- .../execd/pkg/runtime/command_status.go | 17 +-- components/execd/pkg/runtime/context.go | 105 +++++++++--------- components/execd/pkg/runtime/context_test.go | 23 ++-- components/execd/pkg/runtime/ctrl.go | 16 +-- components/execd/pkg/runtime/jupyter.go | 17 +-- 8 files changed, 120 insertions(+), 115 deletions(-) diff --git a/.github/workflows/execd-test.yml b/.github/workflows/execd-test.yml index 43a7fc3d9..e30044293 100644 --- a/.github/workflows/execd-test.yml +++ b/.github/workflows/execd-test.yml @@ -25,7 +25,7 @@ jobs: - name: Run golint run: | cd components/execd - # make golint + make golint - name: Build (Multi platform compile) run: | diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index 01f583f19..bde8b7119 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -35,24 +35,20 @@ import ( ) func (c *Controller) createBashSession(_ *CreateContextRequest) (string, error) { - c.mu.Lock() - defer c.mu.Unlock() - session := newBashSession(nil) if err := session.start(); err != nil { return "", fmt.Errorf("failed to start bash session: %w", err) } - c.bashSessionClientMap[session.config.Session] = session + c.bashSessionClientMap.Store(session.config.Session, session) log.Info("created bash session %s", session.config.Session) return session.config.Session, nil } func (c *Controller) runBashSession(_ context.Context, request *ExecuteCodeRequest) error { if request.Context == "" { - if _, exists := c.defaultLanguageSessions[request.Language]; !exists { - err := c.createDefaultBashSession() - if err != nil { + if c.getDefaultLanguageSession(request.Language) == "" { + if err := c.createDefaultBashSession(); err != nil { return err } } @@ -60,7 +56,7 @@ func (c *Controller) runBashSession(_ context.Context, request *ExecuteCodeReque targetSessionID := request.Context if targetSessionID == "" { - targetSessionID = c.defaultLanguageSessions[request.Language] + targetSessionID = c.getDefaultLanguageSession(request.Language) } session := c.getBashSession(targetSessionID) @@ -77,15 +73,17 @@ func (c *Controller) createDefaultBashSession() error { return err } - c.defaultLanguageSessions[Bash] = session + c.setDefaultLanguageSession(Bash, session) return nil } func (c *Controller) getBashSession(sessionId string) *bashSession { - c.mu.RLock() - defer c.mu.RUnlock() - - return c.bashSessionClientMap[sessionId] + if v, ok := c.bashSessionClientMap.Load(sessionId); ok { + if s, ok := v.(*bashSession); ok { + return s + } + } + return nil } func (c *Controller) closeBashSession(sessionId string) error { @@ -94,25 +92,23 @@ func (c *Controller) closeBashSession(sessionId string) error { return ErrContextNotFound } - c.mu.Lock() - defer c.mu.Unlock() err := session.close() if err != nil { return err } - delete(c.bashSessionClientMap, sessionId) + c.bashSessionClientMap.Delete(sessionId) return nil } +// nolint:unused func (c *Controller) listBashSessions() []string { - c.mu.RLock() - defer c.mu.RUnlock() - - sessions := make([]string, 0, len(c.bashSessionClientMap)) - for sessionID := range c.bashSessionClientMap { + sessions := make([]string, 0) + c.bashSessionClientMap.Range(func(key, _ any) bool { + sessionID, _ := key.(string) sessions = append(sessions, sessionID) - } + return true + }) return sessions } diff --git a/components/execd/pkg/runtime/command_common.go b/components/execd/pkg/runtime/command_common.go index 633efa35b..4f49ebbcb 100644 --- a/components/execd/pkg/runtime/command_common.go +++ b/components/execd/pkg/runtime/command_common.go @@ -45,18 +45,17 @@ func (c *Controller) tailStdPipe(file string, onExecute func(text string), done // getCommandKernel retrieves a command execution context. func (c *Controller) getCommandKernel(sessionID string) *commandKernel { - c.mu.RLock() - defer c.mu.RUnlock() - - return c.commandClientMap[sessionID] + if v, ok := c.commandClientMap.Load(sessionID); ok { + if kernel, ok := v.(*commandKernel); ok { + return kernel + } + } + return nil } // storeCommandKernel registers a command execution context. func (c *Controller) storeCommandKernel(sessionID string, kernel *commandKernel) { - c.mu.Lock() - defer c.mu.Unlock() - - c.commandClientMap[sessionID] = kernel + c.commandClientMap.Store(sessionID, kernel) } // stdLogDescriptor creates temporary files for capturing command output. diff --git a/components/execd/pkg/runtime/command_status.go b/components/execd/pkg/runtime/command_status.go index 97f112b1c..6dbc6d4f2 100644 --- a/components/execd/pkg/runtime/command_status.go +++ b/components/execd/pkg/runtime/command_status.go @@ -40,11 +40,11 @@ type CommandOutput struct { } func (c *Controller) commandSnapshot(session string) *commandKernel { - c.mu.RLock() - defer c.mu.RUnlock() - - kernel, ok := c.commandClientMap[session] - if !ok || kernel == nil { + var kernel *commandKernel + if v, ok := c.commandClientMap.Load(session); ok { + kernel, _ = v.(*commandKernel) + } + if kernel == nil { return nil } @@ -116,8 +116,11 @@ func (c *Controller) markCommandFinished(session string, exitCode int, errMsg st c.mu.Lock() defer c.mu.Unlock() - kernel, ok := c.commandClientMap[session] - if !ok || kernel == nil { + var kernel *commandKernel + if v, ok := c.commandClientMap.Load(session); ok { + kernel, _ = v.(*commandKernel) + } + if kernel == nil { return } diff --git a/components/execd/pkg/runtime/context.go b/components/execd/pkg/runtime/context.go index 0f9abadcd..6e7ea8701 100644 --- a/components/execd/pkg/runtime/context.go +++ b/components/execd/pkg/runtime/context.go @@ -121,15 +121,8 @@ func (c *Controller) deleteSessionAndCleanup(session string) error { return err } - c.mu.Lock() - defer c.mu.Unlock() - - delete(c.jupyterClientMap, session) - for lang, id := range c.defaultLanguageSessions { - if id == session { - delete(c.defaultLanguageSessions, lang) - } - } + c.jupyterClientMap.Delete(session) + c.deleteDefaultSessionByID(session) return nil } @@ -150,6 +143,10 @@ func (c *Controller) newIpynbPath(sessionID, cwd string) (string, error) { // createDefaultLanguageJupyterContext prewarms a session for stateless execution. func (c *Controller) createDefaultLanguageJupyterContext(language Language) error { + if c.getDefaultLanguageSession(language) != "" { + return nil + } + var ( client *jupyter.Client session *jupytersession.Session @@ -169,15 +166,12 @@ func (c *Controller) createDefaultLanguageJupyterContext(language Language) erro return err } - c.mu.Lock() - defer c.mu.Unlock() - - c.defaultLanguageSessions[language] = session.ID - c.jupyterClientMap[session.ID] = &jupyterKernel{ + c.setDefaultLanguageSession(language, session.ID) + c.jupyterClientMap.Store(session.ID, &jupyterKernel{ kernelID: session.Kernel.ID, client: client, language: language, - } + }) return nil } @@ -222,10 +216,7 @@ func (c *Controller) createJupyterContext(request CreateContextRequest) (*jupyte // storeJupyterKernel caches a session -> kernel mapping. func (c *Controller) storeJupyterKernel(sessionID string, kernel *jupyterKernel) { - c.mu.Lock() - defer c.mu.Unlock() - - c.jupyterClientMap[sessionID] = kernel + c.jupyterClientMap.Store(sessionID, kernel) } func (c *Controller) jupyterClient() *jupyter.Client { @@ -241,49 +232,63 @@ func (c *Controller) jupyterClient() *jupyter.Client { jupyter.WithHTTPClient(httpClient)) } -func (c *Controller) listAllContexts() ([]CodeContext, error) { - c.mu.RLock() - defer c.mu.RUnlock() +func (c *Controller) getDefaultLanguageSession(language Language) string { + if v, ok := c.defaultLanguageSessions.Load(language); ok { + if session, ok := v.(string); ok { + return session + } + } + return "" +} + +func (c *Controller) setDefaultLanguageSession(language Language, sessionID string) { + c.defaultLanguageSessions.Store(language, sessionID) +} + +func (c *Controller) deleteDefaultSessionByID(sessionID string) { + c.defaultLanguageSessions.Range(func(key, value any) bool { + if s, ok := value.(string); ok && s == sessionID { + c.defaultLanguageSessions.Delete(key) + } + return true + }) +} +func (c *Controller) listAllContexts() ([]CodeContext, error) { contexts := make([]CodeContext, 0) - for session, kernel := range c.jupyterClientMap { - if kernel != nil { - contexts = append(contexts, CodeContext{ - ID: session, - Language: kernel.language, - }) + c.jupyterClientMap.Range(func(key, value any) bool { + session, _ := key.(string) + if kernel, ok := value.(*jupyterKernel); ok && kernel != nil { + contexts = append(contexts, CodeContext{ID: session, Language: kernel.language}) } - } + return true + }) - for language, defaultContext := range c.defaultLanguageSessions { - contexts = append(contexts, CodeContext{ - ID: defaultContext, - Language: language, - }) - } + c.defaultLanguageSessions.Range(func(key, value any) bool { + lang, _ := key.(Language) + session, _ := value.(string) + if session == "" { + return true + } + contexts = append(contexts, CodeContext{ID: session, Language: lang}) + return true + }) return contexts, nil } func (c *Controller) listLanguageContexts(language Language) ([]CodeContext, error) { - c.mu.RLock() - defer c.mu.RUnlock() - contexts := make([]CodeContext, 0) - for session, kernel := range c.jupyterClientMap { - if kernel != nil && kernel.language == language { - contexts = append(contexts, CodeContext{ - ID: session, - Language: language, - }) + c.jupyterClientMap.Range(func(key, value any) bool { + session, _ := key.(string) + if kernel, ok := value.(*jupyterKernel); ok && kernel != nil && kernel.language == language { + contexts = append(contexts, CodeContext{ID: session, Language: language}) } - } + return true + }) - if defaultContext := c.defaultLanguageSessions[language]; defaultContext != "" { - contexts = append(contexts, CodeContext{ - ID: defaultContext, - Language: language, - }) + if defaultContext := c.getDefaultLanguageSession(language); defaultContext != "" { + contexts = append(contexts, CodeContext{ID: defaultContext, Language: language}) } return contexts, nil diff --git a/components/execd/pkg/runtime/context_test.go b/components/execd/pkg/runtime/context_test.go index 07ee6da35..43efe81c0 100644 --- a/components/execd/pkg/runtime/context_test.go +++ b/components/execd/pkg/runtime/context_test.go @@ -26,8 +26,9 @@ import ( func TestListContextsAndNewIpynbPath(t *testing.T) { c := NewController("http://example", "token") - c.jupyterClientMap["session-python"] = &jupyterKernel{language: Python} - c.defaultLanguageSessions[Go] = "session-go-default" + + c.jupyterClientMap.Store("session-python", &jupyterKernel{language: Python}) + c.setDefaultLanguageSession(Go, "session-go-default") pyContexts, err := c.listLanguageContexts(Python) if err != nil { @@ -128,8 +129,8 @@ func TestDeleteContext_RemovesCacheOnSuccess(t *testing.T) { defer server.Close() c := NewController(server.URL, "token") - c.jupyterClientMap[sessionID] = &jupyterKernel{language: Python} - c.defaultLanguageSessions[Python] = sessionID + c.jupyterClientMap.Store(sessionID, &jupyterKernel{language: Python}) + c.setDefaultLanguageSession(Python, sessionID) if err := c.DeleteContext(sessionID); err != nil { t.Fatalf("DeleteContext returned error: %v", err) @@ -138,7 +139,7 @@ func TestDeleteContext_RemovesCacheOnSuccess(t *testing.T) { if kernel := c.getJupyterKernel(sessionID); kernel != nil { t.Fatalf("expected cache to be cleared, found: %+v", kernel) } - if _, ok := c.defaultLanguageSessions[Python]; ok { + if c.getDefaultLanguageSession(Python) != "" { t.Fatalf("expected default session entry to be removed") } } @@ -166,21 +167,21 @@ func TestDeleteLanguageContext_RemovesCacheOnSuccess(t *testing.T) { defer server.Close() c := NewController(server.URL, "token") - c.jupyterClientMap[session1] = &jupyterKernel{language: lang} - c.jupyterClientMap[session2] = &jupyterKernel{language: lang} - c.defaultLanguageSessions[lang] = session2 + c.jupyterClientMap.Store(session1, &jupyterKernel{language: lang}) + c.jupyterClientMap.Store(session2, &jupyterKernel{language: lang}) + c.setDefaultLanguageSession(lang, session2) if err := c.DeleteLanguageContext(lang); err != nil { t.Fatalf("DeleteLanguageContext returned error: %v", err) } - if _, ok := c.jupyterClientMap[session1]; ok { + if v, ok := c.jupyterClientMap.Load(session1); ok && v != nil { t.Fatalf("expected session1 removed from cache") } - if _, ok := c.jupyterClientMap[session2]; ok { + if v, ok := c.jupyterClientMap.Load(session2); ok && v != nil { t.Fatalf("expected session2 removed from cache") } - if _, ok := c.defaultLanguageSessions[lang]; ok { + if c.getDefaultLanguageSession(lang) != "" { t.Fatalf("expected default entry removed") } if deleteCalls[session1] != 1 || deleteCalls[session2] != 1 { diff --git a/components/execd/pkg/runtime/ctrl.go b/components/execd/pkg/runtime/ctrl.go index 81332afc7..2bb1967be 100644 --- a/components/execd/pkg/runtime/ctrl.go +++ b/components/execd/pkg/runtime/ctrl.go @@ -38,10 +38,10 @@ type Controller struct { baseURL string token string mu sync.RWMutex - jupyterClientMap map[string]*jupyterKernel - defaultLanguageSessions map[Language]string - commandClientMap map[string]*commandKernel - bashSessionClientMap map[string]*bashSession + jupyterClientMap sync.Map // sessionID -> *jupyterKernel + defaultLanguageSessions sync.Map // Language -> sessionID + commandClientMap sync.Map // sessionID -> *commandKernel + bashSessionClientMap sync.Map // sessionID -> *bashSession db *sql.DB dbOnce sync.Once } @@ -72,10 +72,10 @@ func NewController(baseURL, token string) *Controller { baseURL: baseURL, token: token, - jupyterClientMap: make(map[string]*jupyterKernel), - defaultLanguageSessions: make(map[Language]string), - commandClientMap: make(map[string]*commandKernel), - bashSessionClientMap: make(map[string]*bashSession), + jupyterClientMap: sync.Map{}, + defaultLanguageSessions: sync.Map{}, + commandClientMap: sync.Map{}, + bashSessionClientMap: sync.Map{}, } } diff --git a/components/execd/pkg/runtime/jupyter.go b/components/execd/pkg/runtime/jupyter.go index ba53abafd..9ea33b13b 100644 --- a/components/execd/pkg/runtime/jupyter.go +++ b/components/execd/pkg/runtime/jupyter.go @@ -29,9 +29,8 @@ func (c *Controller) runJupyter(ctx context.Context, request *ExecuteCodeRequest return errors.New("language runtime server not configured, please check your image runtime") } if request.Context == "" { - if _, exists := c.defaultLanguageSessions[request.Language]; !exists { - err := c.createDefaultLanguageJupyterContext(request.Language) - if err != nil { + if c.getDefaultLanguageSession(request.Language) == "" { + if err := c.createDefaultLanguageJupyterContext(request.Language); err != nil { return err } } @@ -39,7 +38,7 @@ func (c *Controller) runJupyter(ctx context.Context, request *ExecuteCodeRequest var targetSessionID string if request.Context == "" { - targetSessionID = c.defaultLanguageSessions[request.Language] + targetSessionID = c.getDefaultLanguageSession(request.Language) } else { targetSessionID = request.Context } @@ -135,10 +134,12 @@ func (c *Controller) setWorkingDir(_ *jupyterKernel, _ *CreateContextRequest) er // getJupyterKernel retrieves a kernel connection from the session map. func (c *Controller) getJupyterKernel(sessionID string) *jupyterKernel { - c.mu.RLock() - defer c.mu.RUnlock() - - return c.jupyterClientMap[sessionID] + if v, ok := c.jupyterClientMap.Load(sessionID); ok { + if kernel, ok := v.(*jupyterKernel); ok { + return kernel + } + } + return nil } // searchKernel finds a kernel spec name for the given language. From 31db4acab35fdaaa9393a3921f948a7bba703cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Sun, 18 Jan 2026 11:15:20 +0800 Subject: [PATCH 03/37] feat(tests): add python integration test for bash execution --- .github/workflows/real-e2e.yml | 2 + .../tests/test_code_interpreter_e2e_sync.py | 92 +++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/.github/workflows/real-e2e.yml b/.github/workflows/real-e2e.yml index 3e1c85d3b..9d098b186 100644 --- a/.github/workflows/real-e2e.yml +++ b/.github/workflows/real-e2e.yml @@ -35,6 +35,8 @@ jobs: pip install uv - name: Run tests + env: + OPENSANDBOX_SANDBOX_DEFAULT_IMAGE: opensandbox/code-interpreter:latest run: | set -e diff --git a/tests/python/tests/test_code_interpreter_e2e_sync.py b/tests/python/tests/test_code_interpreter_e2e_sync.py index 95d8f9b7e..9bae9f2ea 100644 --- a/tests/python/tests/test_code_interpreter_e2e_sync.py +++ b/tests/python/tests/test_code_interpreter_e2e_sync.py @@ -893,3 +893,95 @@ def test_09_context_management_endpoints(self): assert len(final_contexts) == 0 logger.info("✓ delete_contexts removed all bash contexts") + @pytest.mark.timeout(300) + @pytest.mark.order(10) + def test_10_bash_env_propagation(self): + """Ensure bash commands share env/vars across sequential executions.""" + TestCodeInterpreterE2ESync._ensure_code_interpreter_created() + code_interpreter = TestCodeInterpreterE2ESync.code_interpreter + assert code_interpreter is not None + + stdout_messages: list[OutputMessage] = [] + stderr_messages: list[OutputMessage] = [] + errors: list[ExecutionError] = [] + completed_events: list[ExecutionComplete] = [] + init_events: list[ExecutionInit] = [] + + def on_stdout(msg: OutputMessage): + stdout_messages.append(msg) + + def on_stderr(msg: OutputMessage): + stderr_messages.append(msg) + + def on_error(err: ExecutionError): + errors.append(err) + + def on_complete(evt: ExecutionComplete): + completed_events.append(evt) + + def on_init(evt: ExecutionInit): + init_events.append(evt) + + handlers = ExecutionHandlersSync( + on_stdout=on_stdout, + on_stderr=on_stderr, + on_result=None, + on_error=on_error, + on_execution_complete=on_complete, + on_init=on_init, + ) + + # Send three sequential commands in the same session, validating env propagation. + code1 = ( + "export FOO=hello\n" + "export BAR=world\n" + ) + code2 = ( + "printf \"step1:$FOO:$BAR\\n\"\n" + ) + code3 = ( + "export FOO=${FOO}_next\n" + "printf \"step2:$FOO:$BAR\\n\"\n" + "export BAR=${BAR}_next\n" + "printf \"step3:$FOO:$BAR\\n\"\n" + ) + + # export envs + result1 = code_interpreter.codes.run( + code1, + language=SupportedLanguage.BASH, + handlers=handlers, + ) + + assert result1 is not None + assert result1.id is not None and str(result1.id).strip() + assert result1.error is None + + # print env + result2 = code_interpreter.codes.run( + code2, + language=SupportedLanguage.BASH, + handlers=handlers, + ) + + assert result2 is not None + assert result2.id is not None and str(result2.id).strip() + assert result2.error is None + + # print env + result3 = code_interpreter.codes.run( + code3, + language=SupportedLanguage.BASH, + handlers=handlers, + ) + assert result3 is not None + assert result3.id is not None and str(result3.id).strip() + assert result3.error is None + + # Expect at least three stdout lines with propagated env values. + stdout_texts = [m.text.strip() for m in stdout_messages if m.text] + assert "step1:hello:world" in stdout_texts + assert "step2:hello_next:world" in stdout_texts + assert "step3:hello_next:world_next" in stdout_texts + for m in stdout_messages[:3]: + _assert_recent_timestamp_ms(m.timestamp) From bb70a0d8ebe3466138ca764a6e151e43aaf10a1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Sun, 18 Jan 2026 11:15:20 +0800 Subject: [PATCH 04/37] feat(tests): add js integration test for bash execution --- .../tests/test_code_interpreter_e2e.test.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/javascript/tests/test_code_interpreter_e2e.test.ts b/tests/javascript/tests/test_code_interpreter_e2e.test.ts index 686fc4076..6ae00a41d 100644 --- a/tests/javascript/tests/test_code_interpreter_e2e.test.ts +++ b/tests/javascript/tests/test_code_interpreter_e2e.test.ts @@ -267,3 +267,58 @@ test("07 interrupt code execution + fake id", async () => { await expect(ci0.codes.interrupt(`fake-${Date.now()}`)).rejects.toBeTruthy(); }); + +test("08 bash env propagation across sequential executions", async () => { + if (!ci) throw new Error("not initialized"); + + const stdout: string[] = []; + const stderr: string[] = []; + const errors: string[] = []; + + const handlers: ExecutionHandlers = { + onStdout: (m) => { + if (m.text) stdout.push(m.text.trim()); + }, + onStderr: (m) => { + if (m.text) stderr.push(m.text.trim()); + }, + onError: (e) => { + errors.push(e.name); + }, + }; + + const code1 = "export FOO=hello\nexport BAR=world\n"; + const code2 = 'printf "step1:$FOO:$BAR\\n"\n'; + const code3 = + "export FOO=${FOO}_next\n" + + 'printf "step2:$FOO:$BAR\\n"\n' + + "export BAR=${BAR}_next\n" + + 'printf "step3:$FOO:$BAR\\n"\n'; + + const r1 = await ci.codes.run(code1, { + language: SupportedLanguages.BASH, + handlers, + }); + expect(r1.id).toBeTruthy(); + expect(r1.error).toBeUndefined(); + + const r2 = await ci.codes.run(code2, { + language: SupportedLanguages.BASH, + handlers, + }); + expect(r2.id).toBeTruthy(); + expect(r2.error).toBeUndefined(); + + const r3 = await ci.codes.run(code3, { + language: SupportedLanguages.BASH, + handlers, + }); + expect(r3.id).toBeTruthy(); + expect(r3.error).toBeUndefined(); + + expect(stdout).toContain("step1:hello:world"); + expect(stdout).toContain("step2:hello_next:world"); + expect(stdout).toContain("step3:hello_next:world_next"); + expect(errors).toHaveLength(0); + expect(stderr.filter((s) => s.length > 0)).toHaveLength(0); +}); From 1e2ed97a76dea5b8a5919397e792397e1a86c543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Sun, 18 Jan 2026 14:28:20 +0800 Subject: [PATCH 05/37] fix(components/execd): reject commands after exit and surface clear session-terminated error --- components/execd/pkg/runtime/bash_session.go | 23 +++++++++++++++----- components/execd/pkg/runtime/types.go | 2 ++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index bde8b7119..8a9acdf04 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -176,6 +176,8 @@ func (s *bashSession) readStdout(r io.Reader) { s.stdoutLines <- strings.TrimRight(line, "\r\n") } if err != nil { + // mark session terminated so subsequent commands can reject early + s.terminated.Store(true) if !errors.Is(err, io.EOF) { s.stdoutErr <- err } @@ -193,6 +195,9 @@ func (s *bashSession) run(command string, timeout time.Duration, hooks *ExecuteR s.mu.Lock() defer s.mu.Unlock() + if s.terminated.Load() { + return errors.New("bash session is terminated (probably by exit); please create a new session") + } if !s.started { return errors.New("session not started") } @@ -203,9 +208,9 @@ func (s *bashSession) run(command string, timeout time.Duration, hooks *ExecuteR hooks.OnExecuteInit(s.config.Session) } - waitSeconds := timeout - if waitSeconds <= 0 { - waitSeconds = 30 * time.Second + wait := timeout + if wait <= 0 { + wait = 3600 * time.Second } cleanCmd := strings.ReplaceAll(command, "\n", " ; ") @@ -213,24 +218,30 @@ func (s *bashSession) run(command string, timeout time.Duration, hooks *ExecuteR // send command + marker cmdText := fmt.Sprintf("%s\nprintf \"%s$?%s\\n\"\n", cleanCmd, exitCodePrefix, exitCodeSuffix) if _, err := fmt.Fprint(s.stdin, cmdText); err != nil { + if errors.Is(err, io.ErrClosedPipe) || strings.Contains(err.Error(), "broken pipe") { + s.terminated.Store(true) + return errors.New("bash session is terminated (probably by exit); please create a new session") + } return fmt.Errorf("write command: %w", err) } // collect output until marker - timer := time.NewTimer(waitSeconds) + timer := time.NewTimer(wait) defer timer.Stop() for { select { case <-timer.C: - return fmt.Errorf("timeout after %s while running command %q", waitSeconds, command) + return fmt.Errorf("timeout after %s while running command %q", wait, command) case err := <-s.stdoutErr: if err != nil { + s.terminated.Store(true) return err } case line, ok := <-s.stdoutLines: if !ok { - return errors.New("stdout closed unexpectedly") + s.terminated.Store(true) + return errors.New("bash session stdout closed (probably by exit); please create a new session") } if _, ok := parseExitCodeLine(line); ok { if hooks != nil && hooks.OnExecuteComplete != nil { diff --git a/components/execd/pkg/runtime/types.go b/components/execd/pkg/runtime/types.go index 5cd5addae..402be1cef 100644 --- a/components/execd/pkg/runtime/types.go +++ b/components/execd/pkg/runtime/types.go @@ -19,6 +19,7 @@ import ( "io" "os/exec" "sync" + "sync/atomic" "time" "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" @@ -112,4 +113,5 @@ type bashSession struct { stdoutErr chan error mu sync.Mutex started bool + terminated atomic.Bool } From 2e9add91eb0848548de76848873024dde34042ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Sun, 18 Jan 2026 15:01:51 +0800 Subject: [PATCH 06/37] fix(components/execd): preserve bash exit status without killing session --- components/execd/pkg/runtime/bash_session.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index 8a9acdf04..1ecaaf5c0 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -215,8 +215,9 @@ func (s *bashSession) run(command string, timeout time.Duration, hooks *ExecuteR cleanCmd := strings.ReplaceAll(command, "\n", " ; ") - // send command + marker - cmdText := fmt.Sprintf("%s\nprintf \"%s$?%s\\n\"\n", cleanCmd, exitCodePrefix, exitCodeSuffix) + // send command + marker, preserving the user's last exit code + // use a subshell at the end to restore $? to the original exit code + cmdText := fmt.Sprintf("%s\n__c=$?\nprintf \"%s${__c}%s\\n\"\n(exit ${__c})\n", cleanCmd, exitCodePrefix, exitCodeSuffix) if _, err := fmt.Fprint(s.stdin, cmdText); err != nil { if errors.Is(err, io.ErrClosedPipe) || strings.Contains(err.Error(), "broken pipe") { s.terminated.Store(true) From 4b20500ddb4974520193d8dafbe3b2cb6294f0cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Sun, 18 Jan 2026 15:05:55 +0800 Subject: [PATCH 07/37] feat(sandboxes/code-interpreter): remove bash jupyter kernel installation --- sandboxes/code-interpreter/Dockerfile | 2 +- sandboxes/code-interpreter/README.md | 1 - sandboxes/code-interpreter/README_zh.md | 1 - sandboxes/code-interpreter/scripts/code-interpreter.sh | 8 -------- 4 files changed, 1 insertion(+), 11 deletions(-) diff --git a/sandboxes/code-interpreter/Dockerfile b/sandboxes/code-interpreter/Dockerfile index 2a1aa0135..280f1b301 100644 --- a/sandboxes/code-interpreter/Dockerfile +++ b/sandboxes/code-interpreter/Dockerfile @@ -24,7 +24,7 @@ RUN set -euo pipefail \ echo "Setting up ipykernel for Python $version" \ && . /opt/opensandbox/code-interpreter-env.sh python $version \ && python3 --version \ - && python3 -m pip install ipykernel jupyter bash_kernel --break-system-packages; \ + && python3 -m pip install ipykernel jupyter --break-system-packages; \ done \ && echo "Setting up ipykernel complete" diff --git a/sandboxes/code-interpreter/README.md b/sandboxes/code-interpreter/README.md index 2b7943a20..b17072e1e 100644 --- a/sandboxes/code-interpreter/README.md +++ b/sandboxes/code-interpreter/README.md @@ -144,7 +144,6 @@ The image comes with pre-configured Jupyter kernels for all supported languages: - **Java**: IJava kernel - **TypeScript/JavaScript**: tslab kernel - **Go**: gonb kernel -- **Bash**: bash_kernel ### Starting Jupyter diff --git a/sandboxes/code-interpreter/README_zh.md b/sandboxes/code-interpreter/README_zh.md index e68b2584c..9a9bfacbd 100644 --- a/sandboxes/code-interpreter/README_zh.md +++ b/sandboxes/code-interpreter/README_zh.md @@ -142,7 +142,6 @@ source /opt/opensandbox/code-interpreter-env.sh go - **Java**:IJava 内核 - **TypeScript/JavaScript**:tslab 内核 - **Go**:gonb 内核 -- **Bash**:bash_kernel ### 启动 Jupyter diff --git a/sandboxes/code-interpreter/scripts/code-interpreter.sh b/sandboxes/code-interpreter/scripts/code-interpreter.sh index 11968c1df..6d03691cf 100755 --- a/sandboxes/code-interpreter/scripts/code-interpreter.sh +++ b/sandboxes/code-interpreter/scripts/code-interpreter.sh @@ -93,12 +93,6 @@ setup_go() { } } -setup_bash() { - time { - python3 -m bash_kernel.install - } -} - # export go bin path export PATH="$(go env GOPATH)/bin:$PATH" if [ -n "${EXECD_ENVS:-}" ]; then @@ -114,7 +108,5 @@ setup_node & pids+=($!) setup_go & pids+=($!) -setup_bash & -pids+=($!) jupyter notebook --ip=127.0.0.1 --port="${JUPYTER_PORT:-44771}" --allow-root --no-browser --NotebookApp.token="${JUPYTER_TOKEN:-opensandboxcodeinterpreterjupyter}" >/opt/opensandbox/jupyter.log From d8909daf8cd350a1d7181503dbf855739e507603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Mon, 19 Jan 2026 10:33:47 +0800 Subject: [PATCH 08/37] fix(sandboxes/code-interpreter): fix stderr discard error --- components/execd/pkg/runtime/bash_session.go | 17 +++++------------ components/execd/pkg/runtime/types.go | 1 - 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index 1ecaaf5c0..74fe815e9 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -123,7 +123,7 @@ func newBashSession(config *bashSessionConfig) *bashSession { } return &bashSession{ config: config, - stdoutLines: make(chan string, 256), + stdoutLines: make(chan string, 1024), stdoutErr: make(chan error, 1), } } @@ -147,10 +147,9 @@ func (s *bashSession) start() error { if err != nil { return fmt.Errorf("stdout pipe: %w", err) } - stderr, err := cmd.StderrPipe() - if err != nil { - return fmt.Errorf("stderr pipe: %w", err) - } + + // merge stderr into stdout + cmd.Stderr = cmd.Stdout if err := cmd.Start(); err != nil { return fmt.Errorf("start bash: %w", err) @@ -159,12 +158,10 @@ func (s *bashSession) start() error { s.cmd = cmd s.stdin = stdin s.stdout = stdout - s.stderr = stderr s.started = true // drain stdout/stderr into channel go s.readStdout(stdout) - go s.discardStderr(stderr) return nil } @@ -187,10 +184,6 @@ func (s *bashSession) readStdout(r io.Reader) { } } -func (s *bashSession) discardStderr(r io.Reader) { - _, _ = io.Copy(io.Discard, r) -} - func (s *bashSession) run(command string, timeout time.Duration, hooks *ExecuteResultHook) error { s.mu.Lock() defer s.mu.Unlock() @@ -210,7 +203,7 @@ func (s *bashSession) run(command string, timeout time.Duration, hooks *ExecuteR wait := timeout if wait <= 0 { - wait = 3600 * time.Second + wait = 24 * 3600 * time.Second // default to 24 hours } cleanCmd := strings.ReplaceAll(command, "\n", " ; ") diff --git a/components/execd/pkg/runtime/types.go b/components/execd/pkg/runtime/types.go index 402be1cef..ad6f620ea 100644 --- a/components/execd/pkg/runtime/types.go +++ b/components/execd/pkg/runtime/types.go @@ -108,7 +108,6 @@ type bashSession struct { cmd *exec.Cmd stdin io.WriteCloser stdout io.ReadCloser - stderr io.ReadCloser stdoutLines chan string stdoutErr chan error mu sync.Mutex From d28a674de2f9e6e57d087b833224f81402f4cc99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Mon, 19 Jan 2026 10:35:13 +0800 Subject: [PATCH 09/37] fix(sandboxes/code-interpreter): fix windows bash session start statement --- components/execd/pkg/runtime/bash_session_windows.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/execd/pkg/runtime/bash_session_windows.go b/components/execd/pkg/runtime/bash_session_windows.go index 8b65db812..9b1bac42c 100644 --- a/components/execd/pkg/runtime/bash_session_windows.go +++ b/components/execd/pkg/runtime/bash_session_windows.go @@ -54,8 +54,8 @@ func newBashSession(config *bashSessionConfig) *bashSession { return &bashSession{config: config} } -func (s *bashSession) start() (string, error) { - return "", errBashSessionNotSupported +func (s *bashSession) start() error { + return errBashSessionNotSupported } func (s *bashSession) run(_ string, _ time.Duration, _ *ExecuteResultHook) error { From 2c9af4c60d2fbe427a7bfdb444c476650d66fb0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Mon, 19 Jan 2026 11:52:42 +0800 Subject: [PATCH 10/37] fix(tests): remove bash context management test --- tests/python/tests/test_code_interpreter_e2e.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/python/tests/test_code_interpreter_e2e.py b/tests/python/tests/test_code_interpreter_e2e.py index 9a2548dea..88075f79e 100644 --- a/tests/python/tests/test_code_interpreter_e2e.py +++ b/tests/python/tests/test_code_interpreter_e2e.py @@ -998,12 +998,12 @@ async def test_09_context_management_endpoints(self): code_interpreter = TestCodeInterpreterE2E.code_interpreter assert code_interpreter is not None - language = SupportedLanguage.BASH + language = SupportedLanguage.PYTHON logger.info("=" * 80) logger.info("TEST 9: Context management endpoints (%s)", language) logger.info("=" * 80) - # Ensure clean slate for bash contexts to avoid interference with other tests. + # Ensure clean slate for python contexts to avoid interference with other tests. await code_interpreter.codes.delete_contexts(language) ctx1 = await code_interpreter.codes.create_context(language) @@ -1012,14 +1012,14 @@ async def test_09_context_management_endpoints(self): assert ctx2.id is not None and ctx2.id.strip() assert ctx1.language == language assert ctx2.language == language - logger.info("✓ Created two bash contexts: %s, %s", ctx1.id, ctx2.id) + logger.info("✓ Created two python contexts: %s, %s", ctx1.id, ctx2.id) listed = await code_interpreter.codes.list_contexts(language) - bash_context_ids = {c.id for c in listed if c.id} - assert ctx1.id in bash_context_ids - assert ctx2.id in bash_context_ids + python_context_ids = {c.id for c in listed if c.id} + assert ctx1.id in python_context_ids + assert ctx2.id in python_context_ids assert all(c.language == language for c in listed) - logger.info("✓ list_contexts returned expected bash contexts") + logger.info("✓ list_contexts returned expected python contexts") fetched = await code_interpreter.codes.get_context(ctx1.id) assert fetched.id == ctx1.id @@ -1038,5 +1038,5 @@ async def test_09_context_management_endpoints(self): c for c in await code_interpreter.codes.list_contexts(language) if c.id ] assert len(final_contexts) == 0 - logger.info("✓ delete_contexts removed all bash contexts") + logger.info("✓ delete_contexts removed all python contexts") From 4d5372ac5cb03f533cd113b615f95ca8dd6409c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Thu, 29 Jan 2026 20:06:42 +0800 Subject: [PATCH 11/37] fix(components/execd): keep bash session newlines to support heredoc scripts --- components/execd/pkg/runtime/bash_session.go | 8 +- .../execd/pkg/runtime/bash_session_test.go | 100 ++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index 74fe815e9..b04da7c90 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -184,6 +184,7 @@ func (s *bashSession) readStdout(r io.Reader) { } } +//nolint:gocognit func (s *bashSession) run(command string, timeout time.Duration, hooks *ExecuteResultHook) error { s.mu.Lock() defer s.mu.Unlock() @@ -206,11 +207,14 @@ func (s *bashSession) run(command string, timeout time.Duration, hooks *ExecuteR wait = 24 * 3600 * time.Second // default to 24 hours } - cleanCmd := strings.ReplaceAll(command, "\n", " ; ") + cmdBody := command + if !strings.HasSuffix(cmdBody, "\n") { + cmdBody += "\n" + } // send command + marker, preserving the user's last exit code // use a subshell at the end to restore $? to the original exit code - cmdText := fmt.Sprintf("%s\n__c=$?\nprintf \"%s${__c}%s\\n\"\n(exit ${__c})\n", cleanCmd, exitCodePrefix, exitCodeSuffix) + cmdText := fmt.Sprintf("%s__c=$?\nprintf \"%s${__c}%s\\n\"\n(exit ${__c})\n", cmdBody, exitCodePrefix, exitCodeSuffix) if _, err := fmt.Fprint(s.stdin, cmdText); err != nil { if errors.Is(err, io.ErrClosedPipe) || strings.Contains(err.Error(), "broken pipe") { s.terminated.Store(true) diff --git a/components/execd/pkg/runtime/bash_session_test.go b/components/execd/pkg/runtime/bash_session_test.go index ac66ca0a3..6529f950a 100644 --- a/components/execd/pkg/runtime/bash_session_test.go +++ b/components/execd/pkg/runtime/bash_session_test.go @@ -18,6 +18,8 @@ package runtime import ( + "fmt" + "os" "strings" "testing" "time" @@ -173,6 +175,104 @@ func TestBashSessionEnvLargeOutputChained(t *testing.T) { } } +func TestBashSession_heredoc(t *testing.T) { + rewardDir := t.TempDir() + controller := NewController("", "") + + hooks := ExecuteResultHook{ + OnExecuteStdout: func(line string) { + fmt.Printf("[stdout] %s\n", line) + }, + OnExecuteComplete: func(d time.Duration) { + fmt.Printf("[complete] %s\n", d) + }, + } + + // First run: heredoc + reward file write. + script := fmt.Sprintf(` +set -x +reward_dir=%q +mkdir -p "$reward_dir" + +cat > /tmp/repro_script.sh <<'SHEOF' +#!/usr/bin/env sh +echo "hello heredoc" +SHEOF + +chmod +x /tmp/repro_script.sh +/tmp/repro_script.sh +echo "after heredoc" +echo 1 > "$reward_dir/reward.txt" +cat "$reward_dir/reward.txt" +`, rewardDir) + + if err := controller.Execute(&ExecuteCodeRequest{ + Language: Bash, + Timeout: 10 * time.Second, + Code: script, + Hooks: hooks, + }); err != nil { + fmt.Fprintf(os.Stderr, "first Execute failed: %v\n", err) + os.Exit(1) + } + + // Second run: ensure the session keeps working. + if err := controller.Execute(&ExecuteCodeRequest{ + Language: Bash, + Timeout: 5 * time.Second, + Code: "echo 'second command works'", + Hooks: hooks, + }); err != nil { + fmt.Fprintf(os.Stderr, "second Execute failed: %v\n", err) + os.Exit(1) + } +} + +func TestBashSession_execReplacesShell(t *testing.T) { + session := newBashSession(nil) + t.Cleanup(func() { _ = session.close() }) + + if err := session.start(); err != nil { + t.Fatalf("Start() error = %v", err) + } + + var stdoutLines []string + hooks := ExecuteResultHook{ + OnExecuteStdout: func(line string) { + stdoutLines = append(stdoutLines, line) + }, + } + + script := ` +cat > /tmp/exec_child.sh <<'EOF' +echo "child says hi" +EOF +chmod +x /tmp/exec_child.sh +exec /tmp/exec_child.sh +` + + err := session.run(script, 5*time.Second, &hooks) + if err == nil { + t.Fatalf("expected error because exec replaces the shell, got nil") + } + if !strings.Contains(err.Error(), "stdout closed") && !strings.Contains(err.Error(), "terminated") { + t.Fatalf("unexpected error for exec: %v", err) + } + if !containsLine(stdoutLines, "child says hi") { + t.Fatalf("expected child output, got %v", stdoutLines) + } + if !session.terminated.Load() { + t.Fatalf("expected session to be marked terminated after exec") + } + + // Subsequent run should fail immediately because the shell was replaced. + if err := session.run("echo still-alive", 2*time.Second, &hooks); err == nil { + t.Fatalf("expected run to fail after exec replaced the shell") + } else if !strings.Contains(err.Error(), "terminated") { + t.Fatalf("expected terminated error, got %v", err) + } +} + func containsLine(lines []string, target string) bool { for _, l := range lines { if strings.TrimSpace(l) == target { From ebb9b4ba52a13721548c4fb537b1d8a40c8ad54f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Fri, 30 Jan 2026 18:08:20 +0800 Subject: [PATCH 12/37] fix(components/execd): fix exec issue --- components/execd/pkg/runtime/bash_session.go | 351 +++++++++++++----- .../execd/pkg/runtime/bash_session_test.go | 65 +++- components/execd/pkg/runtime/types.go | 17 +- 3 files changed, 309 insertions(+), 124 deletions(-) diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index b04da7c90..f9de9e8d3 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -25,6 +25,7 @@ import ( "io" "os" "os/exec" + "sort" "strconv" "strings" "time" @@ -34,6 +35,13 @@ import ( "github.com/alibaba/opensandbox/execd/pkg/log" ) +const ( + envDumpStartMarker = "__ENV_DUMP_START__" + envDumpEndMarker = "__ENV_DUMP_END__" + exitMarkerPrefix = "__EXIT_CODE__:" + pwdMarkerPrefix = "__PWD__:" +) + func (c *Controller) createBashSession(_ *CreateContextRequest) (string, error) { session := newBashSession(nil) if err := session.start(); err != nil { @@ -121,10 +129,17 @@ func newBashSession(config *bashSessionConfig) *bashSession { StartupTimeout: 5 * time.Second, } } + + env := make(map[string]string) + for _, kv := range os.Environ() { + if k, v, ok := splitEnvPair(kv); ok { + env[k] = v + } + } + return &bashSession{ - config: config, - stdoutLines: make(chan string, 1024), - stdoutErr: make(chan error, 1), + config: config, + env: env, } } @@ -136,136 +151,277 @@ func (s *bashSession) start() error { return errors.New("session already started") } - cmd := exec.Command("bash", "--noprofile", "--norc", "-s") - cmd.Env = os.Environ() + s.started = true + return nil +} - stdin, err := cmd.StdinPipe() - if err != nil { - return fmt.Errorf("stdin pipe: %w", err) +//nolint:gocognit +func (s *bashSession) run(command string, timeout time.Duration, hooks *ExecuteResultHook) error { + s.mu.Lock() + if !s.started { + s.mu.Unlock() + return errors.New("session not started") + } + + envSnapshot := copyEnvMap(s.env) + cwd := s.cwd + sessionID := s.config.Session + s.mu.Unlock() + + startAt := time.Now() + if hooks != nil && hooks.OnExecuteInit != nil { + hooks.OnExecuteInit(sessionID) } + + wait := timeout + if wait <= 0 { + wait = 24 * 3600 * time.Second // default to 24 hours + } + + ctx, cancel := context.WithTimeout(context.Background(), wait) + defer cancel() + + cmd := exec.CommandContext(ctx, "bash", "--noprofile", "--norc", "-s") + cmd.Env = envMapToSlice(envSnapshot) + stdout, err := cmd.StdoutPipe() if err != nil { return fmt.Errorf("stdout pipe: %w", err) } - - // merge stderr into stdout cmd.Stderr = cmd.Stdout + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("stdin pipe: %w", err) + } + if err := cmd.Start(); err != nil { return fmt.Errorf("start bash: %w", err) } - s.cmd = cmd - s.stdin = stdin - s.stdout = stdout - s.started = true + script := buildWrappedScript(command, envSnapshot, cwd) + if _, err := io.WriteString(stdin, script); err != nil { + _ = stdin.Close() + _ = cmd.Wait() + return fmt.Errorf("write command: %w", err) + } + _ = stdin.Close() + + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + + var ( + envLines []string + pwdLine string + exitCode *int + inEnv bool + ) + + for scanner.Scan() { + line := scanner.Text() + switch { + case line == envDumpStartMarker: + inEnv = true + case line == envDumpEndMarker: + inEnv = false + case strings.HasPrefix(line, exitMarkerPrefix): + if code, err := strconv.Atoi(strings.TrimPrefix(line, exitMarkerPrefix)); err == nil { + exitCode = &code + } + case strings.HasPrefix(line, pwdMarkerPrefix): + pwdLine = strings.TrimPrefix(line, pwdMarkerPrefix) + default: + if inEnv { + envLines = append(envLines, line) + continue + } + if hooks != nil && hooks.OnExecuteStdout != nil { + hooks.OnExecuteStdout(line) + } + } + } + + scanErr := scanner.Err() + waitErr := cmd.Wait() + + if scanErr != nil { + return fmt.Errorf("read stdout: %w", scanErr) + } + + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("timeout after %s while running command %q", wait, command) + } + + if exitCode == nil && cmd.ProcessState != nil { + code := cmd.ProcessState.ExitCode() + exitCode = &code + } + + updatedEnv := parseExportDump(envLines) + s.mu.Lock() + if len(updatedEnv) > 0 { + s.env = updatedEnv + } + if pwdLine != "" { + s.cwd = pwdLine + } + s.mu.Unlock() + + if hooks != nil && hooks.OnExecuteComplete != nil { + hooks.OnExecuteComplete(time.Since(startAt)) + } + + // Maintain previous behavior: non-zero exit codes do not surface as errors. + var exitErr *exec.ExitError + if waitErr != nil && !errors.As(waitErr, &exitErr) { + return waitErr + } - // drain stdout/stderr into channel - go s.readStdout(stdout) return nil } -func (s *bashSession) readStdout(r io.Reader) { - reader := bufio.NewReader(r) - for { - line, err := reader.ReadString('\n') - if len(line) > 0 { - s.stdoutLines <- strings.TrimRight(line, "\r\n") - } - if err != nil { - // mark session terminated so subsequent commands can reject early - s.terminated.Store(true) - if !errors.Is(err, io.EOF) { - s.stdoutErr <- err - } - close(s.stdoutLines) - return +func buildWrappedScript(command string, env map[string]string, cwd string) string { + var b strings.Builder + + keys := make([]string, 0, len(env)) + for k := range env { + if isValidEnvKey(k) { + keys = append(keys, k) } } -} - -//nolint:gocognit -func (s *bashSession) run(command string, timeout time.Duration, hooks *ExecuteResultHook) error { - s.mu.Lock() - defer s.mu.Unlock() + sort.Strings(keys) + for _, k := range keys { + b.WriteString("export ") + b.WriteString(k) + b.WriteString("=") + b.WriteString(shellEscape(env[k])) + b.WriteString("\n") + } - if s.terminated.Load() { - return errors.New("bash session is terminated (probably by exit); please create a new session") + if cwd != "" { + b.WriteString("cd ") + b.WriteString(shellEscape(cwd)) + b.WriteString("\n") } - if !s.started { - return errors.New("session not started") + + b.WriteString(command) + if !strings.HasSuffix(command, "\n") { + b.WriteString("\n") } - startAt := time.Now() + b.WriteString("__USER_EXIT_CODE__=$?\n") + b.WriteString("echo \"" + envDumpStartMarker + "\"\n") + b.WriteString("export -p\n") + b.WriteString("echo \"" + envDumpEndMarker + "\"\n") + b.WriteString("printf \"" + pwdMarkerPrefix + "%s\\n\" \"$(pwd)\"\n") + b.WriteString("printf \"" + exitMarkerPrefix + "%s\\n\" \"$__USER_EXIT_CODE__\"\n") + b.WriteString("exit \"$__USER_EXIT_CODE__\"\n") - if hooks != nil && hooks.OnExecuteInit != nil { - hooks.OnExecuteInit(s.config.Session) + return b.String() +} + +func parseExportDump(lines []string) map[string]string { + if len(lines) == 0 { + return nil } - wait := timeout - if wait <= 0 { - wait = 24 * 3600 * time.Second // default to 24 hours + env := make(map[string]string, len(lines)) + for _, line := range lines { + if k, v, ok := parseExportLine(line); ok { + env[k] = v + } } + return env +} - cmdBody := command - if !strings.HasSuffix(cmdBody, "\n") { - cmdBody += "\n" +func parseExportLine(line string) (string, string, bool) { + const prefix = "declare -x " + if !strings.HasPrefix(line, prefix) { + return "", "", false } - // send command + marker, preserving the user's last exit code - // use a subshell at the end to restore $? to the original exit code - cmdText := fmt.Sprintf("%s__c=$?\nprintf \"%s${__c}%s\\n\"\n(exit ${__c})\n", cmdBody, exitCodePrefix, exitCodeSuffix) - if _, err := fmt.Fprint(s.stdin, cmdText); err != nil { - if errors.Is(err, io.ErrClosedPipe) || strings.Contains(err.Error(), "broken pipe") { - s.terminated.Store(true) - return errors.New("bash session is terminated (probably by exit); please create a new session") + rest := strings.TrimSpace(strings.TrimPrefix(line, prefix)) + if rest == "" { + return "", "", false + } + + name := rest + value := "" + if eq := strings.Index(rest, "="); eq >= 0 { + name = rest[:eq] + raw := rest[eq+1:] + unquoted, err := strconv.Unquote(raw) + if err != nil { + value = strings.Trim(raw, `"`) + } else { + value = unquoted } - return fmt.Errorf("write command: %w", err) } - // collect output until marker - timer := time.NewTimer(wait) - defer timer.Stop() + if !isValidEnvKey(name) { + return "", "", false + } - for { - select { - case <-timer.C: - return fmt.Errorf("timeout after %s while running command %q", wait, command) - case err := <-s.stdoutErr: - if err != nil { - s.terminated.Store(true) - return err - } - case line, ok := <-s.stdoutLines: - if !ok { - s.terminated.Store(true) - return errors.New("bash session stdout closed (probably by exit); please create a new session") - } - if _, ok := parseExitCodeLine(line); ok { - if hooks != nil && hooks.OnExecuteComplete != nil { - hooks.OnExecuteComplete(time.Since(startAt)) - } - return nil - } - if hooks != nil && hooks.OnExecuteStdout != nil { - hooks.OnExecuteStdout(line) + return name, value, true +} + +func shellEscape(value string) string { + return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" +} + +func isValidEnvKey(key string) bool { + if key == "" { + return false + } + + for i, r := range key { + if i == 0 { + if (r < 'A' || (r > 'Z' && r < 'a') || r > 'z') && r != '_' { + return false } + continue + } + if (r < 'A' || (r > 'Z' && r < 'a') || r > 'z') && (r < '0' || r > '9') && r != '_' { + return false } } + + return true } -func parseExitCodeLine(line string) (int, bool) { - p := strings.Index(line, exitCodePrefix) - q := strings.Index(line, exitCodeSuffix) - if p < 0 || q <= p { - return 0, false +func copyEnvMap(src map[string]string) map[string]string { + if src == nil { + return map[string]string{} } - text := strings.TrimSpace(line[p+len(exitCodePrefix) : q]) - code, err := strconv.Atoi(text) - if err != nil { - return 0, false + + dst := make(map[string]string, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func envMapToSlice(env map[string]string) []string { + if len(env) == 0 { + return os.Environ() + } + + out := make([]string, 0, len(env)) + for k, v := range env { + out = append(out, fmt.Sprintf("%s=%s", k, v)) } - return code, true + return out +} + +func splitEnvPair(kv string) (string, string, bool) { + parts := strings.SplitN(kv, "=", 2) + if len(parts) != 2 { + return "", "", false + } + if !isValidEnvKey(parts[0]) { + return "", "", false + } + return parts[0], parts[1], true } func (s *bashSession) close() error { @@ -276,13 +432,8 @@ func (s *bashSession) close() error { return nil } s.started = false - - if s.stdin != nil { - _ = s.stdin.Close() - } - if s.cmd != nil && s.cmd.Process != nil { - _ = s.cmd.Process.Kill() - } + s.env = nil + s.cwd = "" return nil } diff --git a/components/execd/pkg/runtime/bash_session_test.go b/components/execd/pkg/runtime/bash_session_test.go index 6529f950a..91265ea63 100644 --- a/components/execd/pkg/runtime/bash_session_test.go +++ b/components/execd/pkg/runtime/bash_session_test.go @@ -252,24 +252,65 @@ exec /tmp/exec_child.sh ` err := session.run(script, 5*time.Second, &hooks) - if err == nil { - t.Fatalf("expected error because exec replaces the shell, got nil") - } - if !strings.Contains(err.Error(), "stdout closed") && !strings.Contains(err.Error(), "terminated") { - t.Fatalf("unexpected error for exec: %v", err) + if err != nil { + t.Fatalf("expected exec to complete without killing the session, got %v", err) } if !containsLine(stdoutLines, "child says hi") { t.Fatalf("expected child output, got %v", stdoutLines) } - if !session.terminated.Load() { - t.Fatalf("expected session to be marked terminated after exec") + + // Subsequent run should still work because we restart bash per run. + stdoutLines = nil + if err := session.run("echo still-alive", 2*time.Second, &hooks); err != nil { + t.Fatalf("expected run to succeed after exec replaced the shell, got %v", err) + } + if !containsLine(stdoutLines, "still-alive") { + t.Fatalf("expected follow-up output, got %v", stdoutLines) + } +} + +func TestBashSession_complexExec(t *testing.T) { + session := newBashSession(nil) + t.Cleanup(func() { _ = session.close() }) + + if err := session.start(); err != nil { + t.Fatalf("Start() error = %v", err) + } + + var stdoutLines []string + hooks := ExecuteResultHook{ + OnExecuteStdout: func(line string) { + stdoutLines = append(stdoutLines, line) + }, + } + + script := ` +LOG_FILE=$(mktemp) +export LOG_FILE +exec 3>&1 4>&2 +exec > >(tee "$LOG_FILE") 2>&1 + +set -x +echo "from-complex-exec" +exec 1>&3 2>&4 # step record +echo "after-restore" +` + + err := session.run(script, 5*time.Second, &hooks) + if err != nil { + t.Fatalf("expected complex exec to finish, got %v", err) + } + if !containsLine(stdoutLines, "from-complex-exec") || !containsLine(stdoutLines, "after-restore") { + t.Fatalf("expected exec outputs, got %v", stdoutLines) } - // Subsequent run should fail immediately because the shell was replaced. - if err := session.run("echo still-alive", 2*time.Second, &hooks); err == nil { - t.Fatalf("expected run to fail after exec replaced the shell") - } else if !strings.Contains(err.Error(), "terminated") { - t.Fatalf("expected terminated error, got %v", err) + // Session should still be usable. + stdoutLines = nil + if err := session.run("echo still-alive", 2*time.Second, &hooks); err != nil { + t.Fatalf("expected run to succeed after complex exec, got %v", err) + } + if !containsLine(stdoutLines, "still-alive") { + t.Fatalf("expected follow-up output, got %v", stdoutLines) } } diff --git a/components/execd/pkg/runtime/types.go b/components/execd/pkg/runtime/types.go index ad6f620ea..cc138fe64 100644 --- a/components/execd/pkg/runtime/types.go +++ b/components/execd/pkg/runtime/types.go @@ -16,10 +16,7 @@ package runtime import ( "fmt" - "io" - "os/exec" "sync" - "sync/atomic" "time" "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" @@ -104,13 +101,9 @@ const ( // bashSession represents a bash session. type bashSession struct { - config *bashSessionConfig - cmd *exec.Cmd - stdin io.WriteCloser - stdout io.ReadCloser - stdoutLines chan string - stdoutErr chan error - mu sync.Mutex - started bool - terminated atomic.Bool + config *bashSessionConfig + mu sync.Mutex + started bool + env map[string]string + cwd string } From 1e7f2fa31da54a0747e4188ebf5e8576d8b01035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Thu, 5 Feb 2026 10:20:31 +0800 Subject: [PATCH 13/37] feat(components/execd): override session's cwd if request.cwd is not empty --- components/execd/pkg/runtime/bash_session.go | 31 ++-- .../execd/pkg/runtime/bash_session_test.go | 161 +++++++++++++++++- 2 files changed, 171 insertions(+), 21 deletions(-) diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index f9de9e8d3..b76094e4d 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -72,7 +72,7 @@ func (c *Controller) runBashSession(_ context.Context, request *ExecuteCodeReque return ErrContextNotFound } - return session.run(request.Code, request.Timeout, &request.Hooks) + return session.run(request) } func (c *Controller) createDefaultBashSession() error { @@ -156,7 +156,7 @@ func (s *bashSession) start() error { } //nolint:gocognit -func (s *bashSession) run(command string, timeout time.Duration, hooks *ExecuteResultHook) error { +func (s *bashSession) run(request *ExecuteCodeRequest) error { s.mu.Lock() if !s.started { s.mu.Unlock() @@ -164,18 +164,23 @@ func (s *bashSession) run(command string, timeout time.Duration, hooks *ExecuteR } envSnapshot := copyEnvMap(s.env) + cwd := s.cwd + // override original cwd if specified + if request.Cwd != "" { + cwd = request.Cwd + } sessionID := s.config.Session s.mu.Unlock() startAt := time.Now() - if hooks != nil && hooks.OnExecuteInit != nil { - hooks.OnExecuteInit(sessionID) + if request.Hooks.OnExecuteInit != nil { + request.Hooks.OnExecuteInit(sessionID) } - wait := timeout + wait := request.Timeout if wait <= 0 { - wait = 24 * 3600 * time.Second // default to 24 hours + wait = 24 * 3600 * time.Second // max to 24 hours } ctx, cancel := context.WithTimeout(context.Background(), wait) @@ -199,7 +204,7 @@ func (s *bashSession) run(command string, timeout time.Duration, hooks *ExecuteR return fmt.Errorf("start bash: %w", err) } - script := buildWrappedScript(command, envSnapshot, cwd) + script := buildWrappedScript(request.Code, envSnapshot, cwd) if _, err := io.WriteString(stdin, script); err != nil { _ = stdin.Close() _ = cmd.Wait() @@ -235,8 +240,8 @@ func (s *bashSession) run(command string, timeout time.Duration, hooks *ExecuteR envLines = append(envLines, line) continue } - if hooks != nil && hooks.OnExecuteStdout != nil { - hooks.OnExecuteStdout(line) + if request.Hooks.OnExecuteStdout != nil { + request.Hooks.OnExecuteStdout(line) } } } @@ -248,8 +253,8 @@ func (s *bashSession) run(command string, timeout time.Duration, hooks *ExecuteR return fmt.Errorf("read stdout: %w", scanErr) } - if ctx.Err() == context.DeadlineExceeded { - return fmt.Errorf("timeout after %s while running command %q", wait, command) + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return fmt.Errorf("timeout after %s while running command %q", wait, request.Code) } if exitCode == nil && cmd.ProcessState != nil { @@ -267,8 +272,8 @@ func (s *bashSession) run(command string, timeout time.Duration, hooks *ExecuteR } s.mu.Unlock() - if hooks != nil && hooks.OnExecuteComplete != nil { - hooks.OnExecuteComplete(time.Since(startAt)) + if request.Hooks.OnExecuteComplete != nil { + request.Hooks.OnExecuteComplete(time.Since(startAt)) } // Maintain previous behavior: non-zero exit codes do not surface as errors. diff --git a/components/execd/pkg/runtime/bash_session_test.go b/components/execd/pkg/runtime/bash_session_test.go index 91265ea63..762ce82b1 100644 --- a/components/execd/pkg/runtime/bash_session_test.go +++ b/components/execd/pkg/runtime/bash_session_test.go @@ -56,13 +56,23 @@ func TestBashSessionEnvAndExitCode(t *testing.T) { } // 1) export an env var - if err := session.run("export FOO=hello", 3*time.Second, &hooks); err != nil { + request := &ExecuteCodeRequest{ + Code: "export FOO=hello", + Hooks: hooks, + Timeout: 3 * time.Second, + } + if err := session.run(request); err != nil { t.Fatalf("runCommand(export) error = %v", err) } exportStdoutCount := len(stdoutLines) // 2) verify env is persisted - if err := session.run("echo $FOO", 3*time.Second, &hooks); err != nil { + request = &ExecuteCodeRequest{ + Code: "echo $FOO", + Hooks: hooks, + Timeout: 3 * time.Second, + } + if err := session.run(request); err != nil { t.Fatalf("runCommand(echo) error = %v", err) } echoLines := stdoutLines[exportStdoutCount:] @@ -78,8 +88,13 @@ func TestBashSessionEnvAndExitCode(t *testing.T) { } // 3) ensure exit code of previous command is reflected in shell state + request = &ExecuteCodeRequest{ + Code: "false; echo EXIT:$?", + Hooks: hooks, + Timeout: 3 * time.Second, + } prevCount := len(stdoutLines) - if err := session.run("false; echo EXIT:$?", 3*time.Second, &hooks); err != nil { + if err := session.run(request); err != nil { t.Fatalf("runCommand(exitcode) error = %v", err) } exitLines := stdoutLines[prevCount:] @@ -134,7 +149,12 @@ func TestBashSessionEnvLargeOutputChained(t *testing.T) { runAndCollect := func(cmd string) []string { start := len(stdoutLines) - if err := session.run(cmd, 10*time.Second, &hooks); err != nil { + request := &ExecuteCodeRequest{ + Code: cmd, + Hooks: hooks, + Timeout: 10 * time.Second, + } + if err := session.run(request); err != nil { t.Fatalf("runCommand(%q) error = %v", cmd, err) } return append([]string(nil), stdoutLines[start:]...) @@ -175,6 +195,111 @@ func TestBashSessionEnvLargeOutputChained(t *testing.T) { } } +func TestBashSessionCwdPersistsWithoutOverride(t *testing.T) { + session := newBashSession(nil) + t.Cleanup(func() { _ = session.close() }) + + if err := session.start(); err != nil { + t.Fatalf("Start() error = %v", err) + } + + targetDir := t.TempDir() + var stdoutLines []string + hooks := ExecuteResultHook{ + OnExecuteStdout: func(line string) { + stdoutLines = append(stdoutLines, line) + }, + } + + runAndCollect := func(req *ExecuteCodeRequest) []string { + start := len(stdoutLines) + if err := session.run(req); err != nil { + t.Fatalf("runCommand(%q) error = %v", req.Code, err) + } + return append([]string(nil), stdoutLines[start:]...) + } + + firstRunLines := runAndCollect(&ExecuteCodeRequest{ + Code: fmt.Sprintf("cd %s\npwd", targetDir), + Hooks: hooks, + Timeout: 3 * time.Second, + }) + if !containsLine(firstRunLines, targetDir) { + t.Fatalf("expected cd to update cwd to %q, got %v", targetDir, firstRunLines) + } + + secondRunLines := runAndCollect(&ExecuteCodeRequest{ + Code: "pwd", + Hooks: hooks, + Timeout: 3 * time.Second, + }) + if !containsLine(secondRunLines, targetDir) { + t.Fatalf("expected subsequent run to inherit cwd %q, got %v", targetDir, secondRunLines) + } + + session.mu.Lock() + finalCwd := session.cwd + session.mu.Unlock() + if finalCwd != targetDir { + t.Fatalf("expected session cwd to stay at %q, got %q", targetDir, finalCwd) + } +} + +func TestBashSessionRequestCwdOverridesAfterCd(t *testing.T) { + session := newBashSession(nil) + t.Cleanup(func() { _ = session.close() }) + + if err := session.start(); err != nil { + t.Fatalf("Start() error = %v", err) + } + + initialDir := t.TempDir() + overrideDir := t.TempDir() + + var stdoutLines []string + hooks := ExecuteResultHook{ + OnExecuteStdout: func(line string) { + stdoutLines = append(stdoutLines, line) + }, + } + + runAndCollect := func(req *ExecuteCodeRequest) []string { + start := len(stdoutLines) + if err := session.run(req); err != nil { + t.Fatalf("runCommand(%q) error = %v", req.Code, err) + } + return append([]string(nil), stdoutLines[start:]...) + } + + // First request: change session cwd via script. + firstRunLines := runAndCollect(&ExecuteCodeRequest{ + Code: fmt.Sprintf("cd %s\npwd", initialDir), + Hooks: hooks, + Timeout: 3 * time.Second, + }) + if !containsLine(firstRunLines, initialDir) { + t.Fatalf("expected cd to update cwd to %q, got %v", initialDir, firstRunLines) + } + + // Second request: explicit Cwd overrides session cwd. + secondRunLines := runAndCollect(&ExecuteCodeRequest{ + Code: "pwd", + Cwd: overrideDir, + Hooks: hooks, + Timeout: 3 * time.Second, + }) + if !containsLine(secondRunLines, overrideDir) { + t.Fatalf("expected command to run in override cwd %q, got %v", overrideDir, secondRunLines) + } + + session.mu.Lock() + finalCwd := session.cwd + session.mu.Unlock() + if finalCwd != overrideDir { + t.Fatalf("expected session cwd updated to override dir %q, got %q", overrideDir, finalCwd) + } +} + func TestBashSession_heredoc(t *testing.T) { rewardDir := t.TempDir() controller := NewController("", "") @@ -251,7 +376,12 @@ chmod +x /tmp/exec_child.sh exec /tmp/exec_child.sh ` - err := session.run(script, 5*time.Second, &hooks) + request := &ExecuteCodeRequest{ + Code: script, + Hooks: hooks, + Timeout: 5 * time.Second, + } + err := session.run(request) if err != nil { t.Fatalf("expected exec to complete without killing the session, got %v", err) } @@ -260,8 +390,13 @@ exec /tmp/exec_child.sh } // Subsequent run should still work because we restart bash per run. + request = &ExecuteCodeRequest{ + Code: "echo still-alive", + Hooks: hooks, + Timeout: 2 * time.Second, + } stdoutLines = nil - if err := session.run("echo still-alive", 2*time.Second, &hooks); err != nil { + if err := session.run(request); err != nil { t.Fatalf("expected run to succeed after exec replaced the shell, got %v", err) } if !containsLine(stdoutLines, "still-alive") { @@ -296,7 +431,12 @@ exec 1>&3 2>&4 # step record echo "after-restore" ` - err := session.run(script, 5*time.Second, &hooks) + request := &ExecuteCodeRequest{ + Code: script, + Hooks: hooks, + Timeout: 5 * time.Second, + } + err := session.run(request) if err != nil { t.Fatalf("expected complex exec to finish, got %v", err) } @@ -305,8 +445,13 @@ echo "after-restore" } // Session should still be usable. + request = &ExecuteCodeRequest{ + Code: "echo still-alive", + Hooks: hooks, + Timeout: 2 * time.Second, + } stdoutLines = nil - if err := session.run("echo still-alive", 2*time.Second, &hooks); err != nil { + if err := session.run(request); err != nil { t.Fatalf("expected run to succeed after complex exec, got %v", err) } if !containsLine(stdoutLines, "still-alive") { From 575ca47e1b2ddb4d2acf900af472ef273ffbd365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Thu, 5 Feb 2026 12:34:26 +0800 Subject: [PATCH 14/37] fix(components/execd): avoid env dump leak when command lacks trailing newline --- components/execd/pkg/runtime/bash_session.go | 11 +-- .../execd/pkg/runtime/bash_session_test.go | 84 ++++++++++++++++++- components/execd/pkg/runtime/types.go | 7 -- 3 files changed, 86 insertions(+), 16 deletions(-) diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index b76094e4d..35631b268 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -231,7 +231,7 @@ func (s *bashSession) run(request *ExecuteCodeRequest) error { inEnv = false case strings.HasPrefix(line, exitMarkerPrefix): if code, err := strconv.Atoi(strings.TrimPrefix(line, exitMarkerPrefix)); err == nil { - exitCode = &code + exitCode = &code //nolint:ineffassign } case strings.HasPrefix(line, pwdMarkerPrefix): pwdLine = strings.TrimPrefix(line, pwdMarkerPrefix) @@ -258,8 +258,8 @@ func (s *bashSession) run(request *ExecuteCodeRequest) error { } if exitCode == nil && cmd.ProcessState != nil { - code := cmd.ProcessState.ExitCode() - exitCode = &code + code := cmd.ProcessState.ExitCode() //nolint:staticcheck + exitCode = &code //nolint:ineffassign } updatedEnv := parseExportDump(envLines) @@ -315,9 +315,10 @@ func buildWrappedScript(command string, env map[string]string, cwd string) strin } b.WriteString("__USER_EXIT_CODE__=$?\n") - b.WriteString("echo \"" + envDumpStartMarker + "\"\n") + // Ensure env dump markers are always on their own lines even if the user command omitted a trailing newline. + b.WriteString("printf \"\\n%s\\n\" \"" + envDumpStartMarker + "\"\n") b.WriteString("export -p\n") - b.WriteString("echo \"" + envDumpEndMarker + "\"\n") + b.WriteString("printf \"%s\\n\" \"" + envDumpEndMarker + "\"\n") b.WriteString("printf \"" + pwdMarkerPrefix + "%s\\n\" \"$(pwd)\"\n") b.WriteString("printf \"" + exitMarkerPrefix + "%s\\n\" \"$__USER_EXIT_CODE__\"\n") b.WriteString("exit \"$__USER_EXIT_CODE__\"\n") diff --git a/components/execd/pkg/runtime/bash_session_test.go b/components/execd/pkg/runtime/bash_session_test.go index 762ce82b1..13aed8891 100644 --- a/components/execd/pkg/runtime/bash_session_test.go +++ b/components/execd/pkg/runtime/bash_session_test.go @@ -25,7 +25,7 @@ import ( "time" ) -func TestBashSessionEnvAndExitCode(t *testing.T) { +func TestBashSession_envAndExitCode(t *testing.T) { session := newBashSession(nil) t.Cleanup(func() { _ = session.close() }) @@ -117,7 +117,7 @@ func TestBashSessionEnvAndExitCode(t *testing.T) { } } -func TestBashSessionEnvLargeOutputChained(t *testing.T) { +func TestBashSession_envLargeOutputChained(t *testing.T) { session := newBashSession(nil) t.Cleanup(func() { _ = session.close() }) @@ -195,7 +195,7 @@ func TestBashSessionEnvLargeOutputChained(t *testing.T) { } } -func TestBashSessionCwdPersistsWithoutOverride(t *testing.T) { +func TestBashSession_cwdPersistsWithoutOverride(t *testing.T) { session := newBashSession(nil) t.Cleanup(func() { _ = session.close() }) @@ -245,7 +245,7 @@ func TestBashSessionCwdPersistsWithoutOverride(t *testing.T) { } } -func TestBashSessionRequestCwdOverridesAfterCd(t *testing.T) { +func TestBashSession_requestCwdOverridesAfterCd(t *testing.T) { session := newBashSession(nil) t.Cleanup(func() { _ = session.close() }) @@ -300,6 +300,82 @@ func TestBashSessionRequestCwdOverridesAfterCd(t *testing.T) { } } +func TestBashSession_envDumpNotLeakedWhenNoTrailingNewline(t *testing.T) { + session := newBashSession(nil) + t.Cleanup(func() { _ = session.close() }) + + if err := session.start(); err != nil { + t.Fatalf("Start() error = %v", err) + } + + var stdoutLines []string + hooks := ExecuteResultHook{ + OnExecuteStdout: func(line string) { + stdoutLines = append(stdoutLines, line) + }, + } + + request := &ExecuteCodeRequest{ + Code: `set +x; printf '{"foo":1}'`, + Hooks: hooks, + Timeout: 3 * time.Second, + } + + if err := session.run(request); err != nil { + t.Fatalf("runCommand(no-trailing-newline) error = %v", err) + } + + if len(stdoutLines) != 1 { + t.Fatalf("expected exactly one stdout line, got %v", stdoutLines) + } + if strings.TrimSpace(stdoutLines[0]) != `{"foo":1}` { + t.Fatalf("unexpected stdout content %q", stdoutLines[0]) + } + for _, line := range stdoutLines { + if strings.Contains(line, envDumpStartMarker) || strings.Contains(line, "declare -x") { + t.Fatalf("env dump leaked into stdout: %v", stdoutLines) + } + } +} + +func TestBashSession_envDumpNotLeakedWhenNoOutput(t *testing.T) { + session := newBashSession(nil) + t.Cleanup(func() { _ = session.close() }) + + if err := session.start(); err != nil { + t.Fatalf("Start() error = %v", err) + } + + var stdoutLines []string + hooks := ExecuteResultHook{ + OnExecuteStdout: func(line string) { + stdoutLines = append(stdoutLines, line) + }, + } + + request := &ExecuteCodeRequest{ + Code: `set +x; true`, + Hooks: hooks, + Timeout: 3 * time.Second, + } + + if err := session.run(request); err != nil { + t.Fatalf("runCommand(no-output) error = %v", err) + } + + if len(stdoutLines) > 1 { + t.Fatalf("expected at most one stdout line, got %v", stdoutLines) + } + if len(stdoutLines) == 1 && strings.TrimSpace(stdoutLines[0]) != "" { + t.Fatalf("expected empty stdout, got %q", stdoutLines[0]) + } + for _, line := range stdoutLines { + if strings.Contains(line, envDumpStartMarker) || strings.Contains(line, "declare -x") { + t.Fatalf("env dump leaked into stdout: %v", stdoutLines) + } + } +} + func TestBashSession_heredoc(t *testing.T) { rewardDir := t.TempDir() controller := NewController("", "") diff --git a/components/execd/pkg/runtime/types.go b/components/execd/pkg/runtime/types.go index cc138fe64..5dcf44c8e 100644 --- a/components/execd/pkg/runtime/types.go +++ b/components/execd/pkg/runtime/types.go @@ -92,13 +92,6 @@ type bashSessionConfig struct { StartupTimeout time.Duration } -const ( - // exitCodePrefix marks the beginning of exit code output. - exitCodePrefix = "EXITCODESTART" - // exitCodeSuffix marks the end of exit code output. - exitCodeSuffix = "EXITCODEEND" -) - // bashSession represents a bash session. type bashSession struct { config *bashSessionConfig From 3532fc9253bb4ef439ba9ab83327ac4697fca2d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Wed, 25 Feb 2026 17:54:00 +0800 Subject: [PATCH 15/37] chore(execd): emit bash session exit errors --- components/execd/pkg/runtime/bash_session.go | 34 ++++++++-- .../execd/pkg/runtime/bash_session_test.go | 68 +++++++++++++++++++ 2 files changed, 95 insertions(+), 7 deletions(-) diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index 35631b268..ae4746a92 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -30,9 +30,9 @@ import ( "strings" "time" - "github.com/google/uuid" - + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" "github.com/alibaba/opensandbox/execd/pkg/log" + "github.com/google/uuid" ) const ( @@ -272,16 +272,36 @@ func (s *bashSession) run(request *ExecuteCodeRequest) error { } s.mu.Unlock() - if request.Hooks.OnExecuteComplete != nil { - request.Hooks.OnExecuteComplete(time.Since(startAt)) - } - - // Maintain previous behavior: non-zero exit codes do not surface as errors. var exitErr *exec.ExitError if waitErr != nil && !errors.As(waitErr, &exitErr) { return waitErr } + userExitCode := 0 + if exitCode != nil { + userExitCode = *exitCode + } + + if userExitCode != 0 { + errMsg := fmt.Sprintf("command exited with code %d", userExitCode) + if waitErr != nil { + errMsg = waitErr.Error() + } + if request.Hooks.OnExecuteError != nil { + request.Hooks.OnExecuteError(&execute.ErrorOutput{ + EName: "CommandExecError", + EValue: strconv.Itoa(userExitCode), + Traceback: []string{errMsg}, + }) + } + log.Error("CommandExecError: %s", errMsg) + return nil + } + + if request.Hooks.OnExecuteComplete != nil { + request.Hooks.OnExecuteComplete(time.Since(startAt)) + } + return nil } diff --git a/components/execd/pkg/runtime/bash_session_test.go b/components/execd/pkg/runtime/bash_session_test.go index 13aed8891..2a6e74825 100644 --- a/components/execd/pkg/runtime/bash_session_test.go +++ b/components/execd/pkg/runtime/bash_session_test.go @@ -18,13 +18,81 @@ package runtime import ( + "context" "fmt" "os" + "os/exec" "strings" "testing" "time" + + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" ) +func TestBashSession_NonZeroExitEmitsError(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found in PATH") + } + + c := NewController("", "") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var ( + sessionID string + stdoutLine string + errCh = make(chan *execute.ErrorOutput, 1) + completeCh = make(chan struct{}, 1) + ) + + req := &ExecuteCodeRequest{ + Language: Bash, + Code: `echo "before"; exit 7`, + Cwd: t.TempDir(), + Timeout: 5 * time.Second, + Hooks: ExecuteResultHook{ + OnExecuteInit: func(s string) { sessionID = s }, + OnExecuteStdout: func(s string) { stdoutLine = s }, + OnExecuteError: func(err *execute.ErrorOutput) { errCh <- err }, + OnExecuteComplete: func(_ time.Duration) { + completeCh <- struct{}{} + }, + }, + } + + if err := c.runBashSession(ctx, req); err != nil { + t.Fatalf("runBashSession returned error: %v", err) + } + + var gotErr *execute.ErrorOutput + select { + case gotErr = <-errCh: + case <-time.After(2 * time.Second): + t.Fatalf("expected error hook to be called") + } + + if gotErr == nil { + t.Fatalf("expected non-nil error output") + } + if gotErr.EName != "CommandExecError" || gotErr.EValue != "7" { + t.Fatalf("unexpected error payload: %+v", gotErr) + } + + if sessionID == "" { + t.Fatalf("expected session id to be set") + } + if stdoutLine != "before" { + t.Fatalf("unexpected stdout: %q", stdoutLine) + } + + select { + case <-completeCh: + t.Fatalf("did not expect completion hook on non-zero exit") + default: + } +} + func TestBashSession_envAndExitCode(t *testing.T) { session := newBashSession(nil) t.Cleanup(func() { _ = session.close() }) From 7d28368299834d9420087d796313c48d16e781e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Thu, 26 Feb 2026 18:51:48 +0800 Subject: [PATCH 16/37] fix(execd): run bash session from temp script file to avoid argument list too long --- components/execd/pkg/runtime/bash_session.go | 85 ++++++++++---------- 1 file changed, 41 insertions(+), 44 deletions(-) diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index ae4746a92..58b8aeb3b 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -22,7 +22,6 @@ import ( "context" "errors" "fmt" - "io" "os" "os/exec" "sort" @@ -186,32 +185,35 @@ func (s *bashSession) run(request *ExecuteCodeRequest) error { ctx, cancel := context.WithTimeout(context.Background(), wait) defer cancel() - cmd := exec.CommandContext(ctx, "bash", "--noprofile", "--norc", "-s") - cmd.Env = envMapToSlice(envSnapshot) + script := buildWrappedScript(request.Code, envSnapshot, cwd) + scriptFile, err := os.CreateTemp("", "execd_bash_*.sh") + if err != nil { + return fmt.Errorf("create script file: %w", err) + } + scriptPath := scriptFile.Name() + if _, err := scriptFile.WriteString(script); err != nil { + _ = scriptFile.Close() + return fmt.Errorf("write script file: %w", err) + } + if err := scriptFile.Close(); err != nil { + return fmt.Errorf("close script file: %w", err) + } + cmd := exec.CommandContext(ctx, "bash", "--noprofile", "--norc", scriptPath) + // Do not pass envSnapshot via cmd.Env to avoid "argument list too long" when session env is large. + // Child inherits parent env (nil => default in Go). The script file already has "export K=V" for + // all session vars at the top, so the session environment is applied when the script runs. stdout, err := cmd.StdoutPipe() if err != nil { return fmt.Errorf("stdout pipe: %w", err) } cmd.Stderr = cmd.Stdout - stdin, err := cmd.StdinPipe() - if err != nil { - return fmt.Errorf("stdin pipe: %w", err) - } - if err := cmd.Start(); err != nil { + log.Error("start bash session failed: %v (command: %q)", err, request.Code) return fmt.Errorf("start bash: %w", err) } - script := buildWrappedScript(request.Code, envSnapshot, cwd) - if _, err := io.WriteString(stdin, script); err != nil { - _ = stdin.Close() - _ = cmd.Wait() - return fmt.Errorf("write command: %w", err) - } - _ = stdin.Close() - scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) @@ -250,10 +252,12 @@ func (s *bashSession) run(request *ExecuteCodeRequest) error { waitErr := cmd.Wait() if scanErr != nil { + log.Error("read stdout failed: %v (command: %q)", scanErr, request.Code) return fmt.Errorf("read stdout: %w", scanErr) } if errors.Is(ctx.Err(), context.DeadlineExceeded) { + log.Error("timeout after %s while running command: %q", wait, request.Code) return fmt.Errorf("timeout after %s while running command %q", wait, request.Code) } @@ -274,6 +278,7 @@ func (s *bashSession) run(request *ExecuteCodeRequest) error { var exitErr *exec.ExitError if waitErr != nil && !errors.As(waitErr, &exitErr) { + log.Error("command wait failed: %v (command: %q)", waitErr, request.Code) return waitErr } @@ -294,7 +299,7 @@ func (s *bashSession) run(request *ExecuteCodeRequest) error { Traceback: []string{errMsg}, }) } - log.Error("CommandExecError: %s", errMsg) + log.Error("CommandExecError: %s (command: %q)", errMsg, request.Code) return nil } @@ -310,7 +315,8 @@ func buildWrappedScript(command string, env map[string]string, cwd string) strin keys := make([]string, 0, len(env)) for k := range env { - if isValidEnvKey(k) { + v := env[k] + if isValidEnvKey(k) && !envKeysNotPersisted[k] && len(v) <= maxPersistedEnvValueSize { keys = append(keys, k) } } @@ -335,7 +341,6 @@ func buildWrappedScript(command string, env map[string]string, cwd string) strin } b.WriteString("__USER_EXIT_CODE__=$?\n") - // Ensure env dump markers are always on their own lines even if the user command omitted a trailing newline. b.WriteString("printf \"\\n%s\\n\" \"" + envDumpStartMarker + "\"\n") b.WriteString("export -p\n") b.WriteString("printf \"%s\\n\" \"" + envDumpEndMarker + "\"\n") @@ -346,16 +351,26 @@ func buildWrappedScript(command string, env map[string]string, cwd string) strin return b.String() } +// envKeysNotPersisted are not carried across runs (prompt/display vars). +var envKeysNotPersisted = map[string]bool{ + "PS1": true, "PS2": true, "PS3": true, "PS4": true, + "PROMPT_COMMAND": true, +} + +// maxPersistedEnvValueSize caps single env value length as a safeguard. +const maxPersistedEnvValueSize = 8 * 1024 + func parseExportDump(lines []string) map[string]string { if len(lines) == 0 { return nil } - env := make(map[string]string, len(lines)) for _, line := range lines { - if k, v, ok := parseExportLine(line); ok { - env[k] = v + k, v, ok := parseExportLine(line) + if !ok || envKeysNotPersisted[k] || len(v) > maxPersistedEnvValueSize { + continue } + env[k] = v } return env } @@ -365,29 +380,23 @@ func parseExportLine(line string) (string, string, bool) { if !strings.HasPrefix(line, prefix) { return "", "", false } - rest := strings.TrimSpace(strings.TrimPrefix(line, prefix)) if rest == "" { return "", "", false } - - name := rest - value := "" + name, value := rest, "" if eq := strings.Index(rest, "="); eq >= 0 { name = rest[:eq] raw := rest[eq+1:] - unquoted, err := strconv.Unquote(raw) - if err != nil { - value = strings.Trim(raw, `"`) - } else { + if unquoted, err := strconv.Unquote(raw); err == nil { value = unquoted + } else { + value = strings.Trim(raw, `"`) } } - if !isValidEnvKey(name) { return "", "", false } - return name, value, true } @@ -427,18 +436,6 @@ func copyEnvMap(src map[string]string) map[string]string { return dst } -func envMapToSlice(env map[string]string) []string { - if len(env) == 0 { - return os.Environ() - } - - out := make([]string, 0, len(env)) - for k, v := range env { - out = append(out, fmt.Sprintf("%s=%s", k, v)) - } - return out -} - func splitEnvPair(kv string) (string, string, bool) { parts := strings.SplitN(kv, "=", 2) if len(parts) != 2 { From dc145d1587b9954a6582506941a0bc2f0754a1aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Sun, 15 Mar 2026 21:27:35 +0800 Subject: [PATCH 17/37] feat(execd): support new API for create_session and run_in_session --- .github/workflows/real-e2e.yml | 2 - components/execd/pkg/runtime/bash_session.go | 19 +- .../execd/pkg/runtime/bash_session_test.go | 263 +++++------------- .../execd/pkg/runtime/bash_session_windows.go | 19 +- components/execd/pkg/runtime/context.go | 7 +- components/execd/pkg/runtime/context_test.go | 22 +- components/execd/pkg/runtime/ctrl.go | 21 +- .../pkg/web/controller/codeinterpreting.go | 116 ++++++++ components/execd/pkg/web/model/session.go | 42 +++ components/execd/pkg/web/router.go | 7 + sandboxes/code-interpreter/Dockerfile | 2 +- sandboxes/code-interpreter/README.md | 1 + sandboxes/code-interpreter/README_zh.md | 1 + .../scripts/code-interpreter.sh | 8 + .../tests/test_code_interpreter_e2e.test.ts | 55 ---- .../python/tests/test_code_interpreter_e2e.py | 16 +- .../tests/test_code_interpreter_e2e_sync.py | 92 ------ 17 files changed, 315 insertions(+), 378 deletions(-) create mode 100644 components/execd/pkg/web/model/session.go diff --git a/.github/workflows/real-e2e.yml b/.github/workflows/real-e2e.yml index c041c02b8..e88c7f665 100644 --- a/.github/workflows/real-e2e.yml +++ b/.github/workflows/real-e2e.yml @@ -43,8 +43,6 @@ jobs: docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true - name: Run tests - env: - OPENSANDBOX_SANDBOX_DEFAULT_IMAGE: opensandbox/code-interpreter:latest run: | set -e diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index 58b8aeb3b..bdedd8891 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -29,9 +29,10 @@ import ( "strings" "time" + "github.com/google/uuid" + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" "github.com/alibaba/opensandbox/execd/pkg/log" - "github.com/google/uuid" ) const ( @@ -108,6 +109,22 @@ func (c *Controller) closeBashSession(sessionId string) error { return nil } +// CreateBashSession creates a pipe-based bash session for the session API only (POST /session). +// It is separate from CreateContext; Bash language context still uses Jupyter kernel. +func (c *Controller) CreateBashSession(req *CreateContextRequest) (string, error) { + return c.createBashSession(req) +} + +// RunInBashSession runs code in an existing bash session for the session API only (POST /session/:id/run). +func (c *Controller) RunInBashSession(ctx context.Context, req *ExecuteCodeRequest) error { + return c.runBashSession(ctx, req) +} + +// DeleteBashSession deletes a pipe-based bash session for the session API only (DELETE /session/:id). +func (c *Controller) DeleteBashSession(sessionID string) error { + return c.closeBashSession(sessionID) +} + // nolint:unused func (c *Controller) listBashSessions() []string { sessions := make([]string, 0) diff --git a/components/execd/pkg/runtime/bash_session_test.go b/components/execd/pkg/runtime/bash_session_test.go index 2a6e74825..89ebba00e 100644 --- a/components/execd/pkg/runtime/bash_session_test.go +++ b/components/execd/pkg/runtime/bash_session_test.go @@ -20,12 +20,13 @@ package runtime import ( "context" "fmt" - "os" "os/exec" "strings" "testing" "time" + "github.com/stretchr/testify/require" + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" ) @@ -61,34 +62,23 @@ func TestBashSession_NonZeroExitEmitsError(t *testing.T) { }, } - if err := c.runBashSession(ctx, req); err != nil { - t.Fatalf("runBashSession returned error: %v", err) - } + require.NoError(t, c.runBashSession(ctx, req)) var gotErr *execute.ErrorOutput select { case gotErr = <-errCh: case <-time.After(2 * time.Second): - t.Fatalf("expected error hook to be called") - } - - if gotErr == nil { - t.Fatalf("expected non-nil error output") - } - if gotErr.EName != "CommandExecError" || gotErr.EValue != "7" { - t.Fatalf("unexpected error payload: %+v", gotErr) - } - - if sessionID == "" { - t.Fatalf("expected session id to be set") - } - if stdoutLine != "before" { - t.Fatalf("unexpected stdout: %q", stdoutLine) + require.Fail(t, "expected error hook to be called") } + require.NotNil(t, gotErr, "expected non-nil error output") + require.Equal(t, "CommandExecError", gotErr.EName) + require.Equal(t, "7", gotErr.EValue) + require.NotEmpty(t, sessionID, "expected session id to be set") + require.Equal(t, "before", stdoutLine) select { case <-completeCh: - t.Fatalf("did not expect completion hook on non-zero exit") + require.Fail(t, "did not expect completion hook on non-zero exit") default: } } @@ -97,9 +87,7 @@ func TestBashSession_envAndExitCode(t *testing.T) { session := newBashSession(nil) t.Cleanup(func() { _ = session.close() }) - if err := session.start(); err != nil { - t.Fatalf("Start() error = %v", err) - } + require.NoError(t, session.start()) var ( initCalls int @@ -109,9 +97,7 @@ func TestBashSession_envAndExitCode(t *testing.T) { hooks := ExecuteResultHook{ OnExecuteInit: func(ctx string) { - if ctx != session.config.Session { - t.Fatalf("unexpected session in OnExecuteInit: %s", ctx) - } + require.Equal(t, session.config.Session, ctx, "unexpected session in OnExecuteInit") initCalls++ }, OnExecuteStdout: func(text string) { @@ -129,9 +115,7 @@ func TestBashSession_envAndExitCode(t *testing.T) { Hooks: hooks, Timeout: 3 * time.Second, } - if err := session.run(request); err != nil { - t.Fatalf("runCommand(export) error = %v", err) - } + require.NoError(t, session.run(request)) exportStdoutCount := len(stdoutLines) // 2) verify env is persisted @@ -140,9 +124,7 @@ func TestBashSession_envAndExitCode(t *testing.T) { Hooks: hooks, Timeout: 3 * time.Second, } - if err := session.run(request); err != nil { - t.Fatalf("runCommand(echo) error = %v", err) - } + require.NoError(t, session.run(request)) echoLines := stdoutLines[exportStdoutCount:] foundHello := false for _, line := range echoLines { @@ -151,9 +133,7 @@ func TestBashSession_envAndExitCode(t *testing.T) { break } } - if !foundHello { - t.Fatalf("expected echo $FOO to output 'hello', got %v", echoLines) - } + require.True(t, foundHello, "expected echo $FOO to output 'hello', got %v", echoLines) // 3) ensure exit code of previous command is reflected in shell state request = &ExecuteCodeRequest{ @@ -162,9 +142,7 @@ func TestBashSession_envAndExitCode(t *testing.T) { Timeout: 3 * time.Second, } prevCount := len(stdoutLines) - if err := session.run(request); err != nil { - t.Fatalf("runCommand(exitcode) error = %v", err) - } + require.NoError(t, session.run(request)) exitLines := stdoutLines[prevCount:] foundExit := false for _, line := range exitLines { @@ -173,25 +151,16 @@ func TestBashSession_envAndExitCode(t *testing.T) { break } } - if !foundExit { - t.Fatalf("expected exit code output 'EXIT:1', got %v", exitLines) - } - - if initCalls != 3 { - t.Fatalf("OnExecuteInit expected 3 calls, got %d", initCalls) - } - if completeCalls != 3 { - t.Fatalf("OnExecuteComplete expected 3 calls, got %d", completeCalls) - } + require.True(t, foundExit, "expected exit code output 'EXIT:1', got %v", exitLines) + require.Equal(t, 3, initCalls, "OnExecuteInit expected 3 calls") + require.Equal(t, 3, completeCalls, "OnExecuteComplete expected 3 calls") } func TestBashSession_envLargeOutputChained(t *testing.T) { session := newBashSession(nil) t.Cleanup(func() { _ = session.close() }) - if err := session.start(); err != nil { - t.Fatalf("Start() error = %v", err) - } + require.NoError(t, session.start()) var ( initCalls int @@ -201,9 +170,7 @@ func TestBashSession_envLargeOutputChained(t *testing.T) { hooks := ExecuteResultHook{ OnExecuteInit: func(ctx string) { - if ctx != session.config.Session { - t.Fatalf("unexpected session in OnExecuteInit: %s", ctx) - } + require.Equal(t, session.config.Session, ctx, "unexpected session in OnExecuteInit") initCalls++ }, OnExecuteStdout: func(text string) { @@ -222,54 +189,31 @@ func TestBashSession_envLargeOutputChained(t *testing.T) { Hooks: hooks, Timeout: 10 * time.Second, } - if err := session.run(request); err != nil { - t.Fatalf("runCommand(%q) error = %v", cmd, err) - } + require.NoError(t, session.run(request)) return append([]string(nil), stdoutLines[start:]...) } lines1 := runAndCollect("export FOO=hello1; for i in $(seq 1 60); do echo A${i}:$FOO; done") - if len(lines1) < 60 { - t.Fatalf("expected >=60 lines for cmd1, got %d", len(lines1)) - } - if !containsLine(lines1, "A1:hello1") || !containsLine(lines1, "A60:hello1") { - t.Fatalf("env not reflected in cmd1 output, got %v", lines1[:3]) - } + require.GreaterOrEqual(t, len(lines1), 60, "expected >=60 lines for cmd1") + require.True(t, containsLine(lines1, "A1:hello1") && containsLine(lines1, "A60:hello1"), "env not reflected in cmd1 output, got %v", lines1[:3]) lines2 := runAndCollect("export FOO=${FOO}_next; export BAR=bar1; for i in $(seq 1 60); do echo B${i}:$FOO:$BAR; done") - if len(lines2) < 60 { - t.Fatalf("expected >=60 lines for cmd2, got %d", len(lines2)) - } - if !containsLine(lines2, "B1:hello1_next:bar1") || !containsLine(lines2, "B60:hello1_next:bar1") { - t.Fatalf("env not propagated to cmd2 output, sample %v", lines2[:3]) - } + require.GreaterOrEqual(t, len(lines2), 60, "expected >=60 lines for cmd2") + require.True(t, containsLine(lines2, "B1:hello1_next:bar1") && containsLine(lines2, "B60:hello1_next:bar1"), "env not propagated to cmd2 output, sample %v", lines2[:3]) lines3 := runAndCollect("export BAR=${BAR}_last; for i in $(seq 1 60); do echo C${i}:$FOO:$BAR; done; echo FINAL_FOO=$FOO; echo FINAL_BAR=$BAR") - if len(lines3) < 62 { // 60 lines + 2 finals - t.Fatalf("expected >=62 lines for cmd3, got %d", len(lines3)) - } - if !containsLine(lines3, "C1:hello1_next:bar1_last") || !containsLine(lines3, "C60:hello1_next:bar1_last") { - t.Fatalf("env not propagated to cmd3 output, sample %v", lines3[:3]) - } - if !containsLine(lines3, "FINAL_FOO=hello1_next") || !containsLine(lines3, "FINAL_BAR=bar1_last") { - t.Fatalf("final env lines missing, got %v", lines3[len(lines3)-5:]) - } - - if initCalls != 3 { - t.Fatalf("OnExecuteInit expected 3 calls, got %d", initCalls) - } - if completeCalls != 3 { - t.Fatalf("OnExecuteComplete expected 3 calls, got %d", completeCalls) - } + require.GreaterOrEqual(t, len(lines3), 62, "expected >=62 lines for cmd3") // 60 lines + 2 finals + require.True(t, containsLine(lines3, "C1:hello1_next:bar1_last") && containsLine(lines3, "C60:hello1_next:bar1_last"), "env not propagated to cmd3 output, sample %v", lines3[:3]) + require.True(t, containsLine(lines3, "FINAL_FOO=hello1_next") && containsLine(lines3, "FINAL_BAR=bar1_last"), "final env lines missing, got %v", lines3[len(lines3)-5:]) + require.Equal(t, 3, initCalls, "OnExecuteInit expected 3 calls") + require.Equal(t, 3, completeCalls, "OnExecuteComplete expected 3 calls") } func TestBashSession_cwdPersistsWithoutOverride(t *testing.T) { session := newBashSession(nil) t.Cleanup(func() { _ = session.close() }) - if err := session.start(); err != nil { - t.Fatalf("Start() error = %v", err) - } + require.NoError(t, session.start()) targetDir := t.TempDir() var stdoutLines []string @@ -281,9 +225,7 @@ func TestBashSession_cwdPersistsWithoutOverride(t *testing.T) { runAndCollect := func(req *ExecuteCodeRequest) []string { start := len(stdoutLines) - if err := session.run(req); err != nil { - t.Fatalf("runCommand(%q) error = %v", req.Code, err) - } + require.NoError(t, session.run(req)) return append([]string(nil), stdoutLines[start:]...) } @@ -292,34 +234,26 @@ func TestBashSession_cwdPersistsWithoutOverride(t *testing.T) { Hooks: hooks, Timeout: 3 * time.Second, }) - if !containsLine(firstRunLines, targetDir) { - t.Fatalf("expected cd to update cwd to %q, got %v", targetDir, firstRunLines) - } + require.True(t, containsLine(firstRunLines, targetDir), "expected cd to update cwd to %q, got %v", targetDir, firstRunLines) secondRunLines := runAndCollect(&ExecuteCodeRequest{ Code: "pwd", Hooks: hooks, Timeout: 3 * time.Second, }) - if !containsLine(secondRunLines, targetDir) { - t.Fatalf("expected subsequent run to inherit cwd %q, got %v", targetDir, secondRunLines) - } + require.True(t, containsLine(secondRunLines, targetDir), "expected subsequent run to inherit cwd %q, got %v", targetDir, secondRunLines) session.mu.Lock() finalCwd := session.cwd session.mu.Unlock() - if finalCwd != targetDir { - t.Fatalf("expected session cwd to stay at %q, got %q", targetDir, finalCwd) - } + require.Equal(t, targetDir, finalCwd, "expected session cwd to stay at %q", targetDir) } func TestBashSession_requestCwdOverridesAfterCd(t *testing.T) { session := newBashSession(nil) t.Cleanup(func() { _ = session.close() }) - if err := session.start(); err != nil { - t.Fatalf("Start() error = %v", err) - } + require.NoError(t, session.start()) initialDir := t.TempDir() overrideDir := t.TempDir() @@ -333,9 +267,7 @@ func TestBashSession_requestCwdOverridesAfterCd(t *testing.T) { runAndCollect := func(req *ExecuteCodeRequest) []string { start := len(stdoutLines) - if err := session.run(req); err != nil { - t.Fatalf("runCommand(%q) error = %v", req.Code, err) - } + require.NoError(t, session.run(req)) return append([]string(nil), stdoutLines[start:]...) } @@ -345,9 +277,7 @@ func TestBashSession_requestCwdOverridesAfterCd(t *testing.T) { Hooks: hooks, Timeout: 3 * time.Second, }) - if !containsLine(firstRunLines, initialDir) { - t.Fatalf("expected cd to update cwd to %q, got %v", initialDir, firstRunLines) - } + require.True(t, containsLine(firstRunLines, initialDir), "expected cd to update cwd to %q, got %v", initialDir, firstRunLines) // Second request: explicit Cwd overrides session cwd. secondRunLines := runAndCollect(&ExecuteCodeRequest{ @@ -356,25 +286,19 @@ func TestBashSession_requestCwdOverridesAfterCd(t *testing.T) { Hooks: hooks, Timeout: 3 * time.Second, }) - if !containsLine(secondRunLines, overrideDir) { - t.Fatalf("expected command to run in override cwd %q, got %v", overrideDir, secondRunLines) - } + require.True(t, containsLine(secondRunLines, overrideDir), "expected command to run in override cwd %q, got %v", overrideDir, secondRunLines) session.mu.Lock() finalCwd := session.cwd session.mu.Unlock() - if finalCwd != overrideDir { - t.Fatalf("expected session cwd updated to override dir %q, got %q", overrideDir, finalCwd) - } + require.Equal(t, overrideDir, finalCwd, "expected session cwd updated to override dir %q", overrideDir) } func TestBashSession_envDumpNotLeakedWhenNoTrailingNewline(t *testing.T) { session := newBashSession(nil) t.Cleanup(func() { _ = session.close() }) - if err := session.start(); err != nil { - t.Fatalf("Start() error = %v", err) - } + require.NoError(t, session.start()) var stdoutLines []string hooks := ExecuteResultHook{ @@ -388,21 +312,13 @@ func TestBashSession_envDumpNotLeakedWhenNoTrailingNewline(t *testing.T) { Hooks: hooks, Timeout: 3 * time.Second, } + require.NoError(t, session.run(request)) - if err := session.run(request); err != nil { - t.Fatalf("runCommand(no-trailing-newline) error = %v", err) - } - - if len(stdoutLines) != 1 { - t.Fatalf("expected exactly one stdout line, got %v", stdoutLines) - } - if strings.TrimSpace(stdoutLines[0]) != `{"foo":1}` { - t.Fatalf("unexpected stdout content %q", stdoutLines[0]) - } + require.Len(t, stdoutLines, 1, "expected exactly one stdout line") + require.Equal(t, `{"foo":1}`, strings.TrimSpace(stdoutLines[0])) for _, line := range stdoutLines { - if strings.Contains(line, envDumpStartMarker) || strings.Contains(line, "declare -x") { - t.Fatalf("env dump leaked into stdout: %v", stdoutLines) - } + require.NotContains(t, line, envDumpStartMarker, "env dump leaked into stdout: %v", stdoutLines) + require.NotContains(t, line, "declare -x", "env dump leaked into stdout: %v", stdoutLines) } } @@ -410,9 +326,7 @@ func TestBashSession_envDumpNotLeakedWhenNoOutput(t *testing.T) { session := newBashSession(nil) t.Cleanup(func() { _ = session.close() }) - if err := session.start(); err != nil { - t.Fatalf("Start() error = %v", err) - } + require.NoError(t, session.start()) var stdoutLines []string hooks := ExecuteResultHook{ @@ -426,21 +340,15 @@ func TestBashSession_envDumpNotLeakedWhenNoOutput(t *testing.T) { Hooks: hooks, Timeout: 3 * time.Second, } + require.NoError(t, session.run(request)) - if err := session.run(request); err != nil { - t.Fatalf("runCommand(no-output) error = %v", err) - } - - if len(stdoutLines) > 1 { - t.Fatalf("expected at most one stdout line, got %v", stdoutLines) - } - if len(stdoutLines) == 1 && strings.TrimSpace(stdoutLines[0]) != "" { - t.Fatalf("expected empty stdout, got %q", stdoutLines[0]) + require.LessOrEqual(t, len(stdoutLines), 1, "expected at most one stdout line, got %v", stdoutLines) + if len(stdoutLines) == 1 { + require.Empty(t, strings.TrimSpace(stdoutLines[0]), "expected empty stdout") } for _, line := range stdoutLines { - if strings.Contains(line, envDumpStartMarker) || strings.Contains(line, "declare -x") { - t.Fatalf("env dump leaked into stdout: %v", stdoutLines) - } + require.NotContains(t, line, envDumpStartMarker, "env dump leaked into stdout: %v", stdoutLines) + require.NotContains(t, line, "declare -x", "env dump leaked into stdout: %v", stdoutLines) } } @@ -448,6 +356,10 @@ func TestBashSession_heredoc(t *testing.T) { rewardDir := t.TempDir() controller := NewController("", "") + sessionID, err := controller.CreateBashSession(&CreateContextRequest{}) + require.NoError(t, err) + t.Cleanup(func() { _ = controller.DeleteBashSession(sessionID) }) + hooks := ExecuteResultHook{ OnExecuteStdout: func(line string) { fmt.Printf("[stdout] %s\n", line) @@ -475,35 +387,30 @@ echo 1 > "$reward_dir/reward.txt" cat "$reward_dir/reward.txt" `, rewardDir) - if err := controller.Execute(&ExecuteCodeRequest{ + ctx := context.Background() + require.NoError(t, controller.RunInBashSession(ctx, &ExecuteCodeRequest{ + Context: sessionID, Language: Bash, Timeout: 10 * time.Second, Code: script, Hooks: hooks, - }); err != nil { - fmt.Fprintf(os.Stderr, "first Execute failed: %v\n", err) - os.Exit(1) - } + })) // Second run: ensure the session keeps working. - if err := controller.Execute(&ExecuteCodeRequest{ + require.NoError(t, controller.RunInBashSession(ctx, &ExecuteCodeRequest{ + Context: sessionID, Language: Bash, Timeout: 5 * time.Second, Code: "echo 'second command works'", Hooks: hooks, - }); err != nil { - fmt.Fprintf(os.Stderr, "second Execute failed: %v\n", err) - os.Exit(1) - } + })) } func TestBashSession_execReplacesShell(t *testing.T) { session := newBashSession(nil) t.Cleanup(func() { _ = session.close() }) - if err := session.start(); err != nil { - t.Fatalf("Start() error = %v", err) - } + require.NoError(t, session.start()) var stdoutLines []string hooks := ExecuteResultHook{ @@ -525,13 +432,8 @@ exec /tmp/exec_child.sh Hooks: hooks, Timeout: 5 * time.Second, } - err := session.run(request) - if err != nil { - t.Fatalf("expected exec to complete without killing the session, got %v", err) - } - if !containsLine(stdoutLines, "child says hi") { - t.Fatalf("expected child output, got %v", stdoutLines) - } + require.NoError(t, session.run(request), "expected exec to complete without killing the session") + require.True(t, containsLine(stdoutLines, "child says hi"), "expected child output, got %v", stdoutLines) // Subsequent run should still work because we restart bash per run. request = &ExecuteCodeRequest{ @@ -540,21 +442,15 @@ exec /tmp/exec_child.sh Timeout: 2 * time.Second, } stdoutLines = nil - if err := session.run(request); err != nil { - t.Fatalf("expected run to succeed after exec replaced the shell, got %v", err) - } - if !containsLine(stdoutLines, "still-alive") { - t.Fatalf("expected follow-up output, got %v", stdoutLines) - } + require.NoError(t, session.run(request), "expected run to succeed after exec replaced the shell") + require.True(t, containsLine(stdoutLines, "still-alive"), "expected follow-up output, got %v", stdoutLines) } func TestBashSession_complexExec(t *testing.T) { session := newBashSession(nil) t.Cleanup(func() { _ = session.close() }) - if err := session.start(); err != nil { - t.Fatalf("Start() error = %v", err) - } + require.NoError(t, session.start()) var stdoutLines []string hooks := ExecuteResultHook{ @@ -580,13 +476,8 @@ echo "after-restore" Hooks: hooks, Timeout: 5 * time.Second, } - err := session.run(request) - if err != nil { - t.Fatalf("expected complex exec to finish, got %v", err) - } - if !containsLine(stdoutLines, "from-complex-exec") || !containsLine(stdoutLines, "after-restore") { - t.Fatalf("expected exec outputs, got %v", stdoutLines) - } + require.NoError(t, session.run(request), "expected complex exec to finish") + require.True(t, containsLine(stdoutLines, "from-complex-exec") && containsLine(stdoutLines, "after-restore"), "expected exec outputs, got %v", stdoutLines) // Session should still be usable. request = &ExecuteCodeRequest{ @@ -595,12 +486,8 @@ echo "after-restore" Timeout: 2 * time.Second, } stdoutLines = nil - if err := session.run(request); err != nil { - t.Fatalf("expected run to succeed after complex exec, got %v", err) - } - if !containsLine(stdoutLines, "still-alive") { - t.Fatalf("expected follow-up output, got %v", stdoutLines) - } + require.NoError(t, session.run(request), "expected run to succeed after complex exec") + require.True(t, containsLine(stdoutLines, "still-alive"), "expected follow-up output, got %v", stdoutLines) } func containsLine(lines []string, target string) bool { diff --git a/components/execd/pkg/runtime/bash_session_windows.go b/components/execd/pkg/runtime/bash_session_windows.go index 9b1bac42c..32d399b61 100644 --- a/components/execd/pkg/runtime/bash_session_windows.go +++ b/components/execd/pkg/runtime/bash_session_windows.go @@ -37,8 +37,8 @@ func (c *Controller) createDefaultBashSession() error { //nolint:revive return errBashSessionNotSupported } -func (c *Controller) getBashSession(_ string) (*bashSession, error) { //nolint:revive - return nil, errBashSessionNotSupported +func (c *Controller) getBashSession(_ string) *bashSession { //nolint:revive + return nil } func (c *Controller) closeBashSession(_ string) error { //nolint:revive @@ -49,6 +49,21 @@ func (c *Controller) listBashSessions() []string { //nolint:revive return nil } +// CreateBashSession is not supported on Windows. +func (c *Controller) CreateBashSession(_ *CreateContextRequest) (string, error) { //nolint:revive + return "", errBashSessionNotSupported +} + +// RunInBashSession is not supported on Windows. +func (c *Controller) RunInBashSession(_ context.Context, _ *ExecuteCodeRequest) error { //nolint:revive + return errBashSessionNotSupported +} + +// DeleteBashSession is not supported on Windows. +func (c *Controller) DeleteBashSession(_ string) error { //nolint:revive + return errBashSessionNotSupported +} + // Stub methods on bashSession to satisfy interfaces on non-Linux platforms. func newBashSession(config *bashSessionConfig) *bashSession { return &bashSession{config: config} diff --git a/components/execd/pkg/runtime/context.go b/components/execd/pkg/runtime/context.go index e64f1bdb6..42f7fe506 100644 --- a/components/execd/pkg/runtime/context.go +++ b/components/execd/pkg/runtime/context.go @@ -31,11 +31,8 @@ import ( ) // CreateContext provisions a kernel-backed session and returns its ID. +// Bash language uses Jupyter kernel like other languages; for pipe-based bash sessions use CreateBashSession (session API). func (c *Controller) CreateContext(req *CreateContextRequest) (string, error) { - if req.Language == Bash { - return c.createBashSession(req) - } - // Create a new Jupyter session. var ( client *jupyter.Client @@ -119,11 +116,9 @@ func (c *Controller) deleteSessionAndCleanup(session string) error { if c.getJupyterKernel(session) == nil { return ErrContextNotFound } - if err := c.jupyterClient().DeleteSession(session); err != nil { return err } - c.jupyterClientMap.Delete(session) c.deleteDefaultSessionByID(session) return nil diff --git a/components/execd/pkg/runtime/context_test.go b/components/execd/pkg/runtime/context_test.go index 9a0376d02..e0cdf3e6c 100644 --- a/components/execd/pkg/runtime/context_test.go +++ b/components/execd/pkg/runtime/context_test.go @@ -27,8 +27,8 @@ import ( func TestListContextsAndNewIpynbPath(t *testing.T) { c := NewController("http://example", "token") - c.jupyterClientMap["session-python"] = &jupyterKernel{language: Python} - c.defaultLanguageJupyterSessions[Go] = "session-go-default" + c.jupyterClientMap.Store("session-python", &jupyterKernel{language: Python}) + c.defaultLanguageSessions.Store(Go, "session-go-default") pyContexts, err := c.listLanguageContexts(Python) require.NoError(t, err) @@ -107,13 +107,13 @@ func TestDeleteContext_RemovesCacheOnSuccess(t *testing.T) { defer server.Close() c := NewController(server.URL, "token") - c.jupyterClientMap[sessionID] = &jupyterKernel{language: Python} - c.defaultLanguageJupyterSessions[Python] = sessionID + c.jupyterClientMap.Store(sessionID, &jupyterKernel{language: Python}) + c.defaultLanguageSessions.Store(Python, sessionID) require.NoError(t, c.DeleteContext(sessionID)) require.Nil(t, c.getJupyterKernel(sessionID), "expected cache to be cleared") - _, ok := c.defaultLanguageJupyterSessions[Python] + _, ok := c.defaultLanguageSessions.Load(Python) require.False(t, ok, "expected default session entry to be removed") } @@ -138,17 +138,17 @@ func TestDeleteLanguageContext_RemovesCacheOnSuccess(t *testing.T) { defer server.Close() c := NewController(server.URL, "token") - c.jupyterClientMap[session1] = &jupyterKernel{language: lang} - c.jupyterClientMap[session2] = &jupyterKernel{language: lang} - c.defaultLanguageJupyterSessions[lang] = session2 + c.jupyterClientMap.Store(session1, &jupyterKernel{language: lang}) + c.jupyterClientMap.Store(session2, &jupyterKernel{language: lang}) + c.defaultLanguageSessions.Store(lang, session2) require.NoError(t, c.DeleteLanguageContext(lang)) - _, ok := c.jupyterClientMap[session1] + _, ok := c.jupyterClientMap.Load(session1) require.False(t, ok, "expected session1 removed from cache") - _, ok = c.jupyterClientMap[session2] + _, ok = c.jupyterClientMap.Load(session2) require.False(t, ok, "expected session2 removed from cache") - _, ok = c.defaultLanguageJupyterSessions[lang] + _, ok = c.defaultLanguageSessions.Load(lang) require.False(t, ok, "expected default entry removed") require.Equal(t, 1, deleteCalls[session1]) require.Equal(t, 1, deleteCalls[session2]) diff --git a/components/execd/pkg/runtime/ctrl.go b/components/execd/pkg/runtime/ctrl.go index 36c325b48..3a835b317 100644 --- a/components/execd/pkg/runtime/ctrl.go +++ b/components/execd/pkg/runtime/ctrl.go @@ -35,14 +35,15 @@ var kernelWaitingBackoff = wait.Backoff{ // Controller manages code execution across runtimes. type Controller struct { - baseURL string - token string - mu sync.RWMutex - jupyterClientMap map[string]*jupyterKernel - defaultLanguageJupyterSessions map[Language]string - commandClientMap map[string]*commandKernel - db *sql.DB - dbOnce sync.Once + baseURL string + token string + mu sync.RWMutex + jupyterClientMap sync.Map // map[sessionID]*jupyterKernel + defaultLanguageSessions sync.Map // map[Language]string + commandClientMap sync.Map // map[sessionID]*commandKernel + bashSessionClientMap sync.Map // map[sessionID]*bashSession + db *sql.DB + dbOnce sync.Once } type jupyterKernel struct { @@ -70,10 +71,6 @@ func NewController(baseURL, token string) *Controller { return &Controller{ baseURL: baseURL, token: token, - - jupyterClientMap: make(map[string]*jupyterKernel), - defaultLanguageJupyterSessions: make(map[Language]string), - commandClientMap: make(map[string]*commandKernel), } } diff --git a/components/execd/pkg/web/controller/codeinterpreting.go b/components/execd/pkg/web/controller/codeinterpreting.go index df4a28db8..edab5d622 100644 --- a/components/execd/pkg/web/controller/codeinterpreting.go +++ b/components/execd/pkg/web/controller/codeinterpreting.go @@ -236,6 +236,122 @@ func (c *CodeInterpretingController) DeleteContext() { c.RespondSuccess(nil) } +// CreateSession creates a new bash session (create_session API). +func (c *CodeInterpretingController) CreateSession() { + var request model.CreateSessionRequest + if err := c.bindJSON(&request); err != nil { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("error parsing request. %v", err), + ) + return + } + + sessionID, err := codeRunner.CreateBashSession(&runtime.CreateContextRequest{ + Cwd: request.Cwd, + }) + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error creating session. %v", err), + ) + return + } + + c.RespondSuccess(model.CreateSessionResponse{SessionID: sessionID}) +} + +// RunInSession runs code in an existing bash session and streams output via SSE (run_in_session API). +func (c *CodeInterpretingController) RunInSession() { + sessionID := c.ctx.Param("sessionId") + if sessionID == "" { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeMissingQuery, + "missing path parameter 'sessionId'", + ) + return + } + + var request model.RunInSessionRequest + if err := c.bindJSON(&request); err != nil { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("error parsing request. %v", err), + ) + return + } + if err := request.Validate(); err != nil { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("invalid request. %v", err), + ) + return + } + + timeout := time.Duration(request.TimeoutMs) * time.Millisecond + runReq := &runtime.ExecuteCodeRequest{ + Language: runtime.Bash, + Context: sessionID, + Code: request.Code, + Cwd: request.Cwd, + Timeout: timeout, + } + ctx, cancel := context.WithCancel(c.ctx.Request.Context()) + defer cancel() + runReq.Hooks = c.setServerEventsHandler(ctx) + + c.setupSSEResponse() + err := codeRunner.RunInBashSession(ctx, runReq) + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error running in session. %v", err), + ) + return + } + + time.Sleep(flag.ApiGracefulShutdownTimeout) +} + +// DeleteSession deletes a bash session (delete_session API). +func (c *CodeInterpretingController) DeleteSession() { + sessionID := c.ctx.Param("sessionId") + if sessionID == "" { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeMissingQuery, + "missing path parameter 'sessionId'", + ) + return + } + + err := codeRunner.DeleteBashSession(sessionID) + if err != nil { + if errors.Is(err, runtime.ErrContextNotFound) { + c.RespondError( + http.StatusNotFound, + model.ErrorCodeContextNotFound, + fmt.Sprintf("session %s not found", sessionID), + ) + return + } + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error deleting session %s. %v", sessionID, err), + ) + return + } + + c.RespondSuccess(nil) +} + // buildExecuteCodeRequest converts a RunCodeRequest to runtime format. func (c *CodeInterpretingController) buildExecuteCodeRequest(request model.RunCodeRequest) *runtime.ExecuteCodeRequest { req := &runtime.ExecuteCodeRequest{ diff --git a/components/execd/pkg/web/model/session.go b/components/execd/pkg/web/model/session.go new file mode 100644 index 000000000..0b4f598b7 --- /dev/null +++ b/components/execd/pkg/web/model/session.go @@ -0,0 +1,42 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import ( + "github.com/go-playground/validator/v10" +) + +// CreateSessionRequest is the request body for creating a bash session. +type CreateSessionRequest struct { + Cwd string `json:"cwd,omitempty"` +} + +// CreateSessionResponse is the response for create_session. +type CreateSessionResponse struct { + SessionID string `json:"session_id"` +} + +// RunInSessionRequest is the request body for running code in an existing session. +type RunInSessionRequest struct { + Code string `json:"code" validate:"required"` + Cwd string `json:"cwd,omitempty"` + TimeoutMs int64 `json:"timeout_ms,omitempty" validate:"omitempty,gte=0"` +} + +// Validate validates RunInSessionRequest. +func (r *RunInSessionRequest) Validate() error { + validate := validator.New() + return validate.Struct(r) +} diff --git a/components/execd/pkg/web/router.go b/components/execd/pkg/web/router.go index dfca08219..8894257d2 100644 --- a/components/execd/pkg/web/router.go +++ b/components/execd/pkg/web/router.go @@ -62,6 +62,13 @@ func NewRouter(accessToken string) *gin.Engine { code.GET("/contexts/:contextId", withCode(func(c *controller.CodeInterpretingController) { c.GetContext() })) } + session := r.Group("/session") + { + session.POST("", withCode(func(c *controller.CodeInterpretingController) { c.CreateSession() })) + session.POST("/:sessionId/run", withCode(func(c *controller.CodeInterpretingController) { c.RunInSession() })) + session.DELETE("/:sessionId", withCode(func(c *controller.CodeInterpretingController) { c.DeleteSession() })) + } + command := r.Group("/command") { command.POST("", withCode(func(c *controller.CodeInterpretingController) { c.RunCommand() })) diff --git a/sandboxes/code-interpreter/Dockerfile b/sandboxes/code-interpreter/Dockerfile index 280f1b301..2a1aa0135 100644 --- a/sandboxes/code-interpreter/Dockerfile +++ b/sandboxes/code-interpreter/Dockerfile @@ -24,7 +24,7 @@ RUN set -euo pipefail \ echo "Setting up ipykernel for Python $version" \ && . /opt/opensandbox/code-interpreter-env.sh python $version \ && python3 --version \ - && python3 -m pip install ipykernel jupyter --break-system-packages; \ + && python3 -m pip install ipykernel jupyter bash_kernel --break-system-packages; \ done \ && echo "Setting up ipykernel complete" diff --git a/sandboxes/code-interpreter/README.md b/sandboxes/code-interpreter/README.md index b17072e1e..2b7943a20 100644 --- a/sandboxes/code-interpreter/README.md +++ b/sandboxes/code-interpreter/README.md @@ -144,6 +144,7 @@ The image comes with pre-configured Jupyter kernels for all supported languages: - **Java**: IJava kernel - **TypeScript/JavaScript**: tslab kernel - **Go**: gonb kernel +- **Bash**: bash_kernel ### Starting Jupyter diff --git a/sandboxes/code-interpreter/README_zh.md b/sandboxes/code-interpreter/README_zh.md index 9a9bfacbd..e68b2584c 100644 --- a/sandboxes/code-interpreter/README_zh.md +++ b/sandboxes/code-interpreter/README_zh.md @@ -142,6 +142,7 @@ source /opt/opensandbox/code-interpreter-env.sh go - **Java**:IJava 内核 - **TypeScript/JavaScript**:tslab 内核 - **Go**:gonb 内核 +- **Bash**:bash_kernel ### 启动 Jupyter diff --git a/sandboxes/code-interpreter/scripts/code-interpreter.sh b/sandboxes/code-interpreter/scripts/code-interpreter.sh index 6d03691cf..11968c1df 100755 --- a/sandboxes/code-interpreter/scripts/code-interpreter.sh +++ b/sandboxes/code-interpreter/scripts/code-interpreter.sh @@ -93,6 +93,12 @@ setup_go() { } } +setup_bash() { + time { + python3 -m bash_kernel.install + } +} + # export go bin path export PATH="$(go env GOPATH)/bin:$PATH" if [ -n "${EXECD_ENVS:-}" ]; then @@ -108,5 +114,7 @@ setup_node & pids+=($!) setup_go & pids+=($!) +setup_bash & +pids+=($!) jupyter notebook --ip=127.0.0.1 --port="${JUPYTER_PORT:-44771}" --allow-root --no-browser --NotebookApp.token="${JUPYTER_TOKEN:-opensandboxcodeinterpreterjupyter}" >/opt/opensandbox/jupyter.log diff --git a/tests/javascript/tests/test_code_interpreter_e2e.test.ts b/tests/javascript/tests/test_code_interpreter_e2e.test.ts index 62312fbe4..cba8fc122 100644 --- a/tests/javascript/tests/test_code_interpreter_e2e.test.ts +++ b/tests/javascript/tests/test_code_interpreter_e2e.test.ts @@ -385,58 +385,3 @@ test("07 interrupt code execution + fake id", async () => { await expect(ci!.codes.interrupt(`fake-${Date.now()}`)).rejects.toBeTruthy(); }); - -test("08 bash env propagation across sequential executions", async () => { - if (!ci) throw new Error("not initialized"); - - const stdout: string[] = []; - const stderr: string[] = []; - const errors: string[] = []; - - const handlers: ExecutionHandlers = { - onStdout: (m) => { - if (m.text) stdout.push(m.text.trim()); - }, - onStderr: (m) => { - if (m.text) stderr.push(m.text.trim()); - }, - onError: (e) => { - errors.push(e.name); - }, - }; - - const code1 = "export FOO=hello\nexport BAR=world\n"; - const code2 = 'printf "step1:$FOO:$BAR\\n"\n'; - const code3 = - "export FOO=${FOO}_next\n" + - 'printf "step2:$FOO:$BAR\\n"\n' + - "export BAR=${BAR}_next\n" + - 'printf "step3:$FOO:$BAR\\n"\n'; - - const r1 = await ci.codes.run(code1, { - language: SupportedLanguages.BASH, - handlers, - }); - expect(r1.id).toBeTruthy(); - expect(r1.error).toBeUndefined(); - - const r2 = await ci.codes.run(code2, { - language: SupportedLanguages.BASH, - handlers, - }); - expect(r2.id).toBeTruthy(); - expect(r2.error).toBeUndefined(); - - const r3 = await ci.codes.run(code3, { - language: SupportedLanguages.BASH, - handlers, - }); - expect(r3.id).toBeTruthy(); - expect(r3.error).toBeUndefined(); - - expect(stdout).toContain("step1:hello:world"); - expect(stdout).toContain("step2:hello_next:world"); - expect(stdout).toContain("step3:hello_next:world_next"); - expect(errors).toHaveLength(0); - expect(stderr.filter((s) => s.length > 0)).toHaveLength(0); -}); diff --git a/tests/python/tests/test_code_interpreter_e2e.py b/tests/python/tests/test_code_interpreter_e2e.py index 774f080a3..852d47f3e 100644 --- a/tests/python/tests/test_code_interpreter_e2e.py +++ b/tests/python/tests/test_code_interpreter_e2e.py @@ -1183,12 +1183,12 @@ async def test_09_context_management_endpoints(self): code_interpreter = TestCodeInterpreterE2E.code_interpreter assert code_interpreter is not None - language = SupportedLanguage.PYTHON + language = SupportedLanguage.BASH logger.info("=" * 80) logger.info("TEST 9: Context management endpoints (%s)", language) logger.info("=" * 80) - # Ensure clean slate for python contexts to avoid interference with other tests. + # Ensure clean slate for bash contexts to avoid interference with other tests. await code_interpreter.codes.delete_contexts(language) ctx1 = await code_interpreter.codes.create_context(language) @@ -1197,14 +1197,14 @@ async def test_09_context_management_endpoints(self): assert ctx2.id is not None and ctx2.id.strip() assert ctx1.language == language assert ctx2.language == language - logger.info("✓ Created two python contexts: %s, %s", ctx1.id, ctx2.id) + logger.info("✓ Created two bash contexts: %s, %s", ctx1.id, ctx2.id) listed = await code_interpreter.codes.list_contexts(language) - python_context_ids = {c.id for c in listed if c.id} - assert ctx1.id in python_context_ids - assert ctx2.id in python_context_ids + bash_context_ids = {c.id for c in listed if c.id} + assert ctx1.id in bash_context_ids + assert ctx2.id in bash_context_ids assert all(c.language == language for c in listed) - logger.info("✓ list_contexts returned expected python contexts") + logger.info("✓ list_contexts returned expected bash contexts") fetched = await code_interpreter.codes.get_context(ctx1.id) assert fetched.id == ctx1.id @@ -1223,5 +1223,5 @@ async def test_09_context_management_endpoints(self): c for c in await code_interpreter.codes.list_contexts(language) if c.id ] assert len(final_contexts) == 0 - logger.info("✓ delete_contexts removed all python contexts") + logger.info("✓ delete_contexts removed all bash contexts") diff --git a/tests/python/tests/test_code_interpreter_e2e_sync.py b/tests/python/tests/test_code_interpreter_e2e_sync.py index 4be61d3e8..1b87b924e 100644 --- a/tests/python/tests/test_code_interpreter_e2e_sync.py +++ b/tests/python/tests/test_code_interpreter_e2e_sync.py @@ -1016,95 +1016,3 @@ def test_09_context_management_endpoints(self): assert len(final_contexts) == 0 logger.info("✓ delete_contexts removed all bash contexts") - @pytest.mark.timeout(300) - @pytest.mark.order(10) - def test_10_bash_env_propagation(self): - """Ensure bash commands share env/vars across sequential executions.""" - TestCodeInterpreterE2ESync._ensure_code_interpreter_created() - code_interpreter = TestCodeInterpreterE2ESync.code_interpreter - assert code_interpreter is not None - - stdout_messages: list[OutputMessage] = [] - stderr_messages: list[OutputMessage] = [] - errors: list[ExecutionError] = [] - completed_events: list[ExecutionComplete] = [] - init_events: list[ExecutionInit] = [] - - def on_stdout(msg: OutputMessage): - stdout_messages.append(msg) - - def on_stderr(msg: OutputMessage): - stderr_messages.append(msg) - - def on_error(err: ExecutionError): - errors.append(err) - - def on_complete(evt: ExecutionComplete): - completed_events.append(evt) - - def on_init(evt: ExecutionInit): - init_events.append(evt) - - handlers = ExecutionHandlersSync( - on_stdout=on_stdout, - on_stderr=on_stderr, - on_result=None, - on_error=on_error, - on_execution_complete=on_complete, - on_init=on_init, - ) - - # Send three sequential commands in the same session, validating env propagation. - code1 = ( - "export FOO=hello\n" - "export BAR=world\n" - ) - code2 = ( - "printf \"step1:$FOO:$BAR\\n\"\n" - ) - code3 = ( - "export FOO=${FOO}_next\n" - "printf \"step2:$FOO:$BAR\\n\"\n" - "export BAR=${BAR}_next\n" - "printf \"step3:$FOO:$BAR\\n\"\n" - ) - - # export envs - result1 = code_interpreter.codes.run( - code1, - language=SupportedLanguage.BASH, - handlers=handlers, - ) - - assert result1 is not None - assert result1.id is not None and str(result1.id).strip() - assert result1.error is None - - # print env - result2 = code_interpreter.codes.run( - code2, - language=SupportedLanguage.BASH, - handlers=handlers, - ) - - assert result2 is not None - assert result2.id is not None and str(result2.id).strip() - assert result2.error is None - - # print env - result3 = code_interpreter.codes.run( - code3, - language=SupportedLanguage.BASH, - handlers=handlers, - ) - assert result3 is not None - assert result3.id is not None and str(result3.id).strip() - assert result3.error is None - - # Expect at least three stdout lines with propagated env values. - stdout_texts = [m.text.strip() for m in stdout_messages if m.text] - assert "step1:hello:world" in stdout_texts - assert "step2:hello_next:world" in stdout_texts - assert "step3:hello_next:world_next" in stdout_texts - for m in stdout_messages[:3]: - _assert_recent_timestamp_ms(m.timestamp) From caf11cb6cb34547b7eb0c2934354364da37c9528 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Mon, 16 Mar 2026 10:20:01 +0800 Subject: [PATCH 18/37] fix(execd): propagate caller cancellation into bash session execution --- components/execd/pkg/runtime/bash_session.go | 37 +++---------------- .../execd/pkg/runtime/bash_session_test.go | 24 ++++++------ 2 files changed, 17 insertions(+), 44 deletions(-) diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index bdedd8891..02ebfea06 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -53,36 +53,13 @@ func (c *Controller) createBashSession(_ *CreateContextRequest) (string, error) return session.config.Session, nil } -func (c *Controller) runBashSession(_ context.Context, request *ExecuteCodeRequest) error { - if request.Context == "" { - if c.getDefaultLanguageSession(request.Language) == "" { - if err := c.createDefaultBashSession(); err != nil { - return err - } - } - } - - targetSessionID := request.Context - if targetSessionID == "" { - targetSessionID = c.getDefaultLanguageSession(request.Language) - } - - session := c.getBashSession(targetSessionID) +func (c *Controller) runBashSession(ctx context.Context, request *ExecuteCodeRequest) error { + session := c.getBashSession(request.Context) if session == nil { return ErrContextNotFound } - return session.run(request) -} - -func (c *Controller) createDefaultBashSession() error { - session, err := c.createBashSession(&CreateContextRequest{}) - if err != nil { - return err - } - - c.setDefaultLanguageSession(Bash, session) - return nil + return session.run(ctx, request) } func (c *Controller) getBashSession(sessionId string) *bashSession { @@ -109,18 +86,14 @@ func (c *Controller) closeBashSession(sessionId string) error { return nil } -// CreateBashSession creates a pipe-based bash session for the session API only (POST /session). -// It is separate from CreateContext; Bash language context still uses Jupyter kernel. func (c *Controller) CreateBashSession(req *CreateContextRequest) (string, error) { return c.createBashSession(req) } -// RunInBashSession runs code in an existing bash session for the session API only (POST /session/:id/run). func (c *Controller) RunInBashSession(ctx context.Context, req *ExecuteCodeRequest) error { return c.runBashSession(ctx, req) } -// DeleteBashSession deletes a pipe-based bash session for the session API only (DELETE /session/:id). func (c *Controller) DeleteBashSession(sessionID string) error { return c.closeBashSession(sessionID) } @@ -172,7 +145,7 @@ func (s *bashSession) start() error { } //nolint:gocognit -func (s *bashSession) run(request *ExecuteCodeRequest) error { +func (s *bashSession) run(ctx context.Context, request *ExecuteCodeRequest) error { s.mu.Lock() if !s.started { s.mu.Unlock() @@ -199,7 +172,7 @@ func (s *bashSession) run(request *ExecuteCodeRequest) error { wait = 24 * 3600 * time.Second // max to 24 hours } - ctx, cancel := context.WithTimeout(context.Background(), wait) + ctx, cancel := context.WithTimeout(ctx, wait) defer cancel() script := buildWrappedScript(request.Code, envSnapshot, cwd) diff --git a/components/execd/pkg/runtime/bash_session_test.go b/components/execd/pkg/runtime/bash_session_test.go index 89ebba00e..971d8c1ca 100644 --- a/components/execd/pkg/runtime/bash_session_test.go +++ b/components/execd/pkg/runtime/bash_session_test.go @@ -115,7 +115,7 @@ func TestBashSession_envAndExitCode(t *testing.T) { Hooks: hooks, Timeout: 3 * time.Second, } - require.NoError(t, session.run(request)) + require.NoError(t, session.run(context.Background(), request)) exportStdoutCount := len(stdoutLines) // 2) verify env is persisted @@ -124,7 +124,7 @@ func TestBashSession_envAndExitCode(t *testing.T) { Hooks: hooks, Timeout: 3 * time.Second, } - require.NoError(t, session.run(request)) + require.NoError(t, session.run(context.Background(), request)) echoLines := stdoutLines[exportStdoutCount:] foundHello := false for _, line := range echoLines { @@ -142,7 +142,7 @@ func TestBashSession_envAndExitCode(t *testing.T) { Timeout: 3 * time.Second, } prevCount := len(stdoutLines) - require.NoError(t, session.run(request)) + require.NoError(t, session.run(context.Background(), request)) exitLines := stdoutLines[prevCount:] foundExit := false for _, line := range exitLines { @@ -189,7 +189,7 @@ func TestBashSession_envLargeOutputChained(t *testing.T) { Hooks: hooks, Timeout: 10 * time.Second, } - require.NoError(t, session.run(request)) + require.NoError(t, session.run(context.Background(), request)) return append([]string(nil), stdoutLines[start:]...) } @@ -225,7 +225,7 @@ func TestBashSession_cwdPersistsWithoutOverride(t *testing.T) { runAndCollect := func(req *ExecuteCodeRequest) []string { start := len(stdoutLines) - require.NoError(t, session.run(req)) + require.NoError(t, session.run(context.Background(), req)) return append([]string(nil), stdoutLines[start:]...) } @@ -267,7 +267,7 @@ func TestBashSession_requestCwdOverridesAfterCd(t *testing.T) { runAndCollect := func(req *ExecuteCodeRequest) []string { start := len(stdoutLines) - require.NoError(t, session.run(req)) + require.NoError(t, session.run(context.Background(), req)) return append([]string(nil), stdoutLines[start:]...) } @@ -312,7 +312,7 @@ func TestBashSession_envDumpNotLeakedWhenNoTrailingNewline(t *testing.T) { Hooks: hooks, Timeout: 3 * time.Second, } - require.NoError(t, session.run(request)) + require.NoError(t, session.run(context.Background(), request)) require.Len(t, stdoutLines, 1, "expected exactly one stdout line") require.Equal(t, `{"foo":1}`, strings.TrimSpace(stdoutLines[0])) @@ -340,7 +340,7 @@ func TestBashSession_envDumpNotLeakedWhenNoOutput(t *testing.T) { Hooks: hooks, Timeout: 3 * time.Second, } - require.NoError(t, session.run(request)) + require.NoError(t, session.run(context.Background(), request)) require.LessOrEqual(t, len(stdoutLines), 1, "expected at most one stdout line, got %v", stdoutLines) if len(stdoutLines) == 1 { @@ -432,7 +432,7 @@ exec /tmp/exec_child.sh Hooks: hooks, Timeout: 5 * time.Second, } - require.NoError(t, session.run(request), "expected exec to complete without killing the session") + require.NoError(t, session.run(context.Background(), request), "expected exec to complete without killing the session") require.True(t, containsLine(stdoutLines, "child says hi"), "expected child output, got %v", stdoutLines) // Subsequent run should still work because we restart bash per run. @@ -442,7 +442,7 @@ exec /tmp/exec_child.sh Timeout: 2 * time.Second, } stdoutLines = nil - require.NoError(t, session.run(request), "expected run to succeed after exec replaced the shell") + require.NoError(t, session.run(context.Background(), request), "expected run to succeed after exec replaced the shell") require.True(t, containsLine(stdoutLines, "still-alive"), "expected follow-up output, got %v", stdoutLines) } @@ -476,7 +476,7 @@ echo "after-restore" Hooks: hooks, Timeout: 5 * time.Second, } - require.NoError(t, session.run(request), "expected complex exec to finish") + require.NoError(t, session.run(context.Background(), request), "expected complex exec to finish") require.True(t, containsLine(stdoutLines, "from-complex-exec") && containsLine(stdoutLines, "after-restore"), "expected exec outputs, got %v", stdoutLines) // Session should still be usable. @@ -486,7 +486,7 @@ echo "after-restore" Timeout: 2 * time.Second, } stdoutLines = nil - require.NoError(t, session.run(request), "expected run to succeed after complex exec") + require.NoError(t, session.run(context.Background(), request), "expected run to succeed after complex exec") require.True(t, containsLine(stdoutLines, "still-alive"), "expected follow-up output, got %v", stdoutLines) } From 55f12e4d1479c9741b02dbf3bea12057ad489785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Mon, 16 Mar 2026 10:25:38 +0800 Subject: [PATCH 19/37] fix(execd): apply requested cwd during bash session creation --- components/execd/pkg/runtime/bash_session.go | 27 ++++-------- .../execd/pkg/runtime/bash_session_test.go | 20 +++++---- .../execd/pkg/runtime/bash_session_windows.go | 42 ------------------- components/execd/pkg/runtime/types.go | 2 + 4 files changed, 21 insertions(+), 70 deletions(-) diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index 02ebfea06..f40d3d21a 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -42,8 +42,8 @@ const ( pwdMarkerPrefix = "__PWD__:" ) -func (c *Controller) createBashSession(_ *CreateContextRequest) (string, error) { - session := newBashSession(nil) +func (c *Controller) createBashSession(req *CreateContextRequest) (string, error) { + session := newBashSession(req.Cwd) if err := session.start(); err != nil { return "", fmt.Errorf("failed to start bash session: %w", err) } @@ -98,25 +98,11 @@ func (c *Controller) DeleteBashSession(sessionID string) error { return c.closeBashSession(sessionID) } -// nolint:unused -func (c *Controller) listBashSessions() []string { - sessions := make([]string, 0) - c.bashSessionClientMap.Range(func(key, _ any) bool { - sessionID, _ := key.(string) - sessions = append(sessions, sessionID) - return true - }) - - return sessions -} - // Session implementation (pipe-based, no PTY) -func newBashSession(config *bashSessionConfig) *bashSession { - if config == nil { - config = &bashSessionConfig{ - Session: uuidString(), - StartupTimeout: 5 * time.Second, - } +func newBashSession(cwd string) *bashSession { + config := &bashSessionConfig{ + Session: uuidString(), + StartupTimeout: 5 * time.Second, } env := make(map[string]string) @@ -129,6 +115,7 @@ func newBashSession(config *bashSessionConfig) *bashSession { return &bashSession{ config: config, env: env, + cwd: cwd, } } diff --git a/components/execd/pkg/runtime/bash_session_test.go b/components/execd/pkg/runtime/bash_session_test.go index 971d8c1ca..b11f648f5 100644 --- a/components/execd/pkg/runtime/bash_session_test.go +++ b/components/execd/pkg/runtime/bash_session_test.go @@ -25,6 +25,7 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" @@ -62,6 +63,9 @@ func TestBashSession_NonZeroExitEmitsError(t *testing.T) { }, } + session, err := c.createBashSession(&CreateContextRequest{}) + assert.NoError(t, err) + req.Context = session require.NoError(t, c.runBashSession(ctx, req)) var gotErr *execute.ErrorOutput @@ -84,7 +88,7 @@ func TestBashSession_NonZeroExitEmitsError(t *testing.T) { } func TestBashSession_envAndExitCode(t *testing.T) { - session := newBashSession(nil) + session := newBashSession("") t.Cleanup(func() { _ = session.close() }) require.NoError(t, session.start()) @@ -157,7 +161,7 @@ func TestBashSession_envAndExitCode(t *testing.T) { } func TestBashSession_envLargeOutputChained(t *testing.T) { - session := newBashSession(nil) + session := newBashSession("") t.Cleanup(func() { _ = session.close() }) require.NoError(t, session.start()) @@ -210,7 +214,7 @@ func TestBashSession_envLargeOutputChained(t *testing.T) { } func TestBashSession_cwdPersistsWithoutOverride(t *testing.T) { - session := newBashSession(nil) + session := newBashSession("") t.Cleanup(func() { _ = session.close() }) require.NoError(t, session.start()) @@ -250,7 +254,7 @@ func TestBashSession_cwdPersistsWithoutOverride(t *testing.T) { } func TestBashSession_requestCwdOverridesAfterCd(t *testing.T) { - session := newBashSession(nil) + session := newBashSession("") t.Cleanup(func() { _ = session.close() }) require.NoError(t, session.start()) @@ -295,7 +299,7 @@ func TestBashSession_requestCwdOverridesAfterCd(t *testing.T) { } func TestBashSession_envDumpNotLeakedWhenNoTrailingNewline(t *testing.T) { - session := newBashSession(nil) + session := newBashSession("") t.Cleanup(func() { _ = session.close() }) require.NoError(t, session.start()) @@ -323,7 +327,7 @@ func TestBashSession_envDumpNotLeakedWhenNoTrailingNewline(t *testing.T) { } func TestBashSession_envDumpNotLeakedWhenNoOutput(t *testing.T) { - session := newBashSession(nil) + session := newBashSession("") t.Cleanup(func() { _ = session.close() }) require.NoError(t, session.start()) @@ -407,7 +411,7 @@ cat "$reward_dir/reward.txt" } func TestBashSession_execReplacesShell(t *testing.T) { - session := newBashSession(nil) + session := newBashSession("") t.Cleanup(func() { _ = session.close() }) require.NoError(t, session.start()) @@ -447,7 +451,7 @@ exec /tmp/exec_child.sh } func TestBashSession_complexExec(t *testing.T) { - session := newBashSession(nil) + session := newBashSession("") t.Cleanup(func() { _ = session.close() }) require.NoError(t, session.start()) diff --git a/components/execd/pkg/runtime/bash_session_windows.go b/components/execd/pkg/runtime/bash_session_windows.go index 32d399b61..d0cd008b8 100644 --- a/components/execd/pkg/runtime/bash_session_windows.go +++ b/components/execd/pkg/runtime/bash_session_windows.go @@ -20,35 +20,10 @@ package runtime import ( "context" "errors" - "time" ) var errBashSessionNotSupported = errors.New("bash session is not supported on windows") -func (c *Controller) createBashSession(_ *CreateContextRequest) (string, error) { - return "", errBashSessionNotSupported -} - -func (c *Controller) runBashSession(_ context.Context, _ *ExecuteCodeRequest) error { //nolint:revive - return errBashSessionNotSupported -} - -func (c *Controller) createDefaultBashSession() error { //nolint:revive - return errBashSessionNotSupported -} - -func (c *Controller) getBashSession(_ string) *bashSession { //nolint:revive - return nil -} - -func (c *Controller) closeBashSession(_ string) error { //nolint:revive - return errBashSessionNotSupported -} - -func (c *Controller) listBashSessions() []string { //nolint:revive - return nil -} - // CreateBashSession is not supported on Windows. func (c *Controller) CreateBashSession(_ *CreateContextRequest) (string, error) { //nolint:revive return "", errBashSessionNotSupported @@ -63,20 +38,3 @@ func (c *Controller) RunInBashSession(_ context.Context, _ *ExecuteCodeRequest) func (c *Controller) DeleteBashSession(_ string) error { //nolint:revive return errBashSessionNotSupported } - -// Stub methods on bashSession to satisfy interfaces on non-Linux platforms. -func newBashSession(config *bashSessionConfig) *bashSession { - return &bashSession{config: config} -} - -func (s *bashSession) start() error { - return errBashSessionNotSupported -} - -func (s *bashSession) run(_ string, _ time.Duration, _ *ExecuteResultHook) error { - return errBashSessionNotSupported -} - -func (s *bashSession) close() error { - return nil -} diff --git a/components/execd/pkg/runtime/types.go b/components/execd/pkg/runtime/types.go index f704378ed..f066da5ef 100644 --- a/components/execd/pkg/runtime/types.go +++ b/components/execd/pkg/runtime/types.go @@ -92,6 +92,8 @@ type bashSessionConfig struct { Session string // StartupTimeout is the startup timeout. StartupTimeout time.Duration + // Cwd is the working directory. + Cwd string } // bashSession represents a bash session. From f1ad75cd8d9bb53fd0e25ba6a3dee09a2d075dbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Mon, 16 Mar 2026 10:30:03 +0800 Subject: [PATCH 20/37] fix(execd): accept empty request bodies for session creation --- components/execd/pkg/web/controller/codeinterpreting.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/components/execd/pkg/web/controller/codeinterpreting.go b/components/execd/pkg/web/controller/codeinterpreting.go index edab5d622..c95d83f17 100644 --- a/components/execd/pkg/web/controller/codeinterpreting.go +++ b/components/execd/pkg/web/controller/codeinterpreting.go @@ -18,6 +18,7 @@ import ( "context" "errors" "fmt" + "io" "net/http" "sync" "time" @@ -237,9 +238,10 @@ func (c *CodeInterpretingController) DeleteContext() { } // CreateSession creates a new bash session (create_session API). +// An empty body is allowed and is treated as default options (no cwd override). func (c *CodeInterpretingController) CreateSession() { var request model.CreateSessionRequest - if err := c.bindJSON(&request); err != nil { + if err := c.bindJSON(&request); err != nil && !errors.Is(err, io.EOF) { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidRequest, From 83d3633a4547aeb4eef17fbc21cc756be5c2fb25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Mon, 16 Mar 2026 10:37:05 +0800 Subject: [PATCH 21/37] fix(execd): terminate running bash process when closing a session --- components/execd/pkg/runtime/bash_session.go | 27 +++++- .../execd/pkg/runtime/bash_session_test.go | 95 +++++++++++++++++++ components/execd/pkg/runtime/types.go | 4 + 3 files changed, 123 insertions(+), 3 deletions(-) diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index f40d3d21a..5e5d01d3c 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -27,6 +27,7 @@ import ( "sort" "strconv" "strings" + "syscall" "time" "github.com/google/uuid" @@ -131,6 +132,18 @@ func (s *bashSession) start() error { return nil } +func (s *bashSession) trackCurrentProcess(pid int) { + s.mu.Lock() + defer s.mu.Unlock() + s.currentProcessPid = pid +} + +func (s *bashSession) untrackCurrentProcess() { + s.mu.Lock() + defer s.mu.Unlock() + s.currentProcessPid = 0 +} + //nolint:gocognit func (s *bashSession) run(ctx context.Context, request *ExecuteCodeRequest) error { s.mu.Lock() @@ -177,6 +190,7 @@ func (s *bashSession) run(ctx context.Context, request *ExecuteCodeRequest) erro } cmd := exec.CommandContext(ctx, "bash", "--noprofile", "--norc", scriptPath) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // Do not pass envSnapshot via cmd.Env to avoid "argument list too long" when session env is large. // Child inherits parent env (nil => default in Go). The script file already has "export K=V" for // all session vars at the top, so the session environment is applied when the script runs. @@ -190,6 +204,8 @@ func (s *bashSession) run(ctx context.Context, request *ExecuteCodeRequest) erro log.Error("start bash session failed: %v (command: %q)", err, request.Code) return fmt.Errorf("start bash: %w", err) } + defer s.untrackCurrentProcess() + s.trackCurrentProcess(cmd.Process.Pid) scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) @@ -428,12 +444,17 @@ func (s *bashSession) close() error { s.mu.Lock() defer s.mu.Unlock() - if !s.started { - return nil - } + pid := s.currentProcessPid + s.currentProcessPid = 0 s.started = false s.env = nil s.cwd = "" + + if pid != 0 { + if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil { + log.Warning("kill session process group %d: %v (process may have already exited)", pid, err) + } + } return nil } diff --git a/components/execd/pkg/runtime/bash_session_test.go b/components/execd/pkg/runtime/bash_session_test.go index b11f648f5..b18af3de0 100644 --- a/components/execd/pkg/runtime/bash_session_test.go +++ b/components/execd/pkg/runtime/bash_session_test.go @@ -502,3 +502,98 @@ func containsLine(lines []string, target string) bool { } return false } + +// TestBashSession_CloseKillsRunningProcess verifies that session.close() kills the active +// process group so that a long-running command (e.g. sleep) does not keep running after close. +func TestBashSession_CloseKillsRunningProcess(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found in PATH") + } + + session := newBashSession("") + require.NoError(t, session.start()) + + runDone := make(chan error, 1) + req := &ExecuteCodeRequest{ + Code: "sleep 30", + Timeout: 60 * time.Second, + Hooks: ExecuteResultHook{}, + } + go func() { + runDone <- session.run(context.Background(), req) + }() + + // Give the child process time to start. + time.Sleep(200 * time.Millisecond) + + // Close should kill the process group; run() should return soon (it may return nil + // because the code path treats non-zero exit as success after calling OnExecuteError). + require.NoError(t, session.close()) + + select { + case <-runDone: + // run() returned; process was killed so we did not wait 30s + case <-time.After(3 * time.Second): + require.Fail(t, "run did not return within 3s after close (process was not killed)") + } +} + +// TestBashSession_DeleteBashSessionKillsRunningProcess verifies that DeleteBashSession +// (close path) kills the active run and removes the session from the controller. +func TestBashSession_DeleteBashSessionKillsRunningProcess(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found in PATH") + } + + c := NewController("", "") + sessionID, err := c.CreateBashSession(&CreateContextRequest{}) + require.NoError(t, err) + + runDone := make(chan error, 1) + req := &ExecuteCodeRequest{ + Language: Bash, + Context: sessionID, + Code: "sleep 30", + Timeout: 60 * time.Second, + Hooks: ExecuteResultHook{}, + } + go func() { + runDone <- c.RunInBashSession(context.Background(), req) + }() + + time.Sleep(200 * time.Millisecond) + + require.NoError(t, c.DeleteBashSession(sessionID)) + + select { + case <-runDone: + // RunInBashSession returned; process was killed + case <-time.After(3 * time.Second): + require.Fail(t, "RunInBashSession did not return within 3s after DeleteBashSession") + } + + // Session should be gone; deleting again should return ErrContextNotFound. + err = c.DeleteBashSession(sessionID) + require.Error(t, err) + require.ErrorIs(t, err, ErrContextNotFound) +} + +// TestBashSession_CloseWithNoActiveRun verifies that close() with no running command +// completes without error and does not hang. +func TestBashSession_CloseWithNoActiveRun(t *testing.T) { + session := newBashSession("") + require.NoError(t, session.start()) + + done := make(chan struct{}, 1) + go func() { + _ = session.close() + done <- struct{}{} + }() + + select { + case <-done: + // close() returned + case <-time.After(2 * time.Second): + require.Fail(t, "close() did not return within 2s when no run was active") + } +} diff --git a/components/execd/pkg/runtime/types.go b/components/execd/pkg/runtime/types.go index f066da5ef..cd0615c63 100644 --- a/components/execd/pkg/runtime/types.go +++ b/components/execd/pkg/runtime/types.go @@ -103,4 +103,8 @@ type bashSession struct { started bool env map[string]string cwd string + + // currentProcessPid is the pid of the active run's process group leader (bash). + // Set after cmd.Start(), cleared when run() returns. Used by close() to kill the process group. + currentProcessPid int } From 8c4c6f0d9404c19160079c5cac0a90f689b85da5 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Mon, 16 Mar 2026 04:42:41 -0400 Subject: [PATCH 22/37] ci: add GitHub Actions pipeline and FORK.md - .github/workflows/ci.yml: matrix build/vet/test on Go 1.21 and 1.22, working-directory components/execd, race detector enabled (60s timeout) - FORK.md: documents fork purpose (PAOP WebSocket steering), upstream sync instructions, and which phases are upstream candidates vs. PAOP-only Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 28 ++++++++++++++++++++ FORK.md | 57 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 FORK.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..0fabde53b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI +on: + push: + branches: [feat/paop-steering, main] + pull_request: + branches: [feat/paop-steering, main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + strategy: + matrix: + go: ["1.21", "1.22"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + cache: true + - name: Build + working-directory: components/execd + run: go build ./... + - name: Vet + working-directory: components/execd + run: go vet ./... + - name: Test + working-directory: components/execd + run: go test ./... -race -timeout 60s diff --git a/FORK.md b/FORK.md new file mode 100644 index 000000000..e65e8ebb2 --- /dev/null +++ b/FORK.md @@ -0,0 +1,57 @@ +# OpenSandbox Fork — PAOP WebSocket Steering Integration + +This is `danieliser/OpenSandbox`, a fork of [`alibaba/OpenSandbox`](https://github.com/alibaba/OpenSandbox). + +## Purpose + +This fork adds WebSocket-based steering support to `execd` so that PAOP (Persistent Agent +Orchestration Platform) can replace its tmux/poll executor with a push-based, in-container +execution model. + +Key goals: + +- Add `GET /ws/session/:sessionId` WebSocket endpoint to `components/execd` (Phase 1) +- Add PTY opt-in via `?pty=1` query parameter for interactive programs (Phase 2) +- Fix residual bugs from upstream PR #104 (`feat/bash-session`) (Phase 0) + +The PAOP-side counterpart lives in the `persistence` repo under `paop/executor/`. + +## Working Branch + +All active development happens on `feat/paop-steering`. + +## Upstream Sync + +To pull in upstream changes from `alibaba/OpenSandbox`: + +```bash +git fetch upstream +git checkout feat/paop-steering +git merge upstream/main +# Resolve conflicts, then push +git push origin feat/paop-steering +``` + +If the `upstream` remote is not yet configured: + +```bash +git remote add upstream https://github.com/alibaba/OpenSandbox.git +``` + +## What's PAOP-Only vs. Upstream Candidates + +| Phase | Changes | Upstream candidate? | +|-------|---------|---------------------| +| Phase 0 | Bug fixes for PR #104 (TOCTOU race, stderr routing, sentinel collision, context leak, shutdown race) | **Yes** — these are correctness fixes valuable to all users | +| Phase 1 | `GET /ws/session/:sessionId` WebSocket endpoint | **Possibly** — generic enough; needs upstream discussion | +| Phase 2 | PTY opt-in (`?pty=1`) | **Possibly** — generic; needs upstream discussion | +| Phase 3 | PAOP `WSExecutor` integration (lives in `persistence` repo) | **No** — PAOP-specific, stays here / in persistence repo | + +Phase 0 bug fixes are the strongest upstream PR candidates. They fix real correctness issues +independent of any PAOP integration and should be submitted back once validated. + +## CI + +GitHub Actions runs on every push and pull request targeting `feat/paop-steering` or `main`. +Matrix: Go 1.21 and 1.22. Steps: build, vet, race-detector test suite (60s timeout). +See `.github/workflows/ci.yml`. From c431dbbd5d2f0bd80b96d381e26138b0b65c5707 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Mon, 16 Mar 2026 04:42:48 -0400 Subject: [PATCH 23/37] fix: A3 sentinel markers, A4 cmd.Dir, A5 empty body guard A3: rename sentinel constants with nonce suffix to prevent collision with user code printing those strings (corrupts env/exit parsing) A4: set cmd.Dir = cwd before cmd.Start() so the bash process starts in the correct OS-level working directory A5: allow io.EOF in RunInSession bindJSON so empty POST body falls through to validate() and returns a clear 'code is required' error Co-Authored-By: Claude Sonnet 4.6 --- components/execd/pkg/runtime/bash_session.go | 45 ++++++++++++++----- .../pkg/web/controller/codeinterpreting.go | 2 +- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index 5e5d01d3c..e03a8cc3b 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -37,10 +37,10 @@ import ( ) const ( - envDumpStartMarker = "__ENV_DUMP_START__" - envDumpEndMarker = "__ENV_DUMP_END__" - exitMarkerPrefix = "__EXIT_CODE__:" - pwdMarkerPrefix = "__PWD__:" + envDumpStartMarker = "__EXECD_ENV_DUMP_START_8a3f__" + envDumpEndMarker = "__EXECD_ENV_DUMP_END_8a3f__" + exitMarkerPrefix = "__EXECD_EXIT_v1__:" + pwdMarkerPrefix = "__EXECD_PWD_v1__:" ) func (c *Controller) createBashSession(req *CreateContextRequest) (string, error) { @@ -135,6 +135,11 @@ func (s *bashSession) start() error { func (s *bashSession) trackCurrentProcess(pid int) { s.mu.Lock() defer s.mu.Unlock() + if s.closing { + // close() already ran while we were in cmd.Start(); kill immediately + _ = syscall.Kill(-pid, syscall.SIGKILL) + return + } s.currentProcessPid = pid } @@ -190,24 +195,39 @@ func (s *bashSession) run(ctx context.Context, request *ExecuteCodeRequest) erro } cmd := exec.CommandContext(ctx, "bash", "--noprofile", "--norc", scriptPath) + cmd.Dir = cwd // set OS-level CWD; harmless if cwd == "" (inherits daemon CWD) cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // Do not pass envSnapshot via cmd.Env to avoid "argument list too long" when session env is large. // Child inherits parent env (nil => default in Go). The script file already has "export K=V" for // all session vars at the top, so the session environment is applied when the script runs. - stdout, err := cmd.StdoutPipe() - if err != nil { - return fmt.Errorf("stdout pipe: %w", err) - } - cmd.Stderr = cmd.Stdout + stdoutR, stdoutW := io.Pipe() + stderrR, stderrW := io.Pipe() + cmd.Stdout = stdoutW + cmd.Stderr = stderrW if err := cmd.Start(); err != nil { + _ = stdoutW.Close() + _ = stderrW.Close() log.Error("start bash session failed: %v (command: %q)", err, request.Code) return fmt.Errorf("start bash: %w", err) } defer s.untrackCurrentProcess() s.trackCurrentProcess(cmd.Process.Pid) - scanner := bufio.NewScanner(stdout) + // Drain stderr in a separate goroutine; fire OnExecuteStderr for each line. + stderrDone := make(chan struct{}) + go func() { + defer close(stderrDone) + stderrScanner := bufio.NewScanner(stderrR) + stderrScanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + for stderrScanner.Scan() { + if request.Hooks.OnExecuteStderr != nil { + request.Hooks.OnExecuteStderr(stderrScanner.Text()) + } + } + }() + + scanner := bufio.NewScanner(stdoutR) scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) var ( @@ -243,6 +263,10 @@ func (s *bashSession) run(ctx context.Context, request *ExecuteCodeRequest) erro scanErr := scanner.Err() waitErr := cmd.Wait() + // Close write ends to unblock the stderr goroutine, then wait for it to drain. + _ = stdoutW.Close() + _ = stderrW.Close() + <-stderrDone if scanErr != nil { log.Error("read stdout failed: %v (command: %q)", scanErr, request.Code) @@ -444,6 +468,7 @@ func (s *bashSession) close() error { s.mu.Lock() defer s.mu.Unlock() + s.closing = true pid := s.currentProcessPid s.currentProcessPid = 0 s.started = false diff --git a/components/execd/pkg/web/controller/codeinterpreting.go b/components/execd/pkg/web/controller/codeinterpreting.go index c95d83f17..925e9f3c2 100644 --- a/components/execd/pkg/web/controller/codeinterpreting.go +++ b/components/execd/pkg/web/controller/codeinterpreting.go @@ -278,7 +278,7 @@ func (c *CodeInterpretingController) RunInSession() { } var request model.RunInSessionRequest - if err := c.bindJSON(&request); err != nil { + if err := c.bindJSON(&request); err != nil && !errors.Is(err, io.EOF) { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidRequest, From 5a6b006b4e020bef93cd49a66870ddde1a8c6c34 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Mon, 16 Mar 2026 04:43:09 -0400 Subject: [PATCH 24/37] fix: A1 TOCTOU race in trackCurrentProcess, A2 stderr pipe separation A1: Add closing bool field to bashSession struct; set s.closing = true in close() before reading pid so any concurrent trackCurrentProcess() call sees the flag and immediately SIGKILLs the new process group instead of storing a pid that will never be reaped. A2: Replace cmd.Stderr = cmd.Stdout (which merged stderr into the stdout pipe, silencing OnExecuteStderr) with two explicit io.Pipe() instances. A parallel goroutine drains stderrR and fires OnExecuteStderr per line; write ends are closed after cmd.Wait() to unblock the goroutine cleanly. Co-Authored-By: Claude Sonnet 4.6 --- components/execd/pkg/runtime/bash_session.go | 1 + components/execd/pkg/runtime/types.go | 1 + 2 files changed, 2 insertions(+) diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index e03a8cc3b..351ecaf98 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -22,6 +22,7 @@ import ( "context" "errors" "fmt" + "io" "os" "os/exec" "sort" diff --git a/components/execd/pkg/runtime/types.go b/components/execd/pkg/runtime/types.go index cd0615c63..1b2f5f6d6 100644 --- a/components/execd/pkg/runtime/types.go +++ b/components/execd/pkg/runtime/types.go @@ -101,6 +101,7 @@ type bashSession struct { config *bashSessionConfig mu sync.Mutex started bool + closing bool env map[string]string cwd string From 809d4216ccacfbb8eab9ee2eba9b5a187fa3d1b6 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Mon, 16 Mar 2026 04:48:18 -0400 Subject: [PATCH 25/37] feat: output replay buffer for WebSocket reconnect (Phase 0.5) - Add replayBuffer (1 MiB circular) in pkg/runtime/replay_buffer.go - Wire replay.write() into stdout and stderr scanner goroutines in bashSession - Add GET /session/:id returning SessionStatusResponse with output_offset - Add ?since= support on POST /session/:id/run (SSE replay event) - Add ReplaySessionOutput and GetBashSessionStatus to runtime.Controller - Add StreamEventTypeReplay constant to SSE event model - 5 unit tests: write/read, circular eviction, caught-up offset, large gap, concurrent Co-Authored-By: Claude Sonnet 4.6 --- components/execd/pkg/runtime/bash_session.go | 38 ++++++ components/execd/pkg/runtime/replay_buffer.go | 72 ++++++++++ .../execd/pkg/runtime/replay_buffer_test.go | 123 ++++++++++++++++++ components/execd/pkg/runtime/types.go | 3 + .../pkg/web/controller/codeinterpreting.go | 53 ++++++++ .../execd/pkg/web/model/codeinterpreting.go | 1 + components/execd/pkg/web/model/session.go | 7 + components/execd/pkg/web/router.go | 1 + 8 files changed, 298 insertions(+) create mode 100644 components/execd/pkg/runtime/replay_buffer.go create mode 100644 components/execd/pkg/runtime/replay_buffer_test.go diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index 351ecaf98..60c4ef5c7 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -100,6 +100,40 @@ func (c *Controller) DeleteBashSession(sessionID string) error { return c.closeBashSession(sessionID) } +// BashSessionStatus holds observable state for a bash session. +type BashSessionStatus struct { + SessionID string + Running bool + OutputOffset int64 +} + +// ReplaySessionOutput returns buffered output bytes starting from offset. +// Returns (data, nextOffset). See replayBuffer.readFrom for semantics. +func (c *Controller) ReplaySessionOutput(sessionID string, offset int64) ([]byte, int64, error) { + session := c.getBashSession(sessionID) + if session == nil { + return nil, 0, ErrContextNotFound + } + data, next := session.replay.readFrom(offset) + return data, next, nil +} + +// GetBashSessionStatus returns status info for a bash session, including replay buffer offset. +func (c *Controller) GetBashSessionStatus(sessionID string) (*BashSessionStatus, error) { + session := c.getBashSession(sessionID) + if session == nil { + return nil, ErrContextNotFound + } + session.mu.Lock() + running := session.started && !session.closing + session.mu.Unlock() + return &BashSessionStatus{ + SessionID: sessionID, + Running: running, + OutputOffset: session.replay.total, + }, nil +} + // Session implementation (pipe-based, no PTY) func newBashSession(cwd string) *bashSession { config := &bashSessionConfig{ @@ -118,6 +152,7 @@ func newBashSession(cwd string) *bashSession { config: config, env: env, cwd: cwd, + replay: newReplayBuffer(defaultReplayBufSize), } } @@ -222,6 +257,8 @@ func (s *bashSession) run(ctx context.Context, request *ExecuteCodeRequest) erro stderrScanner := bufio.NewScanner(stderrR) stderrScanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) for stderrScanner.Scan() { + line := stderrScanner.Text() + "\n" + s.replay.write([]byte(line)) if request.Hooks.OnExecuteStderr != nil { request.Hooks.OnExecuteStderr(stderrScanner.Text()) } @@ -256,6 +293,7 @@ func (s *bashSession) run(ctx context.Context, request *ExecuteCodeRequest) erro envLines = append(envLines, line) continue } + s.replay.write([]byte(line + "\n")) if request.Hooks.OnExecuteStdout != nil { request.Hooks.OnExecuteStdout(line) } diff --git a/components/execd/pkg/runtime/replay_buffer.go b/components/execd/pkg/runtime/replay_buffer.go new file mode 100644 index 000000000..a32012130 --- /dev/null +++ b/components/execd/pkg/runtime/replay_buffer.go @@ -0,0 +1,72 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import "sync" + +const defaultReplayBufSize = 1 << 20 // 1 MiB + +// replayBuffer is a bounded circular output buffer that allows reconnecting +// clients to replay missed output from a given byte offset. +type replayBuffer struct { + mu sync.Mutex + buf []byte // circular storage + size int // capacity + head int // next write position (wraps mod size) + total int64 // total bytes ever written (monotonic offset) +} + +func newReplayBuffer(size int) *replayBuffer { + return &replayBuffer{buf: make([]byte, size), size: size} +} + +// write appends p to the ring buffer, overwriting oldest bytes if full. +func (r *replayBuffer) write(p []byte) { + r.mu.Lock() + defer r.mu.Unlock() + for _, b := range p { + r.buf[r.head] = b + r.head = (r.head + 1) % r.size + r.total++ + } +} + +// readFrom returns all bytes from offset onward (up to buffer capacity). +// Returns (data, nextOffset). +// - If offset >= total, returns (nil, total) — client is caught up. +// - If offset is too old (evicted), reads from the oldest available byte. +func (r *replayBuffer) readFrom(offset int64) ([]byte, int64) { + r.mu.Lock() + defer r.mu.Unlock() + + oldest := r.total - int64(r.size) + if oldest < 0 { + oldest = 0 + } + if offset >= r.total { + return nil, r.total // nothing new + } + if offset < oldest { + offset = oldest // truncated — client missed some output + } + + n := int(r.total - offset) + out := make([]byte, n) + start := int(offset % int64(r.size)) + for i := 0; i < n; i++ { + out[i] = r.buf[(start+i)%r.size] + } + return out, r.total +} diff --git a/components/execd/pkg/runtime/replay_buffer_test.go b/components/execd/pkg/runtime/replay_buffer_test.go new file mode 100644 index 000000000..a5142e0fa --- /dev/null +++ b/components/execd/pkg/runtime/replay_buffer_test.go @@ -0,0 +1,123 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import ( + "bytes" + "sync" + "testing" +) + +func TestReplayBuffer_WriteAndRead(t *testing.T) { + rb := newReplayBuffer(64) + data := []byte("hello world\n") + rb.write(data) + + got, next := rb.readFrom(0) + if !bytes.Equal(got, data) { + t.Fatalf("expected %q, got %q", data, got) + } + if next != int64(len(data)) { + t.Fatalf("expected next=%d, got %d", len(data), next) + } +} + +func TestReplayBuffer_CircularEviction(t *testing.T) { + size := 16 + rb := newReplayBuffer(size) + + // Write 20 bytes — 4 bytes will be evicted. + first := []byte("AAAA") // will be evicted + second := []byte("BBBBBBBBBBBBBBBB") // 16 bytes fills the buffer + rb.write(first) + rb.write(second) + + // total == 20, oldest == 4 + got, next := rb.readFrom(0) // offset 0 is too old, should be clamped to oldest + if next != 20 { + t.Fatalf("expected next=20, got %d", next) + } + // Should get exactly 16 bytes (the second write, which overwrote first) + if len(got) != size { + t.Fatalf("expected %d bytes, got %d", size, len(got)) + } + if !bytes.Equal(got, second) { + t.Fatalf("expected %q, got %q", second, got) + } +} + +func TestReplayBuffer_OffsetCaughtUp(t *testing.T) { + rb := newReplayBuffer(64) + rb.write([]byte("some output\n")) + + total := rb.total + got, next := rb.readFrom(total) + if got != nil { + t.Fatalf("expected nil for caught-up offset, got %q", got) + } + if next != total { + t.Fatalf("expected next=%d, got %d", total, next) + } +} + +func TestReplayBuffer_LargeGap(t *testing.T) { + size := 8 + rb := newReplayBuffer(size) + + // Write 16 bytes total — first 8 are evicted. + rb.write([]byte("12345678")) // bytes 0-7 + rb.write([]byte("ABCDEFGH")) // bytes 8-15 + + // Request from offset 0 (evicted) — should return from oldest available (offset 8). + got, next := rb.readFrom(0) + if next != 16 { + t.Fatalf("expected next=16, got %d", next) + } + if !bytes.Equal(got, []byte("ABCDEFGH")) { + t.Fatalf("expected oldest available data, got %q", got) + } +} + +func TestReplayBuffer_Concurrent(t *testing.T) { + rb := newReplayBuffer(1024) + chunk := []byte("x") + + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + rb.write(chunk) + } + }() + } + + // Concurrent reader + wg.Add(1) + go func() { + defer wg.Done() + var off int64 + for i := 0; i < 50; i++ { + _, off = rb.readFrom(off) + } + }() + + wg.Wait() + + if rb.total != 1000 { + t.Fatalf("expected total=1000, got %d", rb.total) + } +} diff --git a/components/execd/pkg/runtime/types.go b/components/execd/pkg/runtime/types.go index 1b2f5f6d6..65ec5566f 100644 --- a/components/execd/pkg/runtime/types.go +++ b/components/execd/pkg/runtime/types.go @@ -108,4 +108,7 @@ type bashSession struct { // currentProcessPid is the pid of the active run's process group leader (bash). // Set after cmd.Start(), cleared when run() returns. Used by close() to kill the process group. currentProcessPid int + + // replay buffers all output so reconnecting clients can catch up on missed bytes. + replay *replayBuffer } diff --git a/components/execd/pkg/web/controller/codeinterpreting.go b/components/execd/pkg/web/controller/codeinterpreting.go index 925e9f3c2..098ff7a57 100644 --- a/components/execd/pkg/web/controller/codeinterpreting.go +++ b/components/execd/pkg/web/controller/codeinterpreting.go @@ -20,6 +20,7 @@ import ( "fmt" "io" "net/http" + "strconv" "sync" "time" @@ -308,6 +309,21 @@ func (c *CodeInterpretingController) RunInSession() { runReq.Hooks = c.setServerEventsHandler(ctx) c.setupSSEResponse() + + // If ?since= is provided, replay buffered output before live stream. + if sinceStr := c.ctx.Query("since"); sinceStr != "" { + if since, err := strconv.ParseInt(sinceStr, 10, 64); err == nil { + if replayData, _, replayErr := codeRunner.ReplaySessionOutput(sessionID, since); replayErr == nil && len(replayData) > 0 { + event := model.ServerStreamEvent{ + Type: model.StreamEventTypeReplay, + Text: string(replayData), + Timestamp: time.Now().UnixMilli(), + } + c.writeSingleEvent("Replay", event.ToJSON(), true, event.Summary()) + } + } + } + err := codeRunner.RunInBashSession(ctx, runReq) if err != nil { c.RespondError( @@ -321,6 +337,43 @@ func (c *CodeInterpretingController) RunInSession() { time.Sleep(flag.ApiGracefulShutdownTimeout) } +// GetSessionStatus returns status and replay buffer offset for a bash session. +func (c *CodeInterpretingController) GetSessionStatus() { + sessionID := c.ctx.Param("sessionId") + if sessionID == "" { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeMissingQuery, + "missing path parameter 'sessionId'", + ) + return + } + + status, err := codeRunner.GetBashSessionStatus(sessionID) + if err != nil { + if errors.Is(err, runtime.ErrContextNotFound) { + c.RespondError( + http.StatusNotFound, + model.ErrorCodeContextNotFound, + fmt.Sprintf("session %s not found", sessionID), + ) + return + } + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error getting session status. %v", err), + ) + return + } + + c.RespondSuccess(model.SessionStatusResponse{ + SessionID: status.SessionID, + Running: status.Running, + OutputOffset: status.OutputOffset, + }) +} + // DeleteSession deletes a bash session (delete_session API). func (c *CodeInterpretingController) DeleteSession() { sessionID := c.ctx.Param("sessionId") diff --git a/components/execd/pkg/web/model/codeinterpreting.go b/components/execd/pkg/web/model/codeinterpreting.go index 771b6d75b..5f8bafdca 100644 --- a/components/execd/pkg/web/model/codeinterpreting.go +++ b/components/execd/pkg/web/model/codeinterpreting.go @@ -83,6 +83,7 @@ const ( StreamEventTypeComplete ServerStreamEventType = "execution_complete" StreamEventTypeCount ServerStreamEventType = "execution_count" StreamEventTypePing ServerStreamEventType = "ping" + StreamEventTypeReplay ServerStreamEventType = "replay" ) // ServerStreamEvent is emitted to clients over SSE. diff --git a/components/execd/pkg/web/model/session.go b/components/execd/pkg/web/model/session.go index 0b4f598b7..0acba83eb 100644 --- a/components/execd/pkg/web/model/session.go +++ b/components/execd/pkg/web/model/session.go @@ -40,3 +40,10 @@ func (r *RunInSessionRequest) Validate() error { validate := validator.New() return validate.Struct(r) } + +// SessionStatusResponse is the response for GET /session/:id. +type SessionStatusResponse struct { + SessionID string `json:"session_id"` + Running bool `json:"running"` + OutputOffset int64 `json:"output_offset"` +} diff --git a/components/execd/pkg/web/router.go b/components/execd/pkg/web/router.go index 8894257d2..afa6c40de 100644 --- a/components/execd/pkg/web/router.go +++ b/components/execd/pkg/web/router.go @@ -65,6 +65,7 @@ func NewRouter(accessToken string) *gin.Engine { session := r.Group("/session") { session.POST("", withCode(func(c *controller.CodeInterpretingController) { c.CreateSession() })) + session.GET("/:sessionId", withCode(func(c *controller.CodeInterpretingController) { c.GetSessionStatus() })) session.POST("/:sessionId/run", withCode(func(c *controller.CodeInterpretingController) { c.RunInSession() })) session.DELETE("/:sessionId", withCode(func(c *controller.CodeInterpretingController) { c.DeleteSession() })) } From 2592a7e881404d7aefa5b25fcc8fee83abc3b225 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Mon, 16 Mar 2026 05:04:09 -0400 Subject: [PATCH 26/37] feat: WebSocket stdin endpoint GET /ws/session/:sessionId (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements bidirectional WebSocket steering for bash sessions: - New GET /ws/session/:sessionId endpoint (registered under /ws group) - pkg/web/model/session_ws.go: ClientFrame/ServerFrame types + WSErrCode constants - pkg/web/controller/session_ws.go: SessionWebSocket handler - HTTP 404 before upgrade for unknown sessions - Exclusive WS lock (HTTP 409 if already connected) - gorilla/websocket upgrade with CheckOrigin: allow all (container env) - Start() launches interactive bash if not already running - ?since= replay from replayBuffer before entering live stream - connected frame immediately after upgrade - Write-serialized stdout/stderr scanner goroutines → replay buffer + WS frames - Exit watcher: closes doneCh → exit frame with code - RFC 6455 ping/pong keepalive (30s interval, 60s read deadline) - Application-level ping/pong frame support - stdin/signal/resize frame handling - pkg/runtime/types.go: BashSession interface + WS fields on bashSession struct - pkg/runtime/bash_session.go: Start() (interactive bash with os.Pipe), LockWS/UnlockWS, IsRunning, ExitCode, StdoutPipe, StderrPipe, Done, SendSignal, signalByName, WriteSessionOutput on Controller - pkg/runtime/bash_session_windows.go: Windows stubs for new Controller methods - pkg/runtime/ctrl.go: GetBashSession returning BashSession interface - 5 tests: ConnectUnknownSession, PingPong, StdinForwarding, ReplayOnConnect, ExitFrame Co-Authored-By: Claude Sonnet 4.6 --- components/execd/pkg/runtime/bash_session.go | 188 +++++++++++- .../execd/pkg/runtime/bash_session_windows.go | 25 ++ components/execd/pkg/runtime/ctrl.go | 9 + components/execd/pkg/runtime/types.go | 34 +++ .../execd/pkg/web/controller/session_ws.go | 238 +++++++++++++++ .../pkg/web/controller/session_ws_test.go | 285 ++++++++++++++++++ components/execd/pkg/web/model/session_ws.go | 46 +++ components/execd/pkg/web/router.go | 5 + 8 files changed, 826 insertions(+), 4 deletions(-) create mode 100644 components/execd/pkg/web/controller/session_ws.go create mode 100644 components/execd/pkg/web/controller/session_ws_test.go create mode 100644 components/execd/pkg/web/model/session_ws.go diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index 60c4ef5c7..cd7c307d4 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -107,6 +107,16 @@ type BashSessionStatus struct { OutputOffset int64 } +// WriteSessionOutput appends data to the replay buffer for the named session. +// Used by the WebSocket handler to persist live output for reconnect replay. +func (c *Controller) WriteSessionOutput(sessionID string, data []byte) { + s := c.getBashSession(sessionID) + if s == nil { + return + } + s.replay.write(data) +} + // ReplaySessionOutput returns buffered output bytes starting from offset. // Returns (data, nextOffset). See replayBuffer.readFrom for semantics. func (c *Controller) ReplaySessionOutput(sessionID string, offset int64) ([]byte, int64, error) { @@ -149,10 +159,11 @@ func newBashSession(cwd string) *bashSession { } return &bashSession{ - config: config, - env: env, - cwd: cwd, - replay: newReplayBuffer(defaultReplayBufSize), + config: config, + env: env, + cwd: cwd, + replay: newReplayBuffer(defaultReplayBufSize), + lastExitCode: -1, } } @@ -168,6 +179,175 @@ func (s *bashSession) start() error { return nil } +// Start launches an interactive bash process for WebSocket stdin/stdout mode. +// It is idempotent: if the process is already running, it returns nil. +// Unlike run(), this bash process stays alive reading from stdin until closed. +func (s *bashSession) Start() error { + s.mu.Lock() + if s.currentProcessPid != 0 { + s.mu.Unlock() + return nil // already running + } + if s.closing { + s.mu.Unlock() + return errors.New("session is closing") + } + s.mu.Unlock() + + cmd := exec.Command("bash", "--noprofile", "--norc") + if s.cwd != "" { + cmd.Dir = s.cwd + } + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + stdinR, stdinW, err := os.Pipe() + if err != nil { + return fmt.Errorf("create stdin pipe: %w", err) + } + stdoutR, stdoutW, err := os.Pipe() + if err != nil { + _ = stdinR.Close() + _ = stdinW.Close() + return fmt.Errorf("create stdout pipe: %w", err) + } + stderrR, stderrW, err := os.Pipe() + if err != nil { + _ = stdinR.Close() + _ = stdinW.Close() + _ = stdoutR.Close() + _ = stdoutW.Close() + return fmt.Errorf("create stderr pipe: %w", err) + } + + cmd.Stdin = stdinR + cmd.Stdout = stdoutW + cmd.Stderr = stderrW + + if err := cmd.Start(); err != nil { + _ = stdinR.Close() + _ = stdinW.Close() + _ = stdoutR.Close() + _ = stdoutW.Close() + _ = stderrR.Close() + _ = stderrW.Close() + return fmt.Errorf("start bash: %w", err) + } + + // Close child-side ends in the parent process. + _ = stdinR.Close() + _ = stdoutW.Close() + _ = stderrW.Close() + + doneCh := make(chan struct{}) + + s.mu.Lock() + s.stdin = stdinW + s.stdoutPipe = stdoutR + s.stderrPipe = stderrR + s.doneCh = doneCh + s.currentProcessPid = cmd.Process.Pid + s.started = true + s.mu.Unlock() + + go func() { + _ = cmd.Wait() + code := -1 + if cmd.ProcessState != nil { + code = cmd.ProcessState.ExitCode() + } + _ = stdinW.Close() + s.mu.Lock() + s.lastExitCode = code + s.currentProcessPid = 0 + s.mu.Unlock() + close(doneCh) + }() + + return nil +} + +// SendSignal sends a named OS signal (e.g. "SIGINT") to the session's process group. +// No-op if the session is not running or the signal name is unknown. +func (s *bashSession) SendSignal(name string) { + s.mu.Lock() + pid := s.currentProcessPid + s.mu.Unlock() + if pid == 0 { + return + } + sig := signalByName(name) + if sig == 0 { + return + } + _ = syscall.Kill(-pid, sig) +} + +// signalByName maps a POSIX signal name to its syscall.Signal number. +// Returns 0 for unknown names. +func signalByName(name string) syscall.Signal { + switch name { + case "SIGINT": + return syscall.SIGINT + case "SIGTERM": + return syscall.SIGTERM + case "SIGKILL": + return syscall.SIGKILL + case "SIGQUIT": + return syscall.SIGQUIT + case "SIGHUP": + return syscall.SIGHUP + default: + return 0 + } +} + +// WriteStdin writes p to the session's stdin pipe. +// Returns error if the session has not started or the pipe is closed. +func (s *bashSession) WriteStdin(p []byte) (int, error) { + s.mu.Lock() + w := s.stdin + s.mu.Unlock() + if w == nil { + return 0, errors.New("session not started") + } + return w.Write(p) +} + +// LockWS atomically acquires exclusive WebSocket access. +// Returns false if already locked. +func (s *bashSession) LockWS() bool { + return s.wsConnected.CompareAndSwap(false, true) +} + +// UnlockWS releases the WebSocket connection lock. +func (s *bashSession) UnlockWS() { + s.wsConnected.Store(false) +} + +// IsRunning reports whether the bash process is currently alive. +func (s *bashSession) IsRunning() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.currentProcessPid != 0 +} + +// ExitCode returns the exit code of the most recently completed process. +// Returns -1 if the process has not yet exited. +func (s *bashSession) ExitCode() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.lastExitCode +} + +// StdoutPipe returns the reader for the process's stdout. +func (s *bashSession) StdoutPipe() io.Reader { return s.stdoutPipe } + +// StderrPipe returns the reader for the process's stderr. +func (s *bashSession) StderrPipe() io.Reader { return s.stderrPipe } + +// Done returns a channel that is closed when the WS-mode bash process exits. +func (s *bashSession) Done() <-chan struct{} { return s.doneCh } + func (s *bashSession) trackCurrentProcess(pid int) { s.mu.Lock() defer s.mu.Unlock() diff --git a/components/execd/pkg/runtime/bash_session_windows.go b/components/execd/pkg/runtime/bash_session_windows.go index d0cd008b8..0891128a2 100644 --- a/components/execd/pkg/runtime/bash_session_windows.go +++ b/components/execd/pkg/runtime/bash_session_windows.go @@ -24,6 +24,13 @@ import ( var errBashSessionNotSupported = errors.New("bash session is not supported on windows") +// BashSessionStatus holds observable state for a bash session. +type BashSessionStatus struct { + SessionID string + Running bool + OutputOffset int64 +} + // CreateBashSession is not supported on Windows. func (c *Controller) CreateBashSession(_ *CreateContextRequest) (string, error) { //nolint:revive return "", errBashSessionNotSupported @@ -38,3 +45,21 @@ func (c *Controller) RunInBashSession(_ context.Context, _ *ExecuteCodeRequest) func (c *Controller) DeleteBashSession(_ string) error { //nolint:revive return errBashSessionNotSupported } + +// GetBashSession is not supported on Windows. +func (c *Controller) GetBashSession(_ string) BashSession { //nolint:revive + return nil +} + +// GetBashSessionStatus is not supported on Windows. +func (c *Controller) GetBashSessionStatus(_ string) (*BashSessionStatus, error) { //nolint:revive + return nil, errBashSessionNotSupported +} + +// ReplaySessionOutput is not supported on Windows. +func (c *Controller) ReplaySessionOutput(_ string, _ int64) ([]byte, int64, error) { //nolint:revive + return nil, 0, errBashSessionNotSupported +} + +// WriteSessionOutput is not supported on Windows. +func (c *Controller) WriteSessionOutput(_ string, _ []byte) {} //nolint:revive diff --git a/components/execd/pkg/runtime/ctrl.go b/components/execd/pkg/runtime/ctrl.go index 3a835b317..ad0d5f1fa 100644 --- a/components/execd/pkg/runtime/ctrl.go +++ b/components/execd/pkg/runtime/ctrl.go @@ -66,6 +66,15 @@ type commandKernel struct { content string } +// GetBashSession retrieves a bash session by ID. Returns nil if not found. +func (c *Controller) GetBashSession(sessionID string) BashSession { + s := c.getBashSession(sessionID) + if s == nil { + return nil + } + return s +} + // NewController creates a runtime controller. func NewController(baseURL, token string) *Controller { return &Controller{ diff --git a/components/execd/pkg/runtime/types.go b/components/execd/pkg/runtime/types.go index 65ec5566f..16c850908 100644 --- a/components/execd/pkg/runtime/types.go +++ b/components/execd/pkg/runtime/types.go @@ -16,7 +16,9 @@ package runtime import ( "fmt" + "io" "sync" + "sync/atomic" "time" "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" @@ -84,6 +86,30 @@ type CodeContext struct { Language Language `json:"language"` } +// BashSession is the interface exposed to callers outside the runtime package. +type BashSession interface { + // LockWS atomically acquires exclusive WebSocket access. Returns false if already locked. + LockWS() bool + // UnlockWS releases the WebSocket connection lock. + UnlockWS() + // Start launches the underlying bash process (idempotent: no-op if already running). + Start() error + // IsRunning reports whether the bash process is currently alive. + IsRunning() bool + // ExitCode returns the exit code of the most recently completed process (-1 if not exited). + ExitCode() int + // WriteStdin writes p to the session's stdin pipe. + WriteStdin(p []byte) (int, error) + // StdoutPipe returns the stdout reader. + StdoutPipe() io.Reader + // StderrPipe returns the stderr reader. + StderrPipe() io.Reader + // Done returns a channel closed when the bash process exits. + Done() <-chan struct{} + // SendSignal sends a named signal (e.g. "SIGINT") to the process group. + SendSignal(name string) +} + // bashSessionConfig holds bash session configuration. type bashSessionConfig struct { // StartupSource is a list of scripts sourced on startup. @@ -111,4 +137,12 @@ type bashSession struct { // replay buffers all output so reconnecting clients can catch up on missed bytes. replay *replayBuffer + + // WS mode fields — set by start() when a WebSocket client connects. + wsConnected atomic.Bool // true while a WS connection holds the session + lastExitCode int // stored on process exit; -1 if not yet exited + stdin io.WriteCloser // write end of bash's stdin pipe (WS mode) + stdoutPipe io.Reader // stdout reader (WS mode) + stderrPipe io.Reader // stderr reader (WS mode) + doneCh chan struct{} // closed when WS-mode bash process exits } diff --git a/components/execd/pkg/web/controller/session_ws.go b/components/execd/pkg/web/controller/session_ws.go new file mode 100644 index 000000000..f89a95491 --- /dev/null +++ b/components/execd/pkg/web/controller/session_ws.go @@ -0,0 +1,238 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "bufio" + "context" + "net/http" + "strconv" + "sync" + "time" + + "github.com/gorilla/websocket" + + "github.com/alibaba/opensandbox/execd/pkg/web/model" +) + +var wsUpgrader = websocket.Upgrader{ + ReadBufferSize: 4096, + WriteBufferSize: 4096, + // execd runs inside a container; auth-header check is the access gate. + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// SessionWebSocket handles GET /ws/session/:sessionId — bidirectional stdin/stdout steering. +func (c *CodeInterpretingController) SessionWebSocket() { + sessionID := c.ctx.Param("sessionId") + + // 1. Look up session BEFORE upgrade so we can still return HTTP errors. + session := codeRunner.GetBashSession(sessionID) + if session == nil { + c.RespondError(http.StatusNotFound, model.ErrorCodeContextNotFound, "session not found") + return + } + + // 2. Acquire exclusive WS lock (prevents concurrent connections). + if !session.LockWS() { + c.RespondError(http.StatusConflict, model.ErrorCodeRuntimeError, "session already connected") + return + } + defer session.UnlockWS() + + // 3. Upgrade HTTP → WebSocket. + conn, err := wsUpgrader.Upgrade(c.ctx.Writer, c.ctx.Request, nil) + if err != nil { + // gorilla writes the HTTP error response automatically. + return + } + defer conn.Close() + + // writeMu serializes all writes to conn — gorilla/websocket requires this. + var writeMu sync.Mutex + writeJSON := func(v any) error { + writeMu.Lock() + defer writeMu.Unlock() + conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) //nolint:errcheck + return conn.WriteJSON(v) + } + + // 4. Start bash if not already running. + if !session.IsRunning() { + if startErr := session.Start(); startErr != nil { + _ = writeJSON(model.ServerFrame{ + Type: "error", + Error: "failed to start bash", + Code: model.WSErrCodeStartFailed, + }) + return + } + } + + // 5. Replay buffered output if ?since= is provided. + if sinceStr := c.ctx.Query("since"); sinceStr != "" { + if since, parseErr := strconv.ParseInt(sinceStr, 10, 64); parseErr == nil { + replayData, nextOffset, _ := codeRunner.ReplaySessionOutput(sessionID, since) + if len(replayData) > 0 { + _ = writeJSON(model.ServerFrame{ + Type: "replay", + Data: string(replayData), + Offset: nextOffset, + }) + } + } + } + + // 6. Send connected frame. + _ = writeJSON(model.ServerFrame{ + Type: "connected", + SessionID: sessionID, + Mode: "pipe", + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // 7. Ping/pong keepalive — RFC 6455 control-level pings every 30s. + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(60 * time.Second)) //nolint:errcheck + return nil + }) + go func() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + writeMu.Lock() + conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) //nolint:errcheck + writeErr := conn.WriteMessage(websocket.PingMessage, nil) + writeMu.Unlock() + if writeErr != nil { + cancel() + return + } + } + } + }() + + // 8. Write pump — stdout scanner. + go func() { + stdout := session.StdoutPipe() + if stdout == nil { + return + } + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + select { + case <-ctx.Done(): + return + default: + } + line := scanner.Text() + "\n" + // Write to replay buffer so reconnecting clients can catch up. + codeRunner.WriteSessionOutput(sessionID, []byte(line)) + if writeErr := writeJSON(model.ServerFrame{ + Type: "stdout", + Data: line, + Timestamp: time.Now().UnixMilli(), + }); writeErr != nil { + return + } + } + }() + + // 9. Write pump — stderr scanner. + go func() { + stderr := session.StderrPipe() + if stderr == nil { + return + } + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + select { + case <-ctx.Done(): + return + default: + } + line := scanner.Text() + "\n" + // Write to replay buffer so reconnecting clients can catch up. + codeRunner.WriteSessionOutput(sessionID, []byte(line)) + if writeErr := writeJSON(model.ServerFrame{ + Type: "stderr", + Data: line, + Timestamp: time.Now().UnixMilli(), + }); writeErr != nil { + return + } + } + }() + + // 10. Exit watcher — sends exit frame when bash process ends. + go func() { + defer cancel() + doneCh := session.Done() + if doneCh == nil { + return + } + select { + case <-ctx.Done(): + return + case <-doneCh: + } + exitCode := session.ExitCode() + _ = writeJSON(model.ServerFrame{Type: "exit", ExitCode: &exitCode}) + }() + + // 11. Read pump — client → bash stdin. + conn.SetReadDeadline(time.Now().Add(60 * time.Second)) //nolint:errcheck + for { + var frame model.ClientFrame + if readErr := conn.ReadJSON(&frame); readErr != nil { + if ctx.Err() == nil { + cancel() + } + break + } + conn.SetReadDeadline(time.Now().Add(60 * time.Second)) //nolint:errcheck + + switch frame.Type { + case "stdin": + if _, writeErr := session.WriteStdin([]byte(frame.Data)); writeErr != nil { + _ = writeJSON(model.ServerFrame{ + Type: "error", + Error: writeErr.Error(), + Code: model.WSErrCodeStdinWriteFailed, + }) + cancel() + return + } + case "signal": + session.SendSignal(frame.Signal) + case "resize": + // Silently ignored in pipe mode; accepted to avoid client errors. + case "ping": + _ = writeJSON(model.ServerFrame{Type: "pong"}) + default: + _ = writeJSON(model.ServerFrame{ + Type: "error", + Error: "unknown frame type", + Code: model.WSErrCodeInvalidFrame, + }) + } + } +} diff --git a/components/execd/pkg/web/controller/session_ws_test.go b/components/execd/pkg/web/controller/session_ws_test.go new file mode 100644 index 000000000..d3ce0bd4a --- /dev/null +++ b/components/execd/pkg/web/controller/session_ws_test.go @@ -0,0 +1,285 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows +// +build !windows + +package controller + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/require" + + "github.com/alibaba/opensandbox/execd/pkg/runtime" + "github.com/alibaba/opensandbox/execd/pkg/web/model" +) + +// wsTestServer spins up a real httptest.Server with the WS route wired in. +func wsTestServer(t *testing.T) *httptest.Server { + t.Helper() + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/ws/session/:sessionId", func(ctx *gin.Context) { + NewCodeInterpretingController(ctx).SessionWebSocket() + }) + return httptest.NewServer(r) +} + +// wsURL converts an http:// test-server URL to a ws:// URL. +func wsURL(srv *httptest.Server, sessionID string) string { + return "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/session/" + sessionID +} + +// wsURLWithSince appends ?since= to the WS URL. +func wsURLWithSince(srv *httptest.Server, sessionID string, since int64) string { + return wsURL(srv, sessionID) + "?since=" + strconv.FormatInt(since, 10) +} + +// dialWS opens a WebSocket connection to the test server. +func dialWS(t *testing.T, url string) *websocket.Conn { + t.Helper() + dialer := websocket.DefaultDialer + conn, resp, err := dialer.Dial(url, nil) + if err != nil { + if resp != nil { + t.Fatalf("WS dial failed: %v (HTTP %d)", err, resp.StatusCode) + } + t.Fatalf("WS dial failed: %v", err) + } + t.Cleanup(func() { conn.Close() }) + return conn +} + +// readFrame reads one JSON ServerFrame from the WebSocket connection. +func readFrame(t *testing.T, conn *websocket.Conn, timeout time.Duration) model.ServerFrame { + t.Helper() + conn.SetReadDeadline(time.Now().Add(timeout)) + var frame model.ServerFrame + _, msg, err := conn.ReadMessage() + require.NoError(t, err, "reading WS frame") + require.NoError(t, json.Unmarshal(msg, &frame), "unmarshalling WS frame") + return frame +} + +// withFreshRunner swaps codeRunner for a clean controller and restores on cleanup. +func withFreshRunner(t *testing.T) { + t.Helper() + prev := codeRunner + codeRunner = runtime.NewController("", "") + t.Cleanup(func() { codeRunner = prev }) +} + +// createTestSession creates a bash session and returns its ID. +func createTestSession(t *testing.T) string { + t.Helper() + id, err := codeRunner.CreateBashSession(&runtime.CreateContextRequest{}) + require.NoError(t, err) + t.Cleanup(func() { _ = codeRunner.DeleteBashSession(id) }) + return id +} + +// TestSessionWS_ConnectUnknownSession verifies that connecting to a non-existent +// session returns HTTP 404 before the WebSocket upgrade. +func TestSessionWS_ConnectUnknownSession(t *testing.T) { + withFreshRunner(t) + srv := wsTestServer(t) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/ws/session/does-not-exist") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +// TestSessionWS_PingPong sends an application-level ping frame and expects a pong. +func TestSessionWS_PingPong(t *testing.T) { + withFreshRunner(t) + srv := wsTestServer(t) + defer srv.Close() + + id := createTestSession(t) + conn := dialWS(t, wsURL(srv, id)) + + // Drain the "connected" frame. + connected := readFrame(t, conn, 5*time.Second) + require.Equal(t, "connected", connected.Type) + + // Send application ping. + require.NoError(t, conn.WriteJSON(model.ClientFrame{Type: "ping"})) + + // Expect pong. + frame := readFrame(t, conn, 5*time.Second) + require.Equal(t, "pong", frame.Type) +} + +// TestSessionWS_StdinForwarding connects to a session, sends a stdin frame +// with an echo command, and verifies that a stdout frame arrives. +func TestSessionWS_StdinForwarding(t *testing.T) { + withFreshRunner(t) + srv := wsTestServer(t) + defer srv.Close() + + id := createTestSession(t) + conn := dialWS(t, wsURL(srv, id)) + + // Drain "connected". + connected := readFrame(t, conn, 5*time.Second) + require.Equal(t, "connected", connected.Type) + + // Send a command via stdin. + require.NoError(t, conn.WriteJSON(model.ClientFrame{ + Type: "stdin", + Data: "echo hello_ws\n", + })) + + // Collect frames until we see the expected stdout or timeout. + deadline := time.Now().Add(10 * time.Second) + found := false + for time.Now().Before(deadline) { + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + var f model.ServerFrame + _, msg, err := conn.ReadMessage() + if err != nil { + break + } + if jsonErr := json.Unmarshal(msg, &f); jsonErr != nil { + continue + } + if f.Type == "stdout" && strings.Contains(f.Data, "hello_ws") { + found = true + break + } + } + require.True(t, found, "expected stdout frame with 'hello_ws'") +} + +// TestSessionWS_ReplayOnConnect connects with ?since=0 and verifies that +// a replay frame arrives before live output when there is buffered data. +func TestSessionWS_ReplayOnConnect(t *testing.T) { + withFreshRunner(t) + srv := wsTestServer(t) + defer srv.Close() + + id := createTestSession(t) + + // Prime the replay buffer by running a command through the HTTP API + // (RunInSession SSE endpoint), then reconnect via WS with ?since=0. + // Simpler: connect once, write stdin, disconnect, reconnect with since=0. + conn1 := dialWS(t, wsURL(srv, id)) + connected := readFrame(t, conn1, 5*time.Second) + require.Equal(t, "connected", connected.Type) + + // Write to stdin and wait briefly for stdout to land in the replay buffer. + require.NoError(t, conn1.WriteJSON(model.ClientFrame{ + Type: "stdin", + Data: "echo replay_test\n", + })) + // Wait for stdout to arrive so the replay buffer is populated. + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + conn1.SetReadDeadline(time.Now().Add(5 * time.Second)) + var f model.ServerFrame + _, msg, err := conn1.ReadMessage() + if err != nil { + break + } + if jsonErr := json.Unmarshal(msg, &f); jsonErr != nil { + continue + } + if f.Type == "stdout" && strings.Contains(f.Data, "replay_test") { + break + } + } + + // Close first connection to release the WS lock. + conn1.Close() + // Give the server a moment to release the lock. + time.Sleep(100 * time.Millisecond) + + // Reconnect with ?since=0 — should receive a replay frame. + conn2 := dialWS(t, wsURLWithSince(srv, id, 0)) + defer conn2.Close() + + // We expect a replay frame before the connected frame (replay is sent first). + deadline = time.Now().Add(10 * time.Second) + foundReplay := false + for time.Now().Before(deadline) { + conn2.SetReadDeadline(time.Now().Add(5 * time.Second)) + var f model.ServerFrame + _, msg, err := conn2.ReadMessage() + if err != nil { + break + } + if jsonErr := json.Unmarshal(msg, &f); jsonErr != nil { + continue + } + if f.Type == "replay" { + require.Contains(t, f.Data, "replay_test", "replay frame should contain buffered output") + foundReplay = true + break + } + } + require.True(t, foundReplay, "expected replay frame with buffered output") +} + +// TestSessionWS_ExitFrame runs a short-lived command and verifies that +// an exit frame is received with code 0 after bash exits. +func TestSessionWS_ExitFrame(t *testing.T) { + withFreshRunner(t) + srv := wsTestServer(t) + defer srv.Close() + + id := createTestSession(t) + conn := dialWS(t, wsURL(srv, id)) + + connected := readFrame(t, conn, 5*time.Second) + require.Equal(t, "connected", connected.Type) + + // Ask bash to exit cleanly. + require.NoError(t, conn.WriteJSON(model.ClientFrame{ + Type: "stdin", + Data: "exit 0\n", + })) + + // Collect frames looking for the exit frame. + deadline := time.Now().Add(10 * time.Second) + foundExit := false + for time.Now().Before(deadline) { + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + var f model.ServerFrame + _, msg, err := conn.ReadMessage() + if err != nil { + break + } + if jsonErr := json.Unmarshal(msg, &f); jsonErr != nil { + continue + } + if f.Type == "exit" { + require.NotNil(t, f.ExitCode, "exit frame must include exit_code") + require.Equal(t, 0, *f.ExitCode) + foundExit = true + break + } + } + require.True(t, foundExit, "expected exit frame with code 0") +} diff --git a/components/execd/pkg/web/model/session_ws.go b/components/execd/pkg/web/model/session_ws.go new file mode 100644 index 000000000..ca08d0603 --- /dev/null +++ b/components/execd/pkg/web/model/session_ws.go @@ -0,0 +1,46 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +// ClientFrame is a JSON frame sent from the WebSocket client to the server. +type ClientFrame struct { + Type string `json:"type"` + Data string `json:"data,omitempty"` // stdin payload (plain text) + Cols int `json:"cols,omitempty"` // resize — PTY mode only + Rows int `json:"rows,omitempty"` // resize — PTY mode only + Signal string `json:"signal,omitempty"` // signal name, e.g. "SIGINT" +} + +// ServerFrame is a JSON frame sent from the server to the WebSocket client. +type ServerFrame struct { + Type string `json:"type"` + SessionID string `json:"session_id,omitempty"` // connected + Mode string `json:"mode,omitempty"` // connected: "pipe" | "pty" + Data string `json:"data,omitempty"` // stdout/stderr/replay payload + Offset int64 `json:"offset,omitempty"` // replay: next byte offset + ExitCode *int `json:"exit_code,omitempty"` // exit — pointer so 0 is marshalled + Error string `json:"error,omitempty"` // error description + Code string `json:"code,omitempty"` // machine-readable error code + Timestamp int64 `json:"timestamp,omitempty"` +} + +// WebSocket error code constants. +const ( + WSErrCodeSessionGone = "SESSION_GONE" + WSErrCodeStartFailed = "START_FAILED" + WSErrCodeStdinWriteFailed = "STDIN_WRITE_FAILED" + WSErrCodeInvalidFrame = "INVALID_FRAME" + WSErrCodeAlreadyConnected = "ALREADY_CONNECTED" +) diff --git a/components/execd/pkg/web/router.go b/components/execd/pkg/web/router.go index afa6c40de..34114c3bc 100644 --- a/components/execd/pkg/web/router.go +++ b/components/execd/pkg/web/router.go @@ -70,6 +70,11 @@ func NewRouter(accessToken string) *gin.Engine { session.DELETE("/:sessionId", withCode(func(c *controller.CodeInterpretingController) { c.DeleteSession() })) } + ws := r.Group("/ws") + { + ws.GET("/session/:sessionId", withCode(func(c *controller.CodeInterpretingController) { c.SessionWebSocket() })) + } + command := r.Group("/command") { command.POST("", withCode(func(c *controller.CodeInterpretingController) { c.RunCommand() })) From 9efbdf0d9308baf1c79c25bcb0b20e82905e83ff Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Mon, 16 Mar 2026 05:09:02 -0400 Subject: [PATCH 27/37] feat: PTY opt-in via ?pty=1 on session creation (Phase 2) - Add github.com/creack/pty dependency (upgraded from v1.1.9 to v1.1.24) - Add isPTY/ptmx fields to bashSession struct - Add StartPTY() method: launches bash via pty.StartWithSize, sets TERM=xterm-256color, COLUMNS=80, LINES=24 in env, merges stdout+stderr on ptmx; pipe mode (Start()) unchanged - Add ResizePTY(cols, rows) method: calls pty.Setsize; no-op if not PTY mode - Override WriteStdin() to write to ptmx in PTY mode, stdin pipe otherwise - Update close() to close ptmx on session teardown - Wire ?pty=1 query param in GET /ws/session/:sessionId WS handler: calls StartPTY() when pty=1, Start() otherwise; sends mode='pty'/'pipe' in connected frame; skips stderr goroutine in PTY mode; handles resize frames by calling ResizePTY() - Add BashSession interface methods: StartPTY() and ResizePTY() - Add 4 PTY tests: BasicExecution, ResizeUpdatesWinsize, AnsiSequencesPresent, PipeModeUnchanged Co-Authored-By: Claude Sonnet 4.6 --- components/execd/go.mod | 1 + components/execd/go.sum | 2 + components/execd/pkg/runtime/bash_session.go | 92 ++++++++++- .../pkg/runtime/bash_session_pty_test.go | 152 ++++++++++++++++++ components/execd/pkg/runtime/types.go | 19 ++- .../execd/pkg/web/controller/session_ws.go | 67 +++++--- 6 files changed, 299 insertions(+), 34 deletions(-) create mode 100644 components/execd/pkg/runtime/bash_session_pty_test.go diff --git a/components/execd/go.mod b/components/execd/go.mod index 0b3f39cb5..7cad2fb06 100644 --- a/components/execd/go.mod +++ b/components/execd/go.mod @@ -23,6 +23,7 @@ require ( github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/creack/pty v1.1.24 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect diff --git a/components/execd/go.sum b/components/execd/go.sum index 0ebe846c5..88c4b7fe4 100644 --- a/components/execd/go.sum +++ b/components/execd/go.sum @@ -11,6 +11,8 @@ github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJ github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index cd7c307d4..a5c9c7c6d 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -31,6 +31,7 @@ import ( "syscall" "time" + "github.com/creack/pty" "github.com/google/uuid" "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" @@ -266,6 +267,74 @@ func (s *bashSession) Start() error { return nil } +// StartPTY launches an interactive bash process using a PTY instead of pipes. +// stdout and stderr arrive merged on the PTY master fd. +// It is idempotent: if the process is already running, it returns nil. +func (s *bashSession) StartPTY() error { + s.mu.Lock() + if s.currentProcessPid != 0 { + s.mu.Unlock() + return nil // already running + } + if s.closing { + s.mu.Unlock() + return errors.New("session is closing") + } + s.mu.Unlock() + + cmd := exec.Command("bash", "--noprofile", "--norc") + if s.cwd != "" { + cmd.Dir = s.cwd + } + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLUMNS=80", "LINES=24") + + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 24, Cols: 80}) + if err != nil { + return fmt.Errorf("pty start: %w", err) + } + + doneCh := make(chan struct{}) + + s.mu.Lock() + s.ptmx = ptmx + s.isPTY = true + s.stdoutPipe = ptmx // PTY merges stdout+stderr + s.stderrPipe = nil // not used in PTY mode + s.doneCh = doneCh + s.currentProcessPid = cmd.Process.Pid + s.started = true + s.mu.Unlock() + + go func() { + _ = cmd.Wait() + code := -1 + if cmd.ProcessState != nil { + code = cmd.ProcessState.ExitCode() + } + _ = ptmx.Close() + s.mu.Lock() + s.lastExitCode = code + s.currentProcessPid = 0 + s.mu.Unlock() + close(doneCh) + }() + + return nil +} + +// ResizePTY sends a TIOCSWINSZ ioctl to the PTY master. +// No-op if not in PTY mode. +func (s *bashSession) ResizePTY(cols, rows uint16) error { + s.mu.Lock() + ptmx := s.ptmx + s.mu.Unlock() + if ptmx == nil { + return nil + } + return pty.Setsize(ptmx, &pty.Winsize{Rows: rows, Cols: cols}) +} + // SendSignal sends a named OS signal (e.g. "SIGINT") to the session's process group. // No-op if the session is not running or the signal name is unknown. func (s *bashSession) SendSignal(name string) { @@ -301,16 +370,26 @@ func signalByName(name string) syscall.Signal { } } -// WriteStdin writes p to the session's stdin pipe. +// WriteStdin writes p to the session's stdin. +// In PTY mode it writes to the PTY master fd; in pipe mode it writes to the stdin pipe. // Returns error if the session has not started or the pipe is closed. func (s *bashSession) WriteStdin(p []byte) (int, error) { s.mu.Lock() - w := s.stdin + isPTY := s.isPTY + ptmx := s.ptmx + stdin := s.stdin s.mu.Unlock() - if w == nil { + + if isPTY { + if ptmx == nil { + return 0, errors.New("PTY not started") + } + return ptmx.Write(p) + } + if stdin == nil { return 0, errors.New("session not started") } - return w.Write(p) + return stdin.Write(p) } // LockWS atomically acquires exclusive WebSocket access. @@ -689,6 +768,7 @@ func (s *bashSession) close() error { s.closing = true pid := s.currentProcessPid + ptmx := s.ptmx s.currentProcessPid = 0 s.started = false s.env = nil @@ -699,6 +779,10 @@ func (s *bashSession) close() error { log.Warning("kill session process group %d: %v (process may have already exited)", pid, err) } } + if ptmx != nil { + _ = ptmx.Close() + s.ptmx = nil + } return nil } diff --git a/components/execd/pkg/runtime/bash_session_pty_test.go b/components/execd/pkg/runtime/bash_session_pty_test.go new file mode 100644 index 000000000..e614c9c70 --- /dev/null +++ b/components/execd/pkg/runtime/bash_session_pty_test.go @@ -0,0 +1,152 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows +// +build !windows + +package runtime + +import ( + "io" + "os/exec" + "strings" + "testing" + "time" + + "github.com/creack/pty" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// readOutputTimeout drains r for up to d, returning all collected bytes. +func readOutputTimeout(r io.Reader, d time.Duration) string { + var buf strings.Builder + deadline := time.Now().Add(d) + tmp := make([]byte, 256) + for time.Now().Before(deadline) { + n, err := r.Read(tmp) + if n > 0 { + buf.Write(tmp[:n]) + } + if err != nil { + break + } + } + return buf.String() +} + +// TestPTY_BasicExecution verifies that a PTY session can run a command and +// the output is received on the PTY master. +func TestPTY_BasicExecution(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found in PATH") + } + + s := newBashSession(t.TempDir()) + t.Cleanup(func() { _ = s.close() }) + + require.NoError(t, s.StartPTY()) + require.True(t, s.IsRunning(), "expected bash process to be running after StartPTY") + + // Send a command via stdin. + _, err := s.WriteStdin([]byte("echo hi\n")) + require.NoError(t, err) + + // Read from ptmx (via StdoutPipe, which equals ptmx in PTY mode). + out := readOutputTimeout(s.StdoutPipe(), 3*time.Second) + assert.Contains(t, out, "hi", "expected 'hi' in PTY output, got: %q", out) +} + +// TestPTY_ResizeUpdatesWinsize verifies that ResizePTY changes the terminal +// dimensions reported by the PTY (no error path; structural change verified). +func TestPTY_ResizeUpdatesWinsize(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found in PATH") + } + + s := newBashSession(t.TempDir()) + t.Cleanup(func() { _ = s.close() }) + + require.NoError(t, s.StartPTY()) + + // Resize to known dimensions. + require.NoError(t, s.ResizePTY(120, 40)) + + // Verify via pty.GetsizeFull that the kernel registered the new size. + s.mu.Lock() + ptmx := s.ptmx + s.mu.Unlock() + require.NotNil(t, ptmx) + + ws, err := pty.GetsizeFull(ptmx) + require.NoError(t, err) + assert.Equal(t, uint16(120), ws.Cols, "expected cols=120 after resize") + assert.Equal(t, uint16(40), ws.Rows, "expected rows=40 after resize") +} + +// TestPTY_AnsiSequencesPresent verifies that PTY output contains ANSI escape +// sequences (the prompt), which distinguishes PTY mode from plain pipe mode. +func TestPTY_AnsiSequencesPresent(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found in PATH") + } + + s := newBashSession(t.TempDir()) + t.Cleanup(func() { _ = s.close() }) + + require.NoError(t, s.StartPTY()) + + // Send a command that forces a prompt re-emission. + _, err := s.WriteStdin([]byte("PS1='\\e[1;32m>>\\e[0m '; echo marker\n")) + require.NoError(t, err) + + out := readOutputTimeout(s.StdoutPipe(), 3*time.Second) + // ANSI escape sequences start with ESC (\x1b) followed by [ + assert.Contains(t, out, "\x1b[", "expected ANSI escape sequence in PTY output, got: %q", out) + assert.Contains(t, out, "marker", "expected 'marker' in PTY output, got: %q", out) +} + +// TestPTY_PipeModeUnchanged verifies that a session created without ?pty=1 +// still uses plain pipes and has no PTY fd open — regression guard. +func TestPTY_PipeModeUnchanged(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found in PATH") + } + + s := newBashSession(t.TempDir()) + t.Cleanup(func() { _ = s.close() }) + + require.NoError(t, s.Start()) + require.True(t, s.IsRunning(), "expected bash process to be running") + + // PTY fields must be unset in pipe mode. + s.mu.Lock() + isPTY := s.isPTY + ptmx := s.ptmx + s.mu.Unlock() + + assert.False(t, isPTY, "isPTY must be false in pipe mode") + assert.Nil(t, ptmx, "ptmx must be nil in pipe mode") + + // ResizePTY must be a no-op (no error) when not in PTY mode. + require.NoError(t, s.ResizePTY(100, 30)) + + // Stdin must still work via pipe. + _, err := s.WriteStdin([]byte("echo pipe-ok\n")) + require.NoError(t, err) + + // Read from stdout pipe. + out := readOutputTimeout(s.StdoutPipe(), 3*time.Second) + assert.Contains(t, out, "pipe-ok", "expected pipe-mode echo output, got: %q", out) +} diff --git a/components/execd/pkg/runtime/types.go b/components/execd/pkg/runtime/types.go index 16c850908..fa364a10d 100644 --- a/components/execd/pkg/runtime/types.go +++ b/components/execd/pkg/runtime/types.go @@ -17,6 +17,7 @@ package runtime import ( "fmt" "io" + "os" "sync" "sync/atomic" "time" @@ -94,6 +95,8 @@ type BashSession interface { UnlockWS() // Start launches the underlying bash process (idempotent: no-op if already running). Start() error + // StartPTY launches the bash process with a PTY instead of pipes (idempotent). + StartPTY() error // IsRunning reports whether the bash process is currently alive. IsRunning() bool // ExitCode returns the exit code of the most recently completed process (-1 if not exited). @@ -108,6 +111,8 @@ type BashSession interface { Done() <-chan struct{} // SendSignal sends a named signal (e.g. "SIGINT") to the process group. SendSignal(name string) + // ResizePTY sends a TIOCSWINSZ ioctl to the PTY master. No-op if not in PTY mode. + ResizePTY(cols, rows uint16) error } // bashSessionConfig holds bash session configuration. @@ -139,10 +144,14 @@ type bashSession struct { replay *replayBuffer // WS mode fields — set by start() when a WebSocket client connects. - wsConnected atomic.Bool // true while a WS connection holds the session - lastExitCode int // stored on process exit; -1 if not yet exited + wsConnected atomic.Bool // true while a WS connection holds the session + lastExitCode int // stored on process exit; -1 if not yet exited stdin io.WriteCloser // write end of bash's stdin pipe (WS mode) - stdoutPipe io.Reader // stdout reader (WS mode) - stderrPipe io.Reader // stderr reader (WS mode) - doneCh chan struct{} // closed when WS-mode bash process exits + stdoutPipe io.Reader // stdout reader (WS mode) + stderrPipe io.Reader // stderr reader (WS mode) + doneCh chan struct{} // closed when WS-mode bash process exits + + // PTY mode fields — non-nil only when started via StartPTY(). + isPTY bool // true when session uses a PTY instead of pipes + ptmx *os.File // PTY master fd (read=stdout+stderr merged, write=stdin) } diff --git a/components/execd/pkg/web/controller/session_ws.go b/components/execd/pkg/web/controller/session_ws.go index f89a95491..4255d4a26 100644 --- a/components/execd/pkg/web/controller/session_ws.go +++ b/components/execd/pkg/web/controller/session_ws.go @@ -69,9 +69,17 @@ func (c *CodeInterpretingController) SessionWebSocket() { return conn.WriteJSON(v) } + usePTY := c.ctx.Query("pty") == "1" + // 4. Start bash if not already running. if !session.IsRunning() { - if startErr := session.Start(); startErr != nil { + var startErr error + if usePTY { + startErr = session.StartPTY() + } else { + startErr = session.Start() + } + if startErr != nil { _ = writeJSON(model.ServerFrame{ Type: "error", Error: "failed to start bash", @@ -95,11 +103,15 @@ func (c *CodeInterpretingController) SessionWebSocket() { } } - // 6. Send connected frame. + // 6. Send connected frame — mode reflects actual session type. + mode := "pipe" + if usePTY { + mode = "pty" + } _ = writeJSON(model.ServerFrame{ Type: "connected", SessionID: sessionID, - Mode: "pipe", + Mode: mode, }) ctx, cancel := context.WithCancel(context.Background()) @@ -156,31 +168,33 @@ func (c *CodeInterpretingController) SessionWebSocket() { } }() - // 9. Write pump — stderr scanner. - go func() { - stderr := session.StderrPipe() - if stderr == nil { - return - } - scanner := bufio.NewScanner(stderr) - for scanner.Scan() { - select { - case <-ctx.Done(): + // 9. Write pump — stderr scanner (pipe mode only; PTY merges stderr into ptmx). + if !usePTY { + go func() { + stderr := session.StderrPipe() + if stderr == nil { return - default: } - line := scanner.Text() + "\n" - // Write to replay buffer so reconnecting clients can catch up. - codeRunner.WriteSessionOutput(sessionID, []byte(line)) - if writeErr := writeJSON(model.ServerFrame{ - Type: "stderr", - Data: line, - Timestamp: time.Now().UnixMilli(), - }); writeErr != nil { - return + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + select { + case <-ctx.Done(): + return + default: + } + line := scanner.Text() + "\n" + // Write to replay buffer so reconnecting clients can catch up. + codeRunner.WriteSessionOutput(sessionID, []byte(line)) + if writeErr := writeJSON(model.ServerFrame{ + Type: "stderr", + Data: line, + Timestamp: time.Now().UnixMilli(), + }); writeErr != nil { + return + } } - } - }() + }() + } // 10. Exit watcher — sends exit frame when bash process ends. go func() { @@ -224,6 +238,9 @@ func (c *CodeInterpretingController) SessionWebSocket() { case "signal": session.SendSignal(frame.Signal) case "resize": + if usePTY { + _ = session.ResizePTY(uint16(frame.Cols), uint16(frame.Rows)) + } // Silently ignored in pipe mode; accepted to avoid client errors. case "ping": _ = writeJSON(model.ServerFrame{Type: "pong"}) From 51128dc7252decae8a65e6144577a8dc24ebb7f8 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Mon, 16 Mar 2026 13:35:46 -0400 Subject: [PATCH 28/37] fix: address Codex review P1/P2 issues in WS session handler P1 (session_ws.go): Derive connected.mode from session.IsPTY() instead of the ?pty query parameter. Reconnecting with a mismatched ?pty value no longer reports the wrong mode or skips the stderr pump. P1 (bash_session.go): Reset isPTY and ptmx in Start() before assigning pipe descriptors. Reusing a session that previously ran in PTY mode no longer leaves WriteStdin targeting a stale PTY fd. P2 (session_ws.go): Set a 16 MiB scanner buffer on both stdout and stderr pumps (matching the run() scanner) and handle scanner.Err() explicitly. Long lines (JSON/base64 tool output) no longer silently terminate the pump. On scan error a RUNTIME_ERROR frame is sent to the client. Also adds IsPTY() to the BashSession interface and WSErrCodeRuntimeError to the model constants. Co-Authored-By: Claude Sonnet 4.6 --- components/execd/pkg/runtime/bash_session.go | 10 +++++++ components/execd/pkg/runtime/types.go | 2 ++ .../execd/pkg/web/controller/session_ws.go | 27 ++++++++++++++++--- components/execd/pkg/web/model/session_ws.go | 1 + 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index a5c9c7c6d..24115fb17 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -242,6 +242,9 @@ func (s *bashSession) Start() error { doneCh := make(chan struct{}) s.mu.Lock() + // Reset stale PTY state so WriteStdin targets the correct pipe on mode switch. + s.isPTY = false + s.ptmx = nil s.stdin = stdinW s.stdoutPipe = stdoutR s.stderrPipe = stderrR @@ -427,6 +430,13 @@ func (s *bashSession) StderrPipe() io.Reader { return s.stderrPipe } // Done returns a channel that is closed when the WS-mode bash process exits. func (s *bashSession) Done() <-chan struct{} { return s.doneCh } +// IsPTY reports whether the session is running in PTY mode. +func (s *bashSession) IsPTY() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.isPTY +} + func (s *bashSession) trackCurrentProcess(pid int) { s.mu.Lock() defer s.mu.Unlock() diff --git a/components/execd/pkg/runtime/types.go b/components/execd/pkg/runtime/types.go index fa364a10d..fbb2bc60f 100644 --- a/components/execd/pkg/runtime/types.go +++ b/components/execd/pkg/runtime/types.go @@ -113,6 +113,8 @@ type BashSession interface { SendSignal(name string) // ResizePTY sends a TIOCSWINSZ ioctl to the PTY master. No-op if not in PTY mode. ResizePTY(cols, rows uint16) error + // IsPTY reports whether the session is currently running in PTY mode. + IsPTY() bool } // bashSessionConfig holds bash session configuration. diff --git a/components/execd/pkg/web/controller/session_ws.go b/components/execd/pkg/web/controller/session_ws.go index 4255d4a26..90a64b37c 100644 --- a/components/execd/pkg/web/controller/session_ws.go +++ b/components/execd/pkg/web/controller/session_ws.go @@ -103,9 +103,10 @@ func (c *CodeInterpretingController) SessionWebSocket() { } } - // 6. Send connected frame — mode reflects actual session type. + // 6. Send connected frame — mode derived from actual session state, not the request parameter, + // so reconnecting clients always receive the correct terminal assumptions. mode := "pipe" - if usePTY { + if session.IsPTY() { mode = "pty" } _ = writeJSON(model.ServerFrame{ @@ -143,12 +144,14 @@ func (c *CodeInterpretingController) SessionWebSocket() { }() // 8. Write pump — stdout scanner. + // Buffer sized to 16 MiB to handle large JSON/base64 lines from agent tools. go func() { stdout := session.StdoutPipe() if stdout == nil { return } scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) for scanner.Scan() { select { case <-ctx.Done(): @@ -166,16 +169,26 @@ func (c *CodeInterpretingController) SessionWebSocket() { return } } + if err := scanner.Err(); err != nil && ctx.Err() == nil { + _ = writeJSON(model.ServerFrame{ + Type: "error", + Error: "stdout read error: " + err.Error(), + Code: model.WSErrCodeRuntimeError, + }) + cancel() + } }() // 9. Write pump — stderr scanner (pipe mode only; PTY merges stderr into ptmx). - if !usePTY { + // Buffer sized to 16 MiB to match stdout pump. + if !session.IsPTY() { go func() { stderr := session.StderrPipe() if stderr == nil { return } scanner := bufio.NewScanner(stderr) + scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) for scanner.Scan() { select { case <-ctx.Done(): @@ -193,6 +206,14 @@ func (c *CodeInterpretingController) SessionWebSocket() { return } } + if err := scanner.Err(); err != nil && ctx.Err() == nil { + _ = writeJSON(model.ServerFrame{ + Type: "error", + Error: "stderr read error: " + err.Error(), + Code: model.WSErrCodeRuntimeError, + }) + cancel() + } }() } diff --git a/components/execd/pkg/web/model/session_ws.go b/components/execd/pkg/web/model/session_ws.go index ca08d0603..d8f77f5f2 100644 --- a/components/execd/pkg/web/model/session_ws.go +++ b/components/execd/pkg/web/model/session_ws.go @@ -43,4 +43,5 @@ const ( WSErrCodeStdinWriteFailed = "STDIN_WRITE_FAILED" WSErrCodeInvalidFrame = "INVALID_FRAME" WSErrCodeAlreadyConnected = "ALREADY_CONNECTED" + WSErrCodeRuntimeError = "RUNTIME_ERROR" ) From 356173597c85dd01054d03ed5e556afd1b567eed Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Mon, 16 Mar 2026 16:24:20 -0400 Subject: [PATCH 29/37] fix: address second round of Codex review P1/P2 issues P1 (bash_session.go): Separate wsPid from currentProcessPid. The long-lived WS shell (Start/StartPTY) now tracks its PID in wsPid; run() continues to use currentProcessPid. Prevents concurrent run() calls from clobbering WS shell tracking, keeping IsRunning, close, SendSignal, and interrupt logic correct. P1 (session_ws.go + types): Add CloseOutputPipes() to BashSession interface. Handler calls it via defer-cancel so stdout/stderr scanner goroutines unblock immediately on disconnect rather than blocking on the shared pipe until the next line of output arrives. Prevents goroutine leaks and nondeterministic output loss on reconnect. P2 (bash_session.go): Add defer os.Remove(scriptPath) in run() so temporary wrapper scripts are cleaned up after each execution instead of accumulating in /tmp. P2 (session_ws.go): Gate resize frame handling on session.IsPTY() instead of the request ?pty query flag, consistent with mode/stderr decisions from the previous round. Also: clear isPTY/ptmx in the StartPTY exit goroutine so a subsequent Start() in pipe mode finds a clean slate without needing to wait for the handler to race the reset. Co-Authored-By: Claude Sonnet 4.6 --- components/execd/pkg/runtime/bash_session.go | 50 ++++++++++++++----- components/execd/pkg/runtime/types.go | 24 ++++++--- .../execd/pkg/web/controller/session_ws.go | 6 ++- 3 files changed, 58 insertions(+), 22 deletions(-) diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index 24115fb17..efc7f38bc 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -185,7 +185,7 @@ func (s *bashSession) start() error { // Unlike run(), this bash process stays alive reading from stdin until closed. func (s *bashSession) Start() error { s.mu.Lock() - if s.currentProcessPid != 0 { + if s.wsPid != 0 { s.mu.Unlock() return nil // already running } @@ -249,7 +249,7 @@ func (s *bashSession) Start() error { s.stdoutPipe = stdoutR s.stderrPipe = stderrR s.doneCh = doneCh - s.currentProcessPid = cmd.Process.Pid + s.wsPid = cmd.Process.Pid s.started = true s.mu.Unlock() @@ -262,7 +262,7 @@ func (s *bashSession) Start() error { _ = stdinW.Close() s.mu.Lock() s.lastExitCode = code - s.currentProcessPid = 0 + s.wsPid = 0 s.mu.Unlock() close(doneCh) }() @@ -275,7 +275,7 @@ func (s *bashSession) Start() error { // It is idempotent: if the process is already running, it returns nil. func (s *bashSession) StartPTY() error { s.mu.Lock() - if s.currentProcessPid != 0 { + if s.wsPid != 0 { s.mu.Unlock() return nil // already running } @@ -305,7 +305,7 @@ func (s *bashSession) StartPTY() error { s.stdoutPipe = ptmx // PTY merges stdout+stderr s.stderrPipe = nil // not used in PTY mode s.doneCh = doneCh - s.currentProcessPid = cmd.Process.Pid + s.wsPid = cmd.Process.Pid s.started = true s.mu.Unlock() @@ -318,7 +318,10 @@ func (s *bashSession) StartPTY() error { _ = ptmx.Close() s.mu.Lock() s.lastExitCode = code - s.currentProcessPid = 0 + s.wsPid = 0 + // Clear PTY descriptors so a subsequent Start() in pipe mode is clean. + s.isPTY = false + s.ptmx = nil s.mu.Unlock() close(doneCh) }() @@ -342,7 +345,7 @@ func (s *bashSession) ResizePTY(cols, rows uint16) error { // No-op if the session is not running or the signal name is unknown. func (s *bashSession) SendSignal(name string) { s.mu.Lock() - pid := s.currentProcessPid + pid := s.wsPid s.mu.Unlock() if pid == 0 { return @@ -406,11 +409,11 @@ func (s *bashSession) UnlockWS() { s.wsConnected.Store(false) } -// IsRunning reports whether the bash process is currently alive. +// IsRunning reports whether the long-lived WS bash process is currently alive. func (s *bashSession) IsRunning() bool { s.mu.Lock() defer s.mu.Unlock() - return s.currentProcessPid != 0 + return s.wsPid != 0 } // ExitCode returns the exit code of the most recently completed process. @@ -427,6 +430,22 @@ func (s *bashSession) StdoutPipe() io.Reader { return s.stdoutPipe } // StderrPipe returns the reader for the process's stderr. func (s *bashSession) StderrPipe() io.Reader { return s.stderrPipe } +// CloseOutputPipes closes the stdout and stderr pipe readers, unblocking any +// goroutine currently blocked in scanner.Scan(). Called by the WebSocket handler +// on disconnect so stale pump goroutines exit promptly rather than leaking. +func (s *bashSession) CloseOutputPipes() { + s.mu.Lock() + stdout := s.stdoutPipe + stderr := s.stderrPipe + s.mu.Unlock() + if stdout != nil { + _ = stdout.Close() + } + if stderr != nil { + _ = stderr.Close() + } +} + // Done returns a channel that is closed when the WS-mode bash process exits. func (s *bashSession) Done() <-chan struct{} { return s.doneCh } @@ -491,6 +510,7 @@ func (s *bashSession) run(ctx context.Context, request *ExecuteCodeRequest) erro return fmt.Errorf("create script file: %w", err) } scriptPath := scriptFile.Name() + defer os.Remove(scriptPath) // clean up temp script regardless of outcome if _, err := scriptFile.WriteString(script); err != nil { _ = scriptFile.Close() return fmt.Errorf("write script file: %w", err) @@ -777,16 +797,20 @@ func (s *bashSession) close() error { defer s.mu.Unlock() s.closing = true - pid := s.currentProcessPid + wsPid := s.wsPid + runPid := s.currentProcessPid ptmx := s.ptmx + s.wsPid = 0 s.currentProcessPid = 0 s.started = false s.env = nil s.cwd = "" - if pid != 0 { - if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil { - log.Warning("kill session process group %d: %v (process may have already exited)", pid, err) + for _, pid := range []int{wsPid, runPid} { + if pid != 0 { + if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil { + log.Warning("kill session process group %d: %v (process may have already exited)", pid, err) + } } } if ptmx != nil { diff --git a/components/execd/pkg/runtime/types.go b/components/execd/pkg/runtime/types.go index fbb2bc60f..e3d1ca92a 100644 --- a/components/execd/pkg/runtime/types.go +++ b/components/execd/pkg/runtime/types.go @@ -115,6 +115,10 @@ type BashSession interface { ResizePTY(cols, rows uint16) error // IsPTY reports whether the session is currently running in PTY mode. IsPTY() bool + // CloseOutputPipes closes the stdout and stderr pipe readers. + // Called by the WebSocket handler on disconnect to unblock any goroutines + // blocked in scanner.Scan(), preventing goroutine leaks across reconnects. + CloseOutputPipes() } // bashSessionConfig holds bash session configuration. @@ -138,20 +142,24 @@ type bashSession struct { env map[string]string cwd string - // currentProcessPid is the pid of the active run's process group leader (bash). - // Set after cmd.Start(), cleared when run() returns. Used by close() to kill the process group. + // currentProcessPid tracks the PID of a short-lived run() command (fire-and-forget execution). + // Set after cmd.Start(), cleared when run() returns. Used by close() for cleanup. currentProcessPid int + // wsPid tracks the PID of the long-lived interactive shell started by Start/StartPTY. + // Kept separate so run() cannot clobber it, ensuring IsRunning/close/interrupt remain correct. + wsPid int + // replay buffers all output so reconnecting clients can catch up on missed bytes. replay *replayBuffer // WS mode fields — set by start() when a WebSocket client connects. - wsConnected atomic.Bool // true while a WS connection holds the session - lastExitCode int // stored on process exit; -1 if not yet exited - stdin io.WriteCloser // write end of bash's stdin pipe (WS mode) - stdoutPipe io.Reader // stdout reader (WS mode) - stderrPipe io.Reader // stderr reader (WS mode) - doneCh chan struct{} // closed when WS-mode bash process exits + wsConnected atomic.Bool // true while a WS connection holds the session + lastExitCode int // stored on process exit; -1 if not yet exited + stdin io.WriteCloser // write end of bash's stdin pipe (WS mode) + stdoutPipe io.ReadCloser // stdout reader (WS mode); closed on handler disconnect to unblock scanners + stderrPipe io.ReadCloser // stderr reader (WS mode); nil in PTY mode + doneCh chan struct{} // closed when WS-mode bash process exits // PTY mode fields — non-nil only when started via StartPTY(). isPTY bool // true when session uses a PTY instead of pipes diff --git a/components/execd/pkg/web/controller/session_ws.go b/components/execd/pkg/web/controller/session_ws.go index 90a64b37c..d7a04bb22 100644 --- a/components/execd/pkg/web/controller/session_ws.go +++ b/components/execd/pkg/web/controller/session_ws.go @@ -118,6 +118,10 @@ func (c *CodeInterpretingController) SessionWebSocket() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Close output pipes when the handler exits so scanner goroutines unblock immediately + // instead of leaking until the next line of output arrives from a future reconnect. + defer session.CloseOutputPipes() + // 7. Ping/pong keepalive — RFC 6455 control-level pings every 30s. conn.SetPongHandler(func(string) error { conn.SetReadDeadline(time.Now().Add(60 * time.Second)) //nolint:errcheck @@ -259,7 +263,7 @@ func (c *CodeInterpretingController) SessionWebSocket() { case "signal": session.SendSignal(frame.Signal) case "resize": - if usePTY { + if session.IsPTY() { _ = session.ResizePTY(uint16(frame.Cols), uint16(frame.Rows)) } // Silently ignored in pipe mode; accepted to avoid client errors. From cd3ccafd6e09aaefe5cb702c41a19f554ec601fa Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Mon, 16 Mar 2026 17:35:03 -0400 Subject: [PATCH 30/37] fix: revert CloseOutputPipes; fix commandSnapshot data race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert CloseOutputPipes approach: closing the session's real stdout/stderr pipe readers on WS disconnect broke reconnects — the shell stays alive with wsPid set, so Start() is skipped, but the new handler is attached to dead descriptors causing SIGPIPE/HUP and silent output loss. Scanner goroutines already exit naturally when writeJSON fails after the connection drops; the defer cancel() is sufficient. Fix commandSnapshot data race: commandSnapshot was copying *kernel without holding c.mu while markCommandFinished writes the same struct fields under c.mu (write lock). Concurrent status polls during command exit could observe a torn struct. commandSnapshot now takes c.mu.RLock() around the copy, consistent with the existing write-lock discipline in markCommandFinished. Co-Authored-By: Claude Sonnet 4.6 --- components/execd/pkg/runtime/bash_session.go | 16 ---------------- components/execd/pkg/runtime/command_status.go | 4 ++++ components/execd/pkg/runtime/types.go | 16 ++++++---------- .../execd/pkg/web/controller/session_ws.go | 4 ---- 4 files changed, 10 insertions(+), 30 deletions(-) diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index efc7f38bc..1eaa6cf55 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -430,22 +430,6 @@ func (s *bashSession) StdoutPipe() io.Reader { return s.stdoutPipe } // StderrPipe returns the reader for the process's stderr. func (s *bashSession) StderrPipe() io.Reader { return s.stderrPipe } -// CloseOutputPipes closes the stdout and stderr pipe readers, unblocking any -// goroutine currently blocked in scanner.Scan(). Called by the WebSocket handler -// on disconnect so stale pump goroutines exit promptly rather than leaking. -func (s *bashSession) CloseOutputPipes() { - s.mu.Lock() - stdout := s.stdoutPipe - stderr := s.stderrPipe - s.mu.Unlock() - if stdout != nil { - _ = stdout.Close() - } - if stderr != nil { - _ = stderr.Close() - } -} - // Done returns a channel that is closed when the WS-mode bash process exits. func (s *bashSession) Done() <-chan struct{} { return s.doneCh } diff --git a/components/execd/pkg/runtime/command_status.go b/components/execd/pkg/runtime/command_status.go index 6dbc6d4f2..7bf6f58dc 100644 --- a/components/execd/pkg/runtime/command_status.go +++ b/components/execd/pkg/runtime/command_status.go @@ -48,7 +48,11 @@ func (c *Controller) commandSnapshot(session string) *commandKernel { return nil } + // Hold the read lock while copying so the snapshot is consistent with + // concurrent markCommandFinished writes (which take the write lock). + c.mu.RLock() cp := *kernel + c.mu.RUnlock() return &cp } diff --git a/components/execd/pkg/runtime/types.go b/components/execd/pkg/runtime/types.go index e3d1ca92a..e1c778af5 100644 --- a/components/execd/pkg/runtime/types.go +++ b/components/execd/pkg/runtime/types.go @@ -115,10 +115,6 @@ type BashSession interface { ResizePTY(cols, rows uint16) error // IsPTY reports whether the session is currently running in PTY mode. IsPTY() bool - // CloseOutputPipes closes the stdout and stderr pipe readers. - // Called by the WebSocket handler on disconnect to unblock any goroutines - // blocked in scanner.Scan(), preventing goroutine leaks across reconnects. - CloseOutputPipes() } // bashSessionConfig holds bash session configuration. @@ -154,12 +150,12 @@ type bashSession struct { replay *replayBuffer // WS mode fields — set by start() when a WebSocket client connects. - wsConnected atomic.Bool // true while a WS connection holds the session - lastExitCode int // stored on process exit; -1 if not yet exited - stdin io.WriteCloser // write end of bash's stdin pipe (WS mode) - stdoutPipe io.ReadCloser // stdout reader (WS mode); closed on handler disconnect to unblock scanners - stderrPipe io.ReadCloser // stderr reader (WS mode); nil in PTY mode - doneCh chan struct{} // closed when WS-mode bash process exits + wsConnected atomic.Bool // true while a WS connection holds the session + lastExitCode int // stored on process exit; -1 if not yet exited + stdin io.WriteCloser // write end of bash's stdin pipe (WS mode) + stdoutPipe io.Reader // stdout reader (WS mode) + stderrPipe io.Reader // stderr reader (WS mode); nil in PTY mode + doneCh chan struct{} // closed when WS-mode bash process exits // PTY mode fields — non-nil only when started via StartPTY(). isPTY bool // true when session uses a PTY instead of pipes diff --git a/components/execd/pkg/web/controller/session_ws.go b/components/execd/pkg/web/controller/session_ws.go index d7a04bb22..e76bf4d8a 100644 --- a/components/execd/pkg/web/controller/session_ws.go +++ b/components/execd/pkg/web/controller/session_ws.go @@ -118,10 +118,6 @@ func (c *CodeInterpretingController) SessionWebSocket() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // Close output pipes when the handler exits so scanner goroutines unblock immediately - // instead of leaking until the next line of output arrives from a future reconnect. - defer session.CloseOutputPipes() - // 7. Ping/pong keepalive — RFC 6455 control-level pings every 30s. conn.SetPongHandler(func(string) error { conn.SetReadDeadline(time.Now().Add(60 * time.Second)) //nolint:errcheck From 2f161bc4662245aa02623d37338ea873bacb5343 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Mon, 16 Mar 2026 18:25:54 -0400 Subject: [PATCH 31/37] fix: broadcast goroutine + WaitGroup for safe scanner lifecycle on reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root problem: scanner goroutines block in Scan() on the shared OS pipe. Closing the WS connection cancels ctx but doesn't unblock Scan(), so goroutines outlived UnlockWS() — a reconnecting client could start new scanners while stale ones from the prior connection were still reading. Solution — output broadcast pattern: - Start/StartPTY now launch a broadcast goroutine that reads the real OS pipe and writes into a swappable *io.PipeWriter stored on the session. - AttachOutput() (new BashSession method) creates a fresh io.Pipe pair per connection, swaps in the PipeWriter, and returns the PipeReader plus a detach() func that closes the PipeWriter. - The WS handler defers: cancel() → detach() → pumpWg.Wait() → UnlockWS() in that order. detach() closes the PipeWriter (EOF to the scanner), pumpWg.Wait() blocks until scanner goroutines exit, then UnlockWS() fires — guaranteeing no stale goroutine is reading when a new client arrives. Also fixes GetBashSessionStatus to derive Running from wsPid != 0 instead of session.started, so the field accurately reflects whether the WS shell is currently alive rather than whether it was ever started. Co-Authored-By: Claude Sonnet 4.6 --- components/execd/pkg/runtime/bash_session.go | 132 ++++++++++++++++-- components/execd/pkg/runtime/types.go | 20 ++- .../execd/pkg/web/controller/session_ws.go | 41 +++--- 3 files changed, 160 insertions(+), 33 deletions(-) diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index 1eaa6cf55..a32be2857 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -136,7 +136,7 @@ func (c *Controller) GetBashSessionStatus(sessionID string) (*BashSessionStatus, return nil, ErrContextNotFound } session.mu.Lock() - running := session.started && !session.closing + running := session.wsPid != 0 session.mu.Unlock() return &BashSessionStatus{ SessionID: sessionID, @@ -246,13 +246,61 @@ func (s *bashSession) Start() error { s.isPTY = false s.ptmx = nil s.stdin = stdinW - s.stdoutPipe = stdoutR - s.stderrPipe = stderrR s.doneCh = doneCh s.wsPid = cmd.Process.Pid s.started = true s.mu.Unlock() + // Broadcast goroutine: reads real stdout and fans out to the current per-connection sink. + go func() { + buf := make([]byte, 32*1024) + for { + n, err := stdoutR.Read(buf) + if n > 0 { + s.outMu.Lock() + w := s.stdoutW + s.outMu.Unlock() + if w != nil { + _, _ = w.Write(buf[:n]) + } + } + if err != nil { + s.outMu.Lock() + if s.stdoutW != nil { + _ = s.stdoutW.Close() + s.stdoutW = nil + } + s.outMu.Unlock() + return + } + } + }() + + // Broadcast goroutine: reads real stderr and fans out to the current per-connection sink. + go func() { + buf := make([]byte, 32*1024) + for { + n, err := stderrR.Read(buf) + if n > 0 { + s.outMu.Lock() + w := s.stderrW + s.outMu.Unlock() + if w != nil { + _, _ = w.Write(buf[:n]) + } + } + if err != nil { + s.outMu.Lock() + if s.stderrW != nil { + _ = s.stderrW.Close() + s.stderrW = nil + } + s.outMu.Unlock() + return + } + } + }() + go func() { _ = cmd.Wait() code := -1 @@ -302,13 +350,36 @@ func (s *bashSession) StartPTY() error { s.mu.Lock() s.ptmx = ptmx s.isPTY = true - s.stdoutPipe = ptmx // PTY merges stdout+stderr - s.stderrPipe = nil // not used in PTY mode s.doneCh = doneCh s.wsPid = cmd.Process.Pid s.started = true s.mu.Unlock() + // Broadcast goroutine: reads PTY master (stdout+stderr merged) and fans out. + go func() { + buf := make([]byte, 32*1024) + for { + n, err := ptmx.Read(buf) + if n > 0 { + s.outMu.Lock() + w := s.stdoutW + s.outMu.Unlock() + if w != nil { + _, _ = w.Write(buf[:n]) + } + } + if err != nil { + s.outMu.Lock() + if s.stdoutW != nil { + _ = s.stdoutW.Close() + s.stdoutW = nil + } + s.outMu.Unlock() + return + } + } + }() + go func() { _ = cmd.Wait() code := -1 @@ -424,11 +495,54 @@ func (s *bashSession) ExitCode() int { return s.lastExitCode } -// StdoutPipe returns the reader for the process's stdout. -func (s *bashSession) StdoutPipe() io.Reader { return s.stdoutPipe } +// AttachOutput installs a fresh per-connection pipe pair and returns readers plus a detach func. +// The broadcast goroutine (started by Start/StartPTY) copies from the real OS pipe into the +// current PipeWriter. Calling detach() closes the PipeWriters so the returned readers return +// EOF, unblocking any scanner goroutines on this connection without affecting the underlying pipe. +func (s *bashSession) AttachOutput() (stdout io.Reader, stderr io.Reader, detach func()) { + stdoutR, stdoutW := io.Pipe() + + s.outMu.Lock() + // Close any previous writer (e.g. from a stale prior connection) before swapping. + if s.stdoutW != nil { + _ = s.stdoutW.Close() + } + s.stdoutW = stdoutW + s.outMu.Unlock() + + var stderrR *io.PipeReader + var stderrPW *io.PipeWriter + + s.mu.Lock() + isPTY := s.isPTY + s.mu.Unlock() -// StderrPipe returns the reader for the process's stderr. -func (s *bashSession) StderrPipe() io.Reader { return s.stderrPipe } + if !isPTY { + stderrR, stderrPW = io.Pipe() + s.outMu.Lock() + if s.stderrW != nil { + _ = s.stderrW.Close() + } + s.stderrW = stderrPW + s.outMu.Unlock() + } + + detach = func() { + s.outMu.Lock() + // Only close if we're still the active writer (guards against double-detach). + if s.stdoutW == stdoutW { + _ = stdoutW.Close() + s.stdoutW = nil + } + if stderrPW != nil && s.stderrW == stderrPW { + _ = stderrPW.Close() + s.stderrW = nil + } + s.outMu.Unlock() + } + + return stdoutR, stderrR, detach +} // Done returns a channel that is closed when the WS-mode bash process exits. func (s *bashSession) Done() <-chan struct{} { return s.doneCh } diff --git a/components/execd/pkg/runtime/types.go b/components/execd/pkg/runtime/types.go index e1c778af5..ae81d1ec3 100644 --- a/components/execd/pkg/runtime/types.go +++ b/components/execd/pkg/runtime/types.go @@ -103,10 +103,11 @@ type BashSession interface { ExitCode() int // WriteStdin writes p to the session's stdin pipe. WriteStdin(p []byte) (int, error) - // StdoutPipe returns the stdout reader. - StdoutPipe() io.Reader - // StderrPipe returns the stderr reader. - StderrPipe() io.Reader + // AttachOutput returns per-connection pipe readers for stdout (and stderr in pipe mode) + // plus a detach func. The broadcast goroutine copies from the real OS pipe into these + // readers. Calling detach closes the write ends, causing the readers to return EOF and + // unblocking any scanner goroutines without touching the underlying OS pipe. + AttachOutput() (stdout io.Reader, stderr io.Reader, detach func()) // Done returns a channel closed when the bash process exits. Done() <-chan struct{} // SendSignal sends a named signal (e.g. "SIGINT") to the process group. @@ -149,14 +150,19 @@ type bashSession struct { // replay buffers all output so reconnecting clients can catch up on missed bytes. replay *replayBuffer - // WS mode fields — set by start() when a WebSocket client connects. + // WS mode fields — set by Start/StartPTY when the interactive shell is launched. wsConnected atomic.Bool // true while a WS connection holds the session lastExitCode int // stored on process exit; -1 if not yet exited stdin io.WriteCloser // write end of bash's stdin pipe (WS mode) - stdoutPipe io.Reader // stdout reader (WS mode) - stderrPipe io.Reader // stderr reader (WS mode); nil in PTY mode doneCh chan struct{} // closed when WS-mode bash process exits + // Output broadcast: a goroutine reads the real OS pipe and writes to the current + // per-connection PipeWriter. On WS disconnect, detach() closes the PipeWriter so + // the handler's scanner gets EOF. On reconnect a new PipeWriter is swapped in. + outMu sync.Mutex // guards stdoutW / stderrW + stdoutW *io.PipeWriter // current broadcast sink for stdout; nil before first attach + stderrW *io.PipeWriter // current broadcast sink for stderr; nil in PTY mode or before attach + // PTY mode fields — non-nil only when started via StartPTY(). isPTY bool // true when session uses a PTY instead of pipes ptmx *os.File // PTY master fd (read=stdout+stderr merged, write=stdin) diff --git a/components/execd/pkg/web/controller/session_ws.go b/components/execd/pkg/web/controller/session_ws.go index e76bf4d8a..11f923b0c 100644 --- a/components/execd/pkg/web/controller/session_ws.go +++ b/components/execd/pkg/web/controller/session_ws.go @@ -50,7 +50,9 @@ func (c *CodeInterpretingController) SessionWebSocket() { c.RespondError(http.StatusConflict, model.ErrorCodeRuntimeError, "session already connected") return } - defer session.UnlockWS() + // Do NOT defer UnlockWS here — we release it manually after pump goroutines + // finish, so a reconnecting client cannot start new scanners on the shared pipe + // while stale scanners from the previous connection are still blocked in Scan(). // 3. Upgrade HTTP → WebSocket. conn, err := wsUpgrader.Upgrade(c.ctx.Writer, c.ctx.Request, nil) @@ -118,6 +120,19 @@ func (c *CodeInterpretingController) SessionWebSocket() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Attach per-connection pipe readers. detach() closes the write ends on handler exit, + // causing scanner goroutines to receive EOF and exit promptly. UnlockWS is called only + // after pumps finish, preventing a reconnecting client from starting new scanners while + // stale ones are still reading from the shared pipe. + stdout, stderr, detach := session.AttachOutput() + var pumpWg sync.WaitGroup + defer func() { + cancel() + detach() + pumpWg.Wait() + session.UnlockWS() + }() + // 7. Ping/pong keepalive — RFC 6455 control-level pings every 30s. conn.SetPongHandler(func(string) error { conn.SetReadDeadline(time.Now().Add(60 * time.Second)) //nolint:errcheck @@ -145,18 +160,14 @@ func (c *CodeInterpretingController) SessionWebSocket() { // 8. Write pump — stdout scanner. // Buffer sized to 16 MiB to handle large JSON/base64 lines from agent tools. + pumpWg.Add(1) go func() { - stdout := session.StdoutPipe() - if stdout == nil { - return - } + defer pumpWg.Done() scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) for scanner.Scan() { - select { - case <-ctx.Done(): + if ctx.Err() != nil { return - default: } line := scanner.Text() + "\n" // Write to replay buffer so reconnecting clients can catch up. @@ -180,20 +191,16 @@ func (c *CodeInterpretingController) SessionWebSocket() { }() // 9. Write pump — stderr scanner (pipe mode only; PTY merges stderr into ptmx). - // Buffer sized to 16 MiB to match stdout pump. - if !session.IsPTY() { + // Buffer sized to 16 MiB to match stdout pump. stderr is nil in PTY mode. + if stderr != nil { + pumpWg.Add(1) go func() { - stderr := session.StderrPipe() - if stderr == nil { - return - } + defer pumpWg.Done() scanner := bufio.NewScanner(stderr) scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) for scanner.Scan() { - select { - case <-ctx.Done(): + if ctx.Err() != nil { return - default: } line := scanner.Text() + "\n" // Write to replay buffer so reconnecting clients can catch up. From 0bb16a7308a34358b1c4d9b6cae7063ca353be97 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Mon, 16 Mar 2026 19:00:55 -0400 Subject: [PATCH 32/37] fix: P0 run() deadlock, P1 WS lock leak on upgrade fail, P1 replay gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 (bash_session.go run()): Stdout scanner blocked waiting for EOF on stdoutR, which required stdoutW to be closed, which only happened after cmd.Wait(), which itself was waiting for stdout to be drained — a deadlock. Fix: close stdoutW and stderrW immediately after cmd.Start() so the process's output buffers are already closed in the parent before scanning begins. Wait() is then called after scanning without blocking. P1 (session_ws.go): WS lock (wsConnected) was not released when the WebSocket upgrade handshake failed. Any subsequent connection attempt returned "session already connected" until process restart. Fix: call session.UnlockWS() in the upgrade error path. P1 (bash_session.go broadcast goroutines): Output produced while no client is attached (between disconnect and reconnect) was discarded — the broadcast goroutine only wrote to the per-connection PipeWriter and skipped the replay buffer. Reconnecting clients using ?since= would miss those bytes entirely. Fix: always write to s.replay before fanout, independent of whether a PipeWriter is active. Handler pumps no longer call WriteSessionOutput (replay is now the broadcast goroutine's job). Also updates bash_session_pty_test.go to use AttachOutput() after the removal of the StdoutPipe/StderrPipe interface methods. Co-Authored-By: Claude Sonnet 4.6 --- components/execd/pkg/runtime/bash_session.go | 34 ++++++++++++++----- .../pkg/runtime/bash_session_pty_test.go | 16 ++++++--- .../execd/pkg/web/controller/session_ws.go | 13 +++---- 3 files changed, 41 insertions(+), 22 deletions(-) diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index a32be2857..c8069abb5 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -251,17 +251,22 @@ func (s *bashSession) Start() error { s.started = true s.mu.Unlock() - // Broadcast goroutine: reads real stdout and fans out to the current per-connection sink. + // Broadcast goroutine: reads real stdout, always writes to replay buffer, and + // fans out to the current per-connection sink when one is attached. + // Output produced during client downtime is preserved in the replay buffer so + // reconnecting clients can catch up via ?since=. go func() { buf := make([]byte, 32*1024) for { n, err := stdoutR.Read(buf) if n > 0 { + chunk := buf[:n] + s.replay.write(chunk) s.outMu.Lock() w := s.stdoutW s.outMu.Unlock() if w != nil { - _, _ = w.Write(buf[:n]) + _, _ = w.Write(chunk) } } if err != nil { @@ -276,17 +281,20 @@ func (s *bashSession) Start() error { } }() - // Broadcast goroutine: reads real stderr and fans out to the current per-connection sink. + // Broadcast goroutine: reads real stderr, always writes to replay buffer, and + // fans out to the current per-connection sink when one is attached. go func() { buf := make([]byte, 32*1024) for { n, err := stderrR.Read(buf) if n > 0 { + chunk := buf[:n] + s.replay.write(chunk) s.outMu.Lock() w := s.stderrW s.outMu.Unlock() if w != nil { - _, _ = w.Write(buf[:n]) + _, _ = w.Write(chunk) } } if err != nil { @@ -355,17 +363,20 @@ func (s *bashSession) StartPTY() error { s.started = true s.mu.Unlock() - // Broadcast goroutine: reads PTY master (stdout+stderr merged) and fans out. + // Broadcast goroutine: reads PTY master (stdout+stderr merged), always writes to + // replay buffer, and fans out to the current per-connection sink when one is attached. go func() { buf := make([]byte, 32*1024) for { n, err := ptmx.Read(buf) if n > 0 { + chunk := buf[:n] + s.replay.write(chunk) s.outMu.Lock() w := s.stdoutW s.outMu.Unlock() if w != nil { - _, _ = w.Write(buf[:n]) + _, _ = w.Write(chunk) } } if err != nil { @@ -637,6 +648,13 @@ func (s *bashSession) run(ctx context.Context, request *ExecuteCodeRequest) erro defer s.untrackCurrentProcess() s.trackCurrentProcess(cmd.Process.Pid) + // Close write ends immediately so scanners receive EOF when the process exits, + // not after cmd.Wait() returns. Without this, stdout scanning and cmd.Wait() + // deadlock: scan waits for EOF (needs stdoutW closed), Wait needs the process + // to exit (which it has, but pipe buffers keep stdoutW open in the parent). + _ = stdoutW.Close() + _ = stderrW.Close() + // Drain stderr in a separate goroutine; fire OnExecuteStderr for each line. stderrDone := make(chan struct{}) go func() { @@ -689,9 +707,7 @@ func (s *bashSession) run(ctx context.Context, request *ExecuteCodeRequest) erro scanErr := scanner.Err() waitErr := cmd.Wait() - // Close write ends to unblock the stderr goroutine, then wait for it to drain. - _ = stdoutW.Close() - _ = stderrW.Close() + // Wait for stderr goroutine to drain. <-stderrDone if scanErr != nil { diff --git a/components/execd/pkg/runtime/bash_session_pty_test.go b/components/execd/pkg/runtime/bash_session_pty_test.go index e614c9c70..6206aa6fd 100644 --- a/components/execd/pkg/runtime/bash_session_pty_test.go +++ b/components/execd/pkg/runtime/bash_session_pty_test.go @@ -63,8 +63,10 @@ func TestPTY_BasicExecution(t *testing.T) { _, err := s.WriteStdin([]byte("echo hi\n")) require.NoError(t, err) - // Read from ptmx (via StdoutPipe, which equals ptmx in PTY mode). - out := readOutputTimeout(s.StdoutPipe(), 3*time.Second) + // Read output via AttachOutput. + outR, _, detach := s.AttachOutput() + defer detach() + out := readOutputTimeout(outR, 3*time.Second) assert.Contains(t, out, "hi", "expected 'hi' in PTY output, got: %q", out) } @@ -111,7 +113,9 @@ func TestPTY_AnsiSequencesPresent(t *testing.T) { _, err := s.WriteStdin([]byte("PS1='\\e[1;32m>>\\e[0m '; echo marker\n")) require.NoError(t, err) - out := readOutputTimeout(s.StdoutPipe(), 3*time.Second) + outR, _, detach := s.AttachOutput() + defer detach() + out := readOutputTimeout(outR, 3*time.Second) // ANSI escape sequences start with ESC (\x1b) followed by [ assert.Contains(t, out, "\x1b[", "expected ANSI escape sequence in PTY output, got: %q", out) assert.Contains(t, out, "marker", "expected 'marker' in PTY output, got: %q", out) @@ -146,7 +150,9 @@ func TestPTY_PipeModeUnchanged(t *testing.T) { _, err := s.WriteStdin([]byte("echo pipe-ok\n")) require.NoError(t, err) - // Read from stdout pipe. - out := readOutputTimeout(s.StdoutPipe(), 3*time.Second) + // Read from stdout via AttachOutput. + outR, _, detach := s.AttachOutput() + defer detach() + out := readOutputTimeout(outR, 3*time.Second) assert.Contains(t, out, "pipe-ok", "expected pipe-mode echo output, got: %q", out) } diff --git a/components/execd/pkg/web/controller/session_ws.go b/components/execd/pkg/web/controller/session_ws.go index 11f923b0c..add3f36f6 100644 --- a/components/execd/pkg/web/controller/session_ws.go +++ b/components/execd/pkg/web/controller/session_ws.go @@ -58,6 +58,7 @@ func (c *CodeInterpretingController) SessionWebSocket() { conn, err := wsUpgrader.Upgrade(c.ctx.Writer, c.ctx.Request, nil) if err != nil { // gorilla writes the HTTP error response automatically. + session.UnlockWS() return } defer conn.Close() @@ -169,12 +170,10 @@ func (c *CodeInterpretingController) SessionWebSocket() { if ctx.Err() != nil { return } - line := scanner.Text() + "\n" - // Write to replay buffer so reconnecting clients can catch up. - codeRunner.WriteSessionOutput(sessionID, []byte(line)) + // Replay buffer is written by the broadcast goroutine; just forward to client. if writeErr := writeJSON(model.ServerFrame{ Type: "stdout", - Data: line, + Data: scanner.Text() + "\n", Timestamp: time.Now().UnixMilli(), }); writeErr != nil { return @@ -202,12 +201,10 @@ func (c *CodeInterpretingController) SessionWebSocket() { if ctx.Err() != nil { return } - line := scanner.Text() + "\n" - // Write to replay buffer so reconnecting clients can catch up. - codeRunner.WriteSessionOutput(sessionID, []byte(line)) + // Replay buffer is written by the broadcast goroutine; just forward to client. if writeErr := writeJSON(model.ServerFrame{ Type: "stderr", - Data: line, + Data: scanner.Text() + "\n", Timestamp: time.Now().UnixMilli(), }); writeErr != nil { return From c4214b1f550dea051f22e49eba2a28272b88025b Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Mon, 16 Mar 2026 19:19:36 -0400 Subject: [PATCH 33/37] fix: os.Pipe for run(), WS lock leak on start fail, replay.Total() mutex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 (bash_session.go run()): Previous fix closed io.PipeWriters immediately after cmd.Start(), but exec's internal copy goroutines still write into those writers after Start() returns — closing them early caused "write on closed pipe" errors and broken output. Switch from io.Pipe to os.Pipe: the kernel owns the buffer, so closing the parent-side write fd after Start() is safe — the child holds its own fd copy and the reader sees EOF only when the child exits. Eliminates the scan↔Wait deadlock cleanly without breaking writes. P1 (session_ws.go): WS lock (wsConnected) was not released when Start()/StartPTY() returned an error. The deferred cleanup block is installed later, so the early return bypassed UnlockWS(). Fixed by calling session.UnlockWS() explicitly before the error return. P2 (replay_buffer.go): GetBashSessionStatus read session.replay.total directly without holding the replay mutex, creating a data race with concurrent write() calls. Added replayBuffer.Total() accessor (mutex-guarded) and updated the status call to use it. Co-Authored-By: Claude Sonnet 4.6 --- components/execd/pkg/runtime/bash_session.go | 27 ++++++++++++++----- components/execd/pkg/runtime/replay_buffer.go | 8 ++++++ .../execd/pkg/web/controller/session_ws.go | 1 + 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index c8069abb5..c0e8832ae 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -141,7 +141,7 @@ func (c *Controller) GetBashSessionStatus(sessionID string) (*BashSessionStatus, return &BashSessionStatus{ SessionID: sessionID, Running: running, - OutputOffset: session.replay.total, + OutputOffset: session.replay.Total(), }, nil } @@ -634,13 +634,27 @@ func (s *bashSession) run(ctx context.Context, request *ExecuteCodeRequest) erro // Do not pass envSnapshot via cmd.Env to avoid "argument list too long" when session env is large. // Child inherits parent env (nil => default in Go). The script file already has "export K=V" for // all session vars at the top, so the session environment is applied when the script runs. - stdoutR, stdoutW := io.Pipe() - stderrR, stderrW := io.Pipe() + // Use OS pipes (not io.Pipe) so we can close the parent-side write ends immediately + // after cmd.Start() without breaking in-flight writes. The kernel buffers data + // independently; closing the write end in the parent just signals EOF to the reader + // once the child has exited and flushed, without any "write on closed pipe" errors. + stdoutR, stdoutW, err := os.Pipe() + if err != nil { + return fmt.Errorf("create stdout pipe: %w", err) + } + stderrR, stderrW, err := os.Pipe() + if err != nil { + _ = stdoutR.Close() + _ = stdoutW.Close() + return fmt.Errorf("create stderr pipe: %w", err) + } cmd.Stdout = stdoutW cmd.Stderr = stderrW if err := cmd.Start(); err != nil { + _ = stdoutR.Close() _ = stdoutW.Close() + _ = stderrR.Close() _ = stderrW.Close() log.Error("start bash session failed: %v (command: %q)", err, request.Code) return fmt.Errorf("start bash: %w", err) @@ -648,10 +662,9 @@ func (s *bashSession) run(ctx context.Context, request *ExecuteCodeRequest) erro defer s.untrackCurrentProcess() s.trackCurrentProcess(cmd.Process.Pid) - // Close write ends immediately so scanners receive EOF when the process exits, - // not after cmd.Wait() returns. Without this, stdout scanning and cmd.Wait() - // deadlock: scan waits for EOF (needs stdoutW closed), Wait needs the process - // to exit (which it has, but pipe buffers keep stdoutW open in the parent). + // Close parent-side write ends now. The child has inherited its own copies; + // closing ours here means the reader gets EOF as soon as the child exits, + // without waiting for cmd.Wait() — eliminating the scan↔Wait deadlock. _ = stdoutW.Close() _ = stderrW.Close() diff --git a/components/execd/pkg/runtime/replay_buffer.go b/components/execd/pkg/runtime/replay_buffer.go index a32012130..9eaa55948 100644 --- a/components/execd/pkg/runtime/replay_buffer.go +++ b/components/execd/pkg/runtime/replay_buffer.go @@ -43,6 +43,14 @@ func (r *replayBuffer) write(p []byte) { } } +// Total returns the total number of bytes ever written to the buffer. +// Safe to call concurrently. +func (r *replayBuffer) Total() int64 { + r.mu.Lock() + defer r.mu.Unlock() + return r.total +} + // readFrom returns all bytes from offset onward (up to buffer capacity). // Returns (data, nextOffset). // - If offset >= total, returns (nil, total) — client is caught up. diff --git a/components/execd/pkg/web/controller/session_ws.go b/components/execd/pkg/web/controller/session_ws.go index add3f36f6..596a161c0 100644 --- a/components/execd/pkg/web/controller/session_ws.go +++ b/components/execd/pkg/web/controller/session_ws.go @@ -88,6 +88,7 @@ func (c *CodeInterpretingController) SessionWebSocket() { Error: "failed to start bash", Code: model.WSErrCodeStartFailed, }) + session.UnlockWS() return } } From de472b6a01d2a705da1ae4d2ebcb2e1bacc299cc Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Mon, 16 Mar 2026 19:57:55 -0400 Subject: [PATCH 34/37] fix: stream raw bytes in WS pumps instead of line scanning bufio.Scanner buffers until newline, which delays or drops PTY prompts, progress spinners, and cursor-control sequences that never emit \n. Replace stdout/stderr scanners with raw 32 KiB chunk reads via io.Read so all output is forwarded immediately without line-boundary buffering. Also fix TestPTY_PipeModeUnchanged: poll replay buffer instead of blocking on PipeReader, which was racy when output arrived before AttachOutput installed the PipeWriter. Co-Authored-By: Claude Sonnet 4.6 --- .../pkg/runtime/bash_session_pty_test.go | 24 ++++-- .../execd/pkg/web/controller/session_ws.go | 83 ++++++++----------- 2 files changed, 52 insertions(+), 55 deletions(-) diff --git a/components/execd/pkg/runtime/bash_session_pty_test.go b/components/execd/pkg/runtime/bash_session_pty_test.go index 6206aa6fd..a41bb402b 100644 --- a/components/execd/pkg/runtime/bash_session_pty_test.go +++ b/components/execd/pkg/runtime/bash_session_pty_test.go @@ -146,13 +146,27 @@ func TestPTY_PipeModeUnchanged(t *testing.T) { // ResizePTY must be a no-op (no error) when not in PTY mode. require.NoError(t, s.ResizePTY(100, 30)) + // Attach output first so the broadcast goroutine has a PipeWriter in place, + // then write stdin to avoid a race where output lands only in the replay buffer. + outR, _, detach := s.AttachOutput() + defer detach() + // Stdin must still work via pipe. _, err := s.WriteStdin([]byte("echo pipe-ok\n")) require.NoError(t, err) - // Read from stdout via AttachOutput. - outR, _, detach := s.AttachOutput() - defer detach() - out := readOutputTimeout(outR, 3*time.Second) - assert.Contains(t, out, "pipe-ok", "expected pipe-mode echo output, got: %q", out) + // Poll the replay buffer until output appears — this is reliable regardless of + // whether the output arrived before or after AttachOutput installed the PipeWriter. + deadline := time.Now().Add(5 * time.Second) + var got string + for time.Now().Before(deadline) { + data, _ := s.replay.readFrom(0) + got = string(data) + if strings.Contains(got, "pipe-ok") { + break + } + time.Sleep(50 * time.Millisecond) + } + _ = outR // attached above; detach() will clean up + assert.Contains(t, got, "pipe-ok", "expected pipe-mode echo output in replay buffer, got: %q", got) } diff --git a/components/execd/pkg/web/controller/session_ws.go b/components/execd/pkg/web/controller/session_ws.go index 596a161c0..f5d1ecc78 100644 --- a/components/execd/pkg/web/controller/session_ws.go +++ b/components/execd/pkg/web/controller/session_ws.go @@ -15,8 +15,8 @@ package controller import ( - "bufio" "context" + "io" "net/http" "strconv" "sync" @@ -160,66 +160,49 @@ func (c *CodeInterpretingController) SessionWebSocket() { } }() - // 8. Write pump — stdout scanner. - // Buffer sized to 16 MiB to handle large JSON/base64 lines from agent tools. - pumpWg.Add(1) - go func() { + // streamPump reads raw byte chunks from r and forwards them as WS frames of the given type. + // Raw reads (rather than line scanning) are required so that PTY prompts, progress spinners, + // and cursor-control sequences — which are often written without a trailing newline — are + // delivered immediately without buffering. + streamPump := func(r io.Reader, frameType string) { defer pumpWg.Done() - scanner := bufio.NewScanner(stdout) - scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) - for scanner.Scan() { - if ctx.Err() != nil { - return - } - // Replay buffer is written by the broadcast goroutine; just forward to client. - if writeErr := writeJSON(model.ServerFrame{ - Type: "stdout", - Data: scanner.Text() + "\n", - Timestamp: time.Now().UnixMilli(), - }); writeErr != nil { - return - } - } - if err := scanner.Err(); err != nil && ctx.Err() == nil { - _ = writeJSON(model.ServerFrame{ - Type: "error", - Error: "stdout read error: " + err.Error(), - Code: model.WSErrCodeRuntimeError, - }) - cancel() - } - }() - - // 9. Write pump — stderr scanner (pipe mode only; PTY merges stderr into ptmx). - // Buffer sized to 16 MiB to match stdout pump. stderr is nil in PTY mode. - if stderr != nil { - pumpWg.Add(1) - go func() { - defer pumpWg.Done() - scanner := bufio.NewScanner(stderr) - scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) - for scanner.Scan() { + buf := make([]byte, 32*1024) + for { + n, readErr := r.Read(buf) + if n > 0 { if ctx.Err() != nil { return } - // Replay buffer is written by the broadcast goroutine; just forward to client. if writeErr := writeJSON(model.ServerFrame{ - Type: "stderr", - Data: scanner.Text() + "\n", + Type: frameType, + Data: string(buf[:n]), Timestamp: time.Now().UnixMilli(), }); writeErr != nil { return } } - if err := scanner.Err(); err != nil && ctx.Err() == nil { - _ = writeJSON(model.ServerFrame{ - Type: "error", - Error: "stderr read error: " + err.Error(), - Code: model.WSErrCodeRuntimeError, - }) - cancel() + if readErr != nil { + if readErr != io.EOF && ctx.Err() == nil { + _ = writeJSON(model.ServerFrame{ + Type: "error", + Error: frameType + " read error: " + readErr.Error(), + Code: model.WSErrCodeRuntimeError, + }) + cancel() + } + return } - }() + } + } + + // 8. Write pump — stdout (raw byte chunks). + pumpWg.Add(1) + go streamPump(stdout, "stdout") + + // 9. Write pump — stderr (pipe mode only; PTY merges stderr into ptmx; nil in PTY mode). + if stderr != nil { + pumpWg.Add(1) + go streamPump(stderr, "stderr") } // 10. Exit watcher — sends exit frame when bash process ends. From d9c68bb741be19a9accdbb370dd407224f7f12cf Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Mon, 16 Mar 2026 20:26:51 -0400 Subject: [PATCH 35/37] fix: 404 for missing session in RunInSession + unblock WS read on exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RunInSession: map ErrContextNotFound → HTTP 404 so clients can distinguish missing sessions from server errors and apply correct retry/recreate logic - SessionWebSocket exit watcher: send CloseMessage + conn.Close() after the exit frame so ReadJSON unblocks immediately instead of waiting up to 60s for the read deadline; eliminates the 'session already connected' false positive window during reconnect after process exit Co-Authored-By: Claude Sonnet 4.6 --- .../execd/pkg/web/controller/codeinterpreting.go | 8 ++++++++ components/execd/pkg/web/controller/session_ws.go | 10 +++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/components/execd/pkg/web/controller/codeinterpreting.go b/components/execd/pkg/web/controller/codeinterpreting.go index 098ff7a57..c12b290f9 100644 --- a/components/execd/pkg/web/controller/codeinterpreting.go +++ b/components/execd/pkg/web/controller/codeinterpreting.go @@ -326,6 +326,14 @@ func (c *CodeInterpretingController) RunInSession() { err := codeRunner.RunInBashSession(ctx, runReq) if err != nil { + if errors.Is(err, runtime.ErrContextNotFound) { + c.RespondError( + http.StatusNotFound, + model.ErrorCodeContextNotFound, + fmt.Sprintf("session not found. %v", err), + ) + return + } c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, diff --git a/components/execd/pkg/web/controller/session_ws.go b/components/execd/pkg/web/controller/session_ws.go index f5d1ecc78..1c3113bc2 100644 --- a/components/execd/pkg/web/controller/session_ws.go +++ b/components/execd/pkg/web/controller/session_ws.go @@ -205,7 +205,10 @@ func (c *CodeInterpretingController) SessionWebSocket() { go streamPump(stderr, "stderr") } - // 10. Exit watcher — sends exit frame when bash process ends. + // 10. Exit watcher — sends exit frame when bash process ends, then closes the + // connection so the read loop's ReadJSON unblocks immediately rather than waiting + // up to 60s for the deadline. Without this, reconnect attempts during that window + // hit "session already connected" even though the process is already gone. go func() { defer cancel() doneCh := session.Done() @@ -219,6 +222,11 @@ func (c *CodeInterpretingController) SessionWebSocket() { } exitCode := session.ExitCode() _ = writeJSON(model.ServerFrame{Type: "exit", ExitCode: &exitCode}) + // Close with a normal closure code so the read loop gets an error immediately. + writeMu.Lock() + _ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "process exited")) + writeMu.Unlock() + conn.Close() }() // 11. Read pump — client → bash stdin. From 036c508c650ec32f0f81257682a5870d592d3dc6 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Mon, 16 Mar 2026 22:01:24 -0400 Subject: [PATCH 36/37] fix: fd leaks, replay/attach race, SSE 200 before validation, resize errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bash_session.go: close stdoutR/stderrR in broadcast goroutines (pipe mode) and stdoutR/stderrR in run() after consumers finish — every interactive session and every RunInBashSession call was leaking two OS file descriptors until GC - session_ws.go: move AttachOutput() before ReplaySessionOutput() so the live pipe sink is installed before the replay snapshot is taken; eliminates the window where bytes produced between snapshot and attach were delivered to neither the replay frame nor the live stream - session_ws.go: report PTY resize errors to the client as WS error frames instead of silently discarding them - codeinterpreting.go: validate session existence via ReplaySessionOutput before calling setupSSEResponse(); prevents HTTP 200 being committed before we know whether the session exists, which made 404 unreachable Co-Authored-By: Claude Sonnet 4.6 --- components/execd/pkg/runtime/bash_session.go | 4 ++ .../pkg/web/controller/codeinterpreting.go | 9 ++++ .../execd/pkg/web/controller/session_ws.go | 47 +++++++++++-------- 3 files changed, 40 insertions(+), 20 deletions(-) diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index c0e8832ae..8ac617a9a 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -256,6 +256,7 @@ func (s *bashSession) Start() error { // Output produced during client downtime is preserved in the replay buffer so // reconnecting clients can catch up via ?since=. go func() { + defer stdoutR.Close() // release the OS fd when the shell's stdout closes buf := make([]byte, 32*1024) for { n, err := stdoutR.Read(buf) @@ -284,6 +285,7 @@ func (s *bashSession) Start() error { // Broadcast goroutine: reads real stderr, always writes to replay buffer, and // fans out to the current per-connection sink when one is attached. go func() { + defer stderrR.Close() // release the OS fd when the shell's stderr closes buf := make([]byte, 32*1024) for { n, err := stderrR.Read(buf) @@ -672,6 +674,7 @@ func (s *bashSession) run(ctx context.Context, request *ExecuteCodeRequest) erro stderrDone := make(chan struct{}) go func() { defer close(stderrDone) + defer stderrR.Close() // release OS fd once stderr is fully drained stderrScanner := bufio.NewScanner(stderrR) stderrScanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) for stderrScanner.Scan() { @@ -683,6 +686,7 @@ func (s *bashSession) run(ctx context.Context, request *ExecuteCodeRequest) erro } }() + defer stdoutR.Close() // release OS fd once stdout is fully drained scanner := bufio.NewScanner(stdoutR) scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) diff --git a/components/execd/pkg/web/controller/codeinterpreting.go b/components/execd/pkg/web/controller/codeinterpreting.go index c12b290f9..0043458a6 100644 --- a/components/execd/pkg/web/controller/codeinterpreting.go +++ b/components/execd/pkg/web/controller/codeinterpreting.go @@ -304,6 +304,15 @@ func (c *CodeInterpretingController) RunInSession() { Cwd: request.Cwd, Timeout: timeout, } + // Verify the session exists BEFORE committing the SSE response (200 + headers). + // Once setupSSEResponse() flushes, we can no longer send HTTP error codes. + if _, _, err := codeRunner.ReplaySessionOutput(sessionID, 0); err != nil { + if errors.Is(err, runtime.ErrContextNotFound) { + c.RespondError(http.StatusNotFound, model.ErrorCodeContextNotFound, "session not found") + return + } + } + ctx, cancel := context.WithCancel(c.ctx.Request.Context()) defer cancel() runReq.Hooks = c.setServerEventsHandler(ctx) diff --git a/components/execd/pkg/web/controller/session_ws.go b/components/execd/pkg/web/controller/session_ws.go index 1c3113bc2..7c1fd9e5e 100644 --- a/components/execd/pkg/web/controller/session_ws.go +++ b/components/execd/pkg/web/controller/session_ws.go @@ -93,7 +93,24 @@ func (c *CodeInterpretingController) SessionWebSocket() { } } - // 5. Replay buffered output if ?since= is provided. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // 5. Attach per-connection pipe readers BEFORE replaying buffered output. + // This ensures the live sink is in place before we snapshot the replay buffer, + // so any bytes the broadcast goroutine emits after the snapshot are delivered + // via the live pipe rather than falling into the gap between replay and attach. + stdout, stderr, detach := session.AttachOutput() + var pumpWg sync.WaitGroup + defer func() { + cancel() + detach() + pumpWg.Wait() + session.UnlockWS() + }() + + // 6. Replay buffered output if ?since= is provided — done AFTER AttachOutput so + // the live sink is already in place and no bytes can fall into the gap. if sinceStr := c.ctx.Query("since"); sinceStr != "" { if since, parseErr := strconv.ParseInt(sinceStr, 10, 64); parseErr == nil { replayData, nextOffset, _ := codeRunner.ReplaySessionOutput(sessionID, since) @@ -107,7 +124,7 @@ func (c *CodeInterpretingController) SessionWebSocket() { } } - // 6. Send connected frame — mode derived from actual session state, not the request parameter, + // 7. Send connected frame — mode derived from actual session state, not the request parameter, // so reconnecting clients always receive the correct terminal assumptions. mode := "pipe" if session.IsPTY() { @@ -119,23 +136,7 @@ func (c *CodeInterpretingController) SessionWebSocket() { Mode: mode, }) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Attach per-connection pipe readers. detach() closes the write ends on handler exit, - // causing scanner goroutines to receive EOF and exit promptly. UnlockWS is called only - // after pumps finish, preventing a reconnecting client from starting new scanners while - // stale ones are still reading from the shared pipe. - stdout, stderr, detach := session.AttachOutput() - var pumpWg sync.WaitGroup - defer func() { - cancel() - detach() - pumpWg.Wait() - session.UnlockWS() - }() - - // 7. Ping/pong keepalive — RFC 6455 control-level pings every 30s. + // 8. Ping/pong keepalive — RFC 6455 control-level pings every 30s. conn.SetPongHandler(func(string) error { conn.SetReadDeadline(time.Now().Add(60 * time.Second)) //nolint:errcheck return nil @@ -256,7 +257,13 @@ func (c *CodeInterpretingController) SessionWebSocket() { session.SendSignal(frame.Signal) case "resize": if session.IsPTY() { - _ = session.ResizePTY(uint16(frame.Cols), uint16(frame.Rows)) + if resizeErr := session.ResizePTY(uint16(frame.Cols), uint16(frame.Rows)); resizeErr != nil { + _ = writeJSON(model.ServerFrame{ + Type: "error", + Error: "resize failed: " + resizeErr.Error(), + Code: model.WSErrCodeRuntimeError, + }) + } } // Silently ignored in pipe mode; accepted to avoid client errors. case "ping": From 6d32a7b108049b64d2ca8dd7e91182ddb52476b8 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Mon, 16 Mar 2026 22:12:41 -0400 Subject: [PATCH 37/37] fix: Windows build conflict on GetBashSession + replay duplicate output - Move GetBashSession from ctrl.go (no build tag) to bash_session.go (!windows build tag) to eliminate the duplicate method definition that breaks Windows compilation - Fix replay/attach ordering: snapshot replay buffer BEFORE AttachOutput so bytes produced between snapshot and attach land in the live pipe only; previously AttachOutput was called first, causing those bytes to appear in both the replay frame and the live stream (duplicate output) Co-Authored-By: Claude Sonnet 4.6 --- components/execd/pkg/runtime/bash_session.go | 9 ++++ components/execd/pkg/runtime/ctrl.go | 8 ---- .../execd/pkg/web/controller/session_ws.go | 42 +++++++++++-------- 3 files changed, 34 insertions(+), 25 deletions(-) diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go index 8ac617a9a..deacb0774 100644 --- a/components/execd/pkg/runtime/bash_session.go +++ b/components/execd/pkg/runtime/bash_session.go @@ -120,6 +120,15 @@ func (c *Controller) WriteSessionOutput(sessionID string, data []byte) { // ReplaySessionOutput returns buffered output bytes starting from offset. // Returns (data, nextOffset). See replayBuffer.readFrom for semantics. +// GetBashSession retrieves a bash session by ID. Returns nil if not found. +func (c *Controller) GetBashSession(sessionID string) BashSession { + s := c.getBashSession(sessionID) + if s == nil { + return nil + } + return s +} + func (c *Controller) ReplaySessionOutput(sessionID string, offset int64) ([]byte, int64, error) { session := c.getBashSession(sessionID) if session == nil { diff --git a/components/execd/pkg/runtime/ctrl.go b/components/execd/pkg/runtime/ctrl.go index ad0d5f1fa..2946fd81b 100644 --- a/components/execd/pkg/runtime/ctrl.go +++ b/components/execd/pkg/runtime/ctrl.go @@ -66,14 +66,6 @@ type commandKernel struct { content string } -// GetBashSession retrieves a bash session by ID. Returns nil if not found. -func (c *Controller) GetBashSession(sessionID string) BashSession { - s := c.getBashSession(sessionID) - if s == nil { - return nil - } - return s -} // NewController creates a runtime controller. func NewController(baseURL, token string) *Controller { diff --git a/components/execd/pkg/web/controller/session_ws.go b/components/execd/pkg/web/controller/session_ws.go index 7c1fd9e5e..bcc26ea6c 100644 --- a/components/execd/pkg/web/controller/session_ws.go +++ b/components/execd/pkg/web/controller/session_ws.go @@ -96,10 +96,24 @@ func (c *CodeInterpretingController) SessionWebSocket() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // 5. Attach per-connection pipe readers BEFORE replaying buffered output. - // This ensures the live sink is in place before we snapshot the replay buffer, - // so any bytes the broadcast goroutine emits after the snapshot are delivered - // via the live pipe rather than falling into the gap between replay and attach. + // 5. Snapshot the replay buffer offset THEN attach the live pipe — in that order. + // + // Why this order matters: + // - Snapshotting first captures a definite "replay up to here" watermark. + // - AttachOutput installs the PipeWriter so the broadcast goroutine begins + // queuing bytes into the pipe immediately. + // - Any byte produced between snapshot and attach lands in the pipe only + // (not in the replay frame), so each byte is delivered exactly once. + // - If we attached first and snapshotted second, bytes produced in that + // window would appear in both the replay frame and the live pipe (duplicate). + var replayData []byte + var replayNextOffset int64 + if sinceStr := c.ctx.Query("since"); sinceStr != "" { + if since, parseErr := strconv.ParseInt(sinceStr, 10, 64); parseErr == nil { + replayData, replayNextOffset, _ = codeRunner.ReplaySessionOutput(sessionID, since) + } + } + stdout, stderr, detach := session.AttachOutput() var pumpWg sync.WaitGroup defer func() { @@ -109,19 +123,13 @@ func (c *CodeInterpretingController) SessionWebSocket() { session.UnlockWS() }() - // 6. Replay buffered output if ?since= is provided — done AFTER AttachOutput so - // the live sink is already in place and no bytes can fall into the gap. - if sinceStr := c.ctx.Query("since"); sinceStr != "" { - if since, parseErr := strconv.ParseInt(sinceStr, 10, 64); parseErr == nil { - replayData, nextOffset, _ := codeRunner.ReplaySessionOutput(sessionID, since) - if len(replayData) > 0 { - _ = writeJSON(model.ServerFrame{ - Type: "replay", - Data: string(replayData), - Offset: nextOffset, - }) - } - } + // 6. Send replay frame now that the live sink is attached — no gap, no duplicates. + if len(replayData) > 0 { + _ = writeJSON(model.ServerFrame{ + Type: "replay", + Data: string(replayData), + Offset: replayNextOffset, + }) } // 7. Send connected frame — mode derived from actual session state, not the request parameter,