feat(shell): add parallel bash shell subsystem auto-started per workspace

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-03 16:36:35 +08:00
co-authored by Claude
parent 75e807c154
commit 0ec3efa812
14 changed files with 730 additions and 12 deletions
+9 -4
View File
@@ -11,6 +11,7 @@ import (
"codespace/internal/process"
"codespace/internal/service"
"codespace/internal/shell"
"codespace/internal/workspace"
"github.com/gin-gonic/gin"
@@ -29,11 +30,13 @@ func TestProcessWS(t *testing.T) {
wsRoot := filepath.Join(tmpDir, "workspaces")
wsMgr := workspace.NewLocalManager(wsRoot)
procMgr := process.NewManager(opencodePath)
wsSvc := service.NewWorkspaceService(wsMgr, procMgr, nil)
shellMgr := shell.NewManager("bash", []string{"-i"})
wsSvc := service.NewWorkspaceService(wsMgr, procMgr, shellMgr, nil)
fileSvc := service.NewFileService(wsMgr, 1<<20)
procSvc := service.NewProcessService(wsMgr, procMgr, nil)
shellSvc := service.NewShellService(wsMgr, shellMgr, nil)
r := NewRouter(wsSvc, fileSvc, procSvc, nil, gin.TestMode)
r := NewRouter(wsSvc, fileSvc, procSvc, shellSvc, nil, gin.TestMode)
srv := httptest.NewServer(r)
defer srv.Close()
@@ -102,11 +105,13 @@ func TestProcessWSMultiSubscriber(t *testing.T) {
wsRoot := filepath.Join(tmpDir, "workspaces")
wsMgr := workspace.NewLocalManager(wsRoot)
procMgr := process.NewManager(opencodePath)
wsSvc := service.NewWorkspaceService(wsMgr, procMgr, nil)
shellMgr := shell.NewManager("bash", []string{"-i"})
wsSvc := service.NewWorkspaceService(wsMgr, procMgr, shellMgr, nil)
fileSvc := service.NewFileService(wsMgr, 1<<20)
procSvc := service.NewProcessService(wsMgr, procMgr, nil)
shellSvc := service.NewShellService(wsMgr, shellMgr, nil)
r := NewRouter(wsSvc, fileSvc, procSvc, nil, gin.TestMode)
r := NewRouter(wsSvc, fileSvc, procSvc, shellSvc, nil, gin.TestMode)
srv := httptest.NewServer(r)
defer srv.Close()
+8 -1
View File
@@ -10,7 +10,7 @@ import (
)
// NewRouter builds a Gin engine with all API routes registered.
func NewRouter(workspaces *service.WorkspaceService, files *service.FileService, processes *service.ProcessService, lg *slog.Logger, ginMode string) *gin.Engine {
func NewRouter(workspaces *service.WorkspaceService, files *service.FileService, processes *service.ProcessService, shells *service.ShellService, lg *slog.Logger, ginMode string) *gin.Engine {
gin.SetMode(ginMode)
r := gin.New()
r.Use(gin.Recovery())
@@ -19,6 +19,7 @@ func NewRouter(workspaces *service.WorkspaceService, files *service.FileService,
wsHandler := &workspaceHandler{svc: workspaces}
fileHandler := &fileHandler{svc: files}
procHandler := &processHandler{svc: processes}
shellHandler := &shellHandler{svc: shells}
r.GET("/healthz", healthHandler)
@@ -42,6 +43,12 @@ func NewRouter(workspaces *service.WorkspaceService, files *service.FileService,
api.GET("/workspaces/:id/process/status", procHandler.status)
api.GET("/workspaces/:id/process/ws", procHandler.ws)
api.POST("/workspaces/:id/shell/start", shellHandler.start)
api.POST("/workspaces/:id/shell/stop", shellHandler.stop)
api.POST("/workspaces/:id/shell/restart", shellHandler.restart)
api.GET("/workspaces/:id/shell/status", shellHandler.status)
api.GET("/workspaces/:id/shell/ws", shellHandler.ws)
return r
}
+5 -2
View File
@@ -9,6 +9,7 @@ import (
"codespace/internal/process"
"codespace/internal/service"
"codespace/internal/shell"
"codespace/internal/workspace"
"github.com/gin-gonic/gin"
@@ -24,10 +25,12 @@ func setupTestRouterWithMaxWriteBytes(t *testing.T, maxWriteBytes int64) http.Ha
root := t.TempDir()
wsMgr := workspace.NewLocalManager(root)
procMgr := process.NewManager("")
wsSvc := service.NewWorkspaceService(wsMgr, procMgr, nil)
shellMgr := shell.NewManager("bash", []string{"-i"})
wsSvc := service.NewWorkspaceService(wsMgr, procMgr, shellMgr, nil)
fileSvc := service.NewFileService(wsMgr, maxWriteBytes)
procSvc := service.NewProcessService(wsMgr, procMgr, nil)
return NewRouter(wsSvc, fileSvc, procSvc, nil, gin.TestMode)
shellSvc := service.NewShellService(wsMgr, shellMgr, nil)
return NewRouter(wsSvc, fileSvc, procSvc, shellSvc, nil, gin.TestMode)
}
func createWorkspaceForTest(t *testing.T, router http.Handler, id string) {
+157
View File
@@ -0,0 +1,157 @@
package api
import (
"fmt"
"net/http"
"sync"
"time"
"codespace/internal/model"
"codespace/internal/service"
"codespace/internal/shell"
"codespace/internal/util"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
type shellHandler struct {
svc *service.ShellService
}
func shellExitBanner(exit shell.ExitInfo) string {
if exit.Signal != "" {
return fmt.Sprintf("\r\n[process exited: signal %s]\r\n", exit.Signal)
}
return fmt.Sprintf("\r\n[process exited with code %d]\r\n", exit.Code)
}
func (h *shellHandler) start(c *gin.Context) {
id := c.Param("id")
if err := h.svc.Start(id); err != nil {
writeError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *shellHandler) stop(c *gin.Context) {
id := c.Param("id")
if err := h.svc.Stop(id); err != nil {
writeError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *shellHandler) restart(c *gin.Context) {
id := c.Param("id")
if err := h.svc.Restart(id); err != nil {
writeError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *shellHandler) status(c *gin.Context) {
id := c.Param("id")
status, err := h.svc.Status(id)
if err != nil {
writeError(c, err)
return
}
c.JSON(http.StatusOK, model.ShellStatusResponse{
WorkspaceID: status.WorkspaceID,
Running: status.Running,
PID: status.PID,
})
}
func (h *shellHandler) ws(c *gin.Context) {
id := c.Param("id")
stdin, err := h.svc.Input(id)
if err != nil {
if util.CodeOf(err) == util.CodeNotFound {
c.JSON(409, gin.H{"error": "shell not running"})
return
}
writeError(c, err)
return
}
sub, err := h.svc.Subscribe(id)
if err != nil {
writeError(c, err)
return
}
exit, _ := h.svc.ExitStatus(id)
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
ReadBufferSize: 4096,
WriteBufferSize: 4096,
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
done := make(chan struct{})
var closeOnce sync.Once
closeAll := func() {
closeOnce.Do(func() {
sub.Close()
conn.Close()
stdin.Close()
close(done)
})
}
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(5*time.Second)); err != nil {
closeAll()
return
}
case chunk, ok := <-sub.Output():
if !ok {
banner := shellExitBanner(exit)
conn.WriteMessage(websocket.TextMessage, []byte(banner))
conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
closeAll()
return
}
if err := conn.WriteMessage(websocket.TextMessage, chunk); err != nil {
closeAll()
return
}
}
}
}()
conn.SetReadLimit(1 << 20)
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
for {
mt, data, err := conn.ReadMessage()
if err != nil {
break
}
if mt == websocket.TextMessage {
stdin.Write(data)
}
}
closeAll()
}