shell.go 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. package shell
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "os"
  7. "os/exec"
  8. "path/filepath"
  9. "strings"
  10. "sync"
  11. "syscall"
  12. "time"
  13. "github.com/opencode-ai/opencode/internal/status"
  14. )
  15. type PersistentShell struct {
  16. cmd *exec.Cmd
  17. stdin *os.File
  18. isAlive bool
  19. cwd string
  20. mu sync.Mutex
  21. commandQueue chan *commandExecution
  22. }
  23. type commandExecution struct {
  24. command string
  25. timeout time.Duration
  26. resultChan chan commandResult
  27. ctx context.Context
  28. }
  29. type commandResult struct {
  30. stdout string
  31. stderr string
  32. exitCode int
  33. interrupted bool
  34. err error
  35. }
  36. var (
  37. shellInstance *PersistentShell
  38. shellInstanceOnce sync.Once
  39. )
  40. func GetPersistentShell(workingDir string) *PersistentShell {
  41. shellInstanceOnce.Do(func() {
  42. shellInstance = newPersistentShell(workingDir)
  43. })
  44. if shellInstance == nil {
  45. shellInstance = newPersistentShell(workingDir)
  46. } else if !shellInstance.isAlive {
  47. shellInstance = newPersistentShell(shellInstance.cwd)
  48. }
  49. return shellInstance
  50. }
  51. func newPersistentShell(cwd string) *PersistentShell {
  52. shellPath := os.Getenv("SHELL")
  53. if shellPath == "" {
  54. shellPath = "/bin/bash"
  55. }
  56. cmd := exec.Command(shellPath, "-l")
  57. cmd.Dir = cwd
  58. stdinPipe, err := cmd.StdinPipe()
  59. if err != nil {
  60. return nil
  61. }
  62. cmd.Env = append(os.Environ(), "GIT_EDITOR=true")
  63. err = cmd.Start()
  64. if err != nil {
  65. return nil
  66. }
  67. shell := &PersistentShell{
  68. cmd: cmd,
  69. stdin: stdinPipe.(*os.File),
  70. isAlive: true,
  71. cwd: cwd,
  72. commandQueue: make(chan *commandExecution, 10),
  73. }
  74. go func() {
  75. defer func() {
  76. if r := recover(); r != nil {
  77. fmt.Fprintf(os.Stderr, "Panic in shell command processor: %v\n", r)
  78. shell.isAlive = false
  79. close(shell.commandQueue)
  80. }
  81. }()
  82. shell.processCommands()
  83. }()
  84. go func() {
  85. err := cmd.Wait()
  86. if err != nil {
  87. status.Error(fmt.Sprintf("Shell process exited with error: %v", err))
  88. }
  89. shell.isAlive = false
  90. close(shell.commandQueue)
  91. }()
  92. return shell
  93. }
  94. func (s *PersistentShell) processCommands() {
  95. for cmd := range s.commandQueue {
  96. result := s.execCommand(cmd.command, cmd.timeout, cmd.ctx)
  97. cmd.resultChan <- result
  98. }
  99. }
  100. func (s *PersistentShell) execCommand(command string, timeout time.Duration, ctx context.Context) commandResult {
  101. s.mu.Lock()
  102. defer s.mu.Unlock()
  103. if !s.isAlive {
  104. return commandResult{
  105. stderr: "Shell is not alive",
  106. exitCode: 1,
  107. err: errors.New("shell is not alive"),
  108. }
  109. }
  110. tempDir := os.TempDir()
  111. stdoutFile := filepath.Join(tempDir, fmt.Sprintf("opencode-stdout-%d", time.Now().UnixNano()))
  112. stderrFile := filepath.Join(tempDir, fmt.Sprintf("opencode-stderr-%d", time.Now().UnixNano()))
  113. statusFile := filepath.Join(tempDir, fmt.Sprintf("opencode-status-%d", time.Now().UnixNano()))
  114. cwdFile := filepath.Join(tempDir, fmt.Sprintf("opencode-cwd-%d", time.Now().UnixNano()))
  115. defer func() {
  116. os.Remove(stdoutFile)
  117. os.Remove(stderrFile)
  118. os.Remove(statusFile)
  119. os.Remove(cwdFile)
  120. }()
  121. fullCommand := fmt.Sprintf(`
  122. eval %s < /dev/null > %s 2> %s
  123. EXEC_EXIT_CODE=$?
  124. pwd > %s
  125. echo $EXEC_EXIT_CODE > %s
  126. `,
  127. shellQuote(command),
  128. shellQuote(stdoutFile),
  129. shellQuote(stderrFile),
  130. shellQuote(cwdFile),
  131. shellQuote(statusFile),
  132. )
  133. _, err := s.stdin.Write([]byte(fullCommand + "\n"))
  134. if err != nil {
  135. return commandResult{
  136. stderr: fmt.Sprintf("Failed to write command to shell: %v", err),
  137. exitCode: 1,
  138. err: err,
  139. }
  140. }
  141. interrupted := false
  142. startTime := time.Now()
  143. done := make(chan bool)
  144. go func() {
  145. for {
  146. select {
  147. case <-ctx.Done():
  148. s.killChildren()
  149. interrupted = true
  150. done <- true
  151. return
  152. case <-time.After(10 * time.Millisecond):
  153. if fileExists(statusFile) && fileSize(statusFile) > 0 {
  154. done <- true
  155. return
  156. }
  157. if timeout > 0 {
  158. elapsed := time.Since(startTime)
  159. if elapsed > timeout {
  160. s.killChildren()
  161. interrupted = true
  162. done <- true
  163. return
  164. }
  165. }
  166. }
  167. }
  168. }()
  169. <-done
  170. stdout := readFileOrEmpty(stdoutFile)
  171. stderr := readFileOrEmpty(stderrFile)
  172. exitCodeStr := readFileOrEmpty(statusFile)
  173. newCwd := readFileOrEmpty(cwdFile)
  174. exitCode := 0
  175. if exitCodeStr != "" {
  176. fmt.Sscanf(exitCodeStr, "%d", &exitCode)
  177. } else if interrupted {
  178. exitCode = 143
  179. stderr += "\nCommand execution timed out or was interrupted"
  180. }
  181. if newCwd != "" {
  182. s.cwd = strings.TrimSpace(newCwd)
  183. }
  184. return commandResult{
  185. stdout: stdout,
  186. stderr: stderr,
  187. exitCode: exitCode,
  188. interrupted: interrupted,
  189. }
  190. }
  191. func (s *PersistentShell) killChildren() {
  192. if s.cmd == nil || s.cmd.Process == nil {
  193. return
  194. }
  195. pgrepCmd := exec.Command("pgrep", "-P", fmt.Sprintf("%d", s.cmd.Process.Pid))
  196. output, err := pgrepCmd.Output()
  197. if err != nil {
  198. return
  199. }
  200. for pidStr := range strings.SplitSeq(string(output), "\n") {
  201. if pidStr = strings.TrimSpace(pidStr); pidStr != "" {
  202. var pid int
  203. fmt.Sscanf(pidStr, "%d", &pid)
  204. if pid > 0 {
  205. proc, err := os.FindProcess(pid)
  206. if err == nil {
  207. proc.Signal(syscall.SIGTERM)
  208. }
  209. }
  210. }
  211. }
  212. }
  213. func (s *PersistentShell) Exec(ctx context.Context, command string, timeoutMs int) (string, string, int, bool, error) {
  214. if !s.isAlive {
  215. return "", "Shell is not alive", 1, false, errors.New("shell is not alive")
  216. }
  217. timeout := time.Duration(timeoutMs) * time.Millisecond
  218. resultChan := make(chan commandResult)
  219. s.commandQueue <- &commandExecution{
  220. command: command,
  221. timeout: timeout,
  222. resultChan: resultChan,
  223. ctx: ctx,
  224. }
  225. result := <-resultChan
  226. return result.stdout, result.stderr, result.exitCode, result.interrupted, result.err
  227. }
  228. func (s *PersistentShell) Close() {
  229. s.mu.Lock()
  230. defer s.mu.Unlock()
  231. if !s.isAlive {
  232. return
  233. }
  234. s.stdin.Write([]byte("exit\n"))
  235. s.cmd.Process.Kill()
  236. s.isAlive = false
  237. }
  238. func shellQuote(s string) string {
  239. return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
  240. }
  241. func readFileOrEmpty(path string) string {
  242. content, err := os.ReadFile(path)
  243. if err != nil {
  244. return ""
  245. }
  246. return string(content)
  247. }
  248. func fileExists(path string) bool {
  249. _, err := os.Stat(path)
  250. return err == nil
  251. }
  252. func fileSize(path string) int64 {
  253. info, err := os.Stat(path)
  254. if err != nil {
  255. return 0
  256. }
  257. return info.Size()
  258. }