Files
wehub-resource-sync e0e362d700
SDK Tests / SDK CI (push) Blocked by required conditions
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
SDK Tests / Python SDK Tests (sandbox) (push) Waiting to run
SDK Tests / CLI Quality (push) Waiting to run
SDK Tests / CLI Tests (push) Waiting to run
SDK Tests / Python SDK Quality (code-interpreter) (push) Waiting to run
SDK Tests / Python SDK Quality (sandbox) (push) Waiting to run
SDK Tests / Python SDK Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Go SDK Quality And Tests (push) Waiting to run
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:39:33 +08:00

542 lines
17 KiB
Go

// Copyright 2025 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/binary"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os/exec"
"strings"
"sync"
"sync/atomic"
"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"
)
// buildPTYRouter assembles a minimal Gin router with only the /pty routes,
// avoiding any import cycle with pkg/web.
func buildPTYRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(gin.Recovery())
pty := r.Group("/pty")
{
pty.POST("", func(ctx *gin.Context) {
NewPTYController(ctx).CreatePTYSession()
})
pty.GET("/:sessionId", func(ctx *gin.Context) {
NewPTYController(ctx).GetPTYSessionStatus()
})
pty.DELETE("/:sessionId", func(ctx *gin.Context) {
NewPTYController(ctx).DeletePTYSession()
})
pty.GET("/:sessionId/ws", PTYSessionWebSocket)
}
return r
}
// newPTYTestServer creates a test HTTP server with fresh codeRunner and PTY routes only.
func newPTYTestServer(t *testing.T) *httptest.Server {
t.Helper()
prev := codeRunner
codeRunner = runtime.NewController("", "")
t.Cleanup(func() { codeRunner = prev })
return httptest.NewServer(buildPTYRouter())
}
// wsDialPTY dials a WebSocket URL; accepts an optional extra query string.
func wsDialPTY(t *testing.T, baseURL, path, query string) *websocket.Conn {
t.Helper()
u := "ws" + strings.TrimPrefix(baseURL+path, "http")
if query != "" {
u += "?" + query
}
conn, resp, err := websocket.DefaultDialer.Dial(u, nil)
if err != nil {
if resp != nil {
t.Fatalf("WS dial %s: %v (HTTP %d)", u, err, resp.StatusCode)
}
t.Fatalf("WS dial %s: %v", u, err)
}
t.Cleanup(func() { _ = conn.Close() })
return conn
}
// wsDialExpectHTTP dials and returns the HTTP status without upgrading (for 4xx cases).
func wsDialExpectHTTP(t *testing.T, baseURL, path, query string) int {
t.Helper()
u := "ws" + strings.TrimPrefix(baseURL+path, "http")
if query != "" {
u += "?" + query
}
_, resp, err := websocket.DefaultDialer.Dial(u, nil)
require.Error(t, err, "expected dial to fail with HTTP error")
require.NotNil(t, resp)
return resp.StatusCode
}
// ptyCreateSession calls POST /pty and returns the session_id.
func ptyCreateSession(t *testing.T, srv *httptest.Server) string {
t.Helper()
resp, err := http.Post(srv.URL+"/pty", "application/json", strings.NewReader(`{}`))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
var r model.CreatePTYSessionResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&r))
require.NotEmpty(t, r.SessionID)
return r.SessionID
}
// ptyReadFrame reads the next frame, handling both binary data frames and JSON control frames.
func ptyReadFrame(conn *websocket.Conn, timeout time.Duration) (model.ServerFrame, error) {
_ = conn.SetReadDeadline(time.Now().Add(timeout))
msgType, raw, err := conn.ReadMessage()
if err != nil {
return model.ServerFrame{}, err
}
if msgType == websocket.TextMessage {
var f model.ServerFrame
return f, json.Unmarshal(raw, &f)
}
// Binary data frame: decode type byte into ServerFrame.
if len(raw) == 0 {
return model.ServerFrame{}, errors.New("empty binary frame")
}
switch raw[0] {
case model.BinStdout:
return model.ServerFrame{Type: "stdout", Data: string(raw[1:])}, nil
case model.BinStderr:
return model.ServerFrame{Type: "stderr", Data: string(raw[1:])}, nil
case model.BinReplay:
if len(raw) < 9 {
return model.ServerFrame{}, fmt.Errorf("replay frame too short: %d bytes", len(raw))
}
offset := int64(binary.BigEndian.Uint64(raw[1:9]))
return model.ServerFrame{Type: "replay", Data: string(raw[9:]), Offset: offset}, nil
}
return model.ServerFrame{}, fmt.Errorf("unknown binary frame type 0x%02x", raw[0])
}
// ptyWaitFrame reads frames until one with the given type is found.
func ptyWaitFrame(t *testing.T, conn *websocket.Conn, wantType string, timeout time.Duration) model.ServerFrame {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
f, err := ptyReadFrame(conn, time.Until(deadline))
if err != nil {
t.Fatalf("ptyWaitFrame(%q): %v", wantType, err)
}
if f.Type == wantType {
return f
}
}
t.Fatalf("ptyWaitFrame(%q): timed out after %s", wantType, timeout)
return model.ServerFrame{}
}
// ptyOutputContains reads frames until stdout/stderr/replay contains substr.
func ptyOutputContains(t *testing.T, conn *websocket.Conn, substr string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
f, err := ptyReadFrame(conn, time.Until(deadline))
if err != nil {
t.Fatalf("ptyOutputContains(%q): read error: %v", substr, err)
}
if f.Type == "stdout" || f.Type == "stderr" || f.Type == "replay" {
if strings.Contains(f.Data, substr) {
return
}
}
}
t.Fatalf("ptyOutputContains(%q): timed out", substr)
}
// ptyWriteStdin sends a binary stdin frame (production path).
func ptyWriteStdin(t *testing.T, conn *websocket.Conn, text string) {
t.Helper()
frame := make([]byte, 1+len(text))
frame[0] = model.BinStdin
copy(frame[1:], text)
require.NoError(t, conn.WriteMessage(websocket.BinaryMessage, frame))
}
// --- Tests ---
func TestPTYWS_UnknownSessionReturns404(t *testing.T) {
srv := newPTYTestServer(t)
defer srv.Close()
code := wsDialExpectHTTP(t, srv.URL, "/pty/nonexistent/ws", "")
require.Equal(t, http.StatusNotFound, code)
}
func TestPTYWS_AlreadyConnectedReturns409(t *testing.T) {
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not found")
}
srv := newPTYTestServer(t)
defer srv.Close()
id := ptyCreateSession(t, srv)
conn1 := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "")
ptyWaitFrame(t, conn1, "connected", 10*time.Second)
code := wsDialExpectHTTP(t, srv.URL, "/pty/"+id+"/ws", "")
require.Equal(t, http.StatusConflict, code)
}
func TestPTYWS_ConnectedFramePTYMode(t *testing.T) {
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not found")
}
srv := newPTYTestServer(t)
defer srv.Close()
id := ptyCreateSession(t, srv)
conn := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "")
f := ptyWaitFrame(t, conn, "connected", 10*time.Second)
require.Equal(t, "connected", f.Type)
require.Equal(t, id, f.SessionID)
require.Equal(t, "pty", f.Mode)
}
func TestPTYWS_StdinForwarding(t *testing.T) {
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not found")
}
srv := newPTYTestServer(t)
defer srv.Close()
id := ptyCreateSession(t, srv)
conn := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "")
ptyWaitFrame(t, conn, "connected", 10*time.Second)
ptyWriteStdin(t, conn, "echo hello_ws\n")
ptyOutputContains(t, conn, "hello_ws", 8*time.Second)
}
func TestPTYWS_PingPong(t *testing.T) {
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not found")
}
srv := newPTYTestServer(t)
defer srv.Close()
id := ptyCreateSession(t, srv)
conn := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "")
ptyWaitFrame(t, conn, "connected", 10*time.Second)
require.NoError(t, conn.WriteJSON(model.ClientFrame{Type: "ping"}))
f := ptyWaitFrame(t, conn, "pong", 5*time.Second)
require.Equal(t, "pong", f.Type)
}
func TestPTYWS_ExitFrame(t *testing.T) {
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not found")
}
srv := newPTYTestServer(t)
defer srv.Close()
id := ptyCreateSession(t, srv)
conn := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "")
ptyWaitFrame(t, conn, "connected", 10*time.Second)
ptyWriteStdin(t, conn, "exit 0\n")
f := ptyWaitFrame(t, conn, "exit", 10*time.Second)
require.Equal(t, "exit", f.Type)
require.NotNil(t, f.ExitCode)
require.Equal(t, 0, *f.ExitCode)
}
func TestPTYWS_ReplayOnReconnect(t *testing.T) {
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not found")
}
srv := newPTYTestServer(t)
defer srv.Close()
id := ptyCreateSession(t, srv)
// First connection: produce output.
conn1 := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "")
ptyWaitFrame(t, conn1, "connected", 10*time.Second)
ptyWriteStdin(t, conn1, "echo replay_test\n")
ptyOutputContains(t, conn1, "replay_test", 8*time.Second)
// Check offset via REST.
resp, err := http.Get(srv.URL + "/pty/" + id)
require.NoError(t, err)
defer resp.Body.Close()
var status model.PTYSessionStatusResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&status))
require.True(t, status.OutputOffset > 0)
// Disconnect.
_ = conn1.Close()
time.Sleep(100 * time.Millisecond)
// Reconnect from offset 0 — should receive a replay frame.
conn2 := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "since=0")
deadline := time.Now().Add(8 * time.Second)
gotReplay := false
for time.Now().Before(deadline) {
f, err2 := ptyReadFrame(conn2, time.Until(deadline))
if err2 != nil {
break
}
if f.Type == "replay" && strings.Contains(f.Data, "replay_test") {
gotReplay = true
break
}
}
require.True(t, gotReplay, "expected replay frame containing 'replay_test'")
}
// TestPTYWS_TakeoverEvictsHolder verifies that ?takeover=1 evicts the current
// holder (closing its WS with WSCloseTakenOver) and that the taking-over client
// reattaches to the SAME shell: it replays the prior scrollback and can read a
// shell variable set by the evicted client.
func TestPTYWS_TakeoverEvictsHolder(t *testing.T) {
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not found")
}
srv := newPTYTestServer(t)
defer srv.Close()
id := ptyCreateSession(t, srv)
// Holder connects, sets a shell var, and emits a marker.
conn1 := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "")
ptyWaitFrame(t, conn1, "connected", 10*time.Second)
ptyWriteStdin(t, conn1, "TAKEOVER_VAR=alive\n")
ptyWriteStdin(t, conn1, "echo holder_marker\n")
ptyOutputContains(t, conn1, "holder_marker", 8*time.Second)
// A new client takes over.
conn2 := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "takeover=1&since=0")
// 1. The holder's connection is closed with the takeover close code.
var closeErr *websocket.CloseError
deadline := time.Now().Add(8 * time.Second)
for time.Now().Before(deadline) {
if _, _, err := conn1.ReadMessage(); err != nil {
_ = errors.As(err, &closeErr)
break
}
}
require.NotNil(t, closeErr, "holder should be closed with a WS CloseError after takeover")
require.Equal(t, model.WSCloseTakenOver, closeErr.Code)
// 2. The taking-over client replays the prior scrollback...
ptyOutputContains(t, conn2, "holder_marker", 8*time.Second)
ptyWaitFrame(t, conn2, "connected", 8*time.Second)
// 3. ...and it is the SAME shell: the var set on conn1 is still readable.
ptyWriteStdin(t, conn2, "echo SAMESHELL_$TAKEOVER_VAR\n")
ptyOutputContains(t, conn2, "SAMESHELL_alive", 8*time.Second)
}
// TestPTYWS_TakeoverOnFreeSessionConnects verifies ?takeover=1 is a no-op when the
// session is free: it connects normally (there is no holder to evict).
func TestPTYWS_TakeoverOnFreeSessionConnects(t *testing.T) {
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not found")
}
srv := newPTYTestServer(t)
defer srv.Close()
id := ptyCreateSession(t, srv)
conn := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "takeover=1")
f := ptyWaitFrame(t, conn, "connected", 10*time.Second)
require.Equal(t, id, f.SessionID)
}
// TestPTYWS_TakeoverRequiresWebSocketUpgrade verifies a plain HTTP GET carrying
// takeover=1 does NOT evict the holder: without a WS handshake it would evict and
// then fail to upgrade, orphaning the session. It must return 409 and leave the
// holder attached and functional.
func TestPTYWS_TakeoverRequiresWebSocketUpgrade(t *testing.T) {
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not found")
}
srv := newPTYTestServer(t)
defer srv.Close()
id := ptyCreateSession(t, srv)
conn1 := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "")
ptyWaitFrame(t, conn1, "connected", 10*time.Second)
// Plain HTTP GET (no Upgrade header) with takeover=1 → must be refused, not evict.
resp, err := http.Get(srv.URL + "/pty/" + id + "/ws?takeover=1")
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusConflict, resp.StatusCode)
// The holder is untouched: it still drives the shell.
ptyWriteStdin(t, conn1, "echo still_here\n")
ptyOutputContains(t, conn1, "still_here", 8*time.Second)
}
// TestPTYWS_ConcurrentTakeovers hammers a session with several simultaneous
// ?takeover=1 reconnects (each with non-empty replay). They must serialize through
// the lock without tripping the race detector — exercising the initial replay/connected
// writes vs. eviction and the cleanup-window paths — and the shell must survive.
func TestPTYWS_ConcurrentTakeovers(t *testing.T) {
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not found")
}
srv := newPTYTestServer(t)
defer srv.Close()
id := ptyCreateSession(t, srv)
// Holder seeds output so every takeover gets a non-empty replay frame.
conn0 := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "")
ptyWaitFrame(t, conn0, "connected", 10*time.Second)
ptyWriteStdin(t, conn0, "echo seed_output\n")
ptyOutputContains(t, conn0, "seed_output", 8*time.Second)
const n = 6
var wg sync.WaitGroup
var connectedCount int32
wsURL := "ws" + strings.TrimPrefix(srv.URL+"/pty/"+id+"/ws", "http") + "?takeover=1&since=0"
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
return
}
defer func() { _ = c.Close() }()
deadline := time.Now().Add(6 * time.Second)
for time.Now().Before(deadline) {
f, err := ptyReadFrame(c, time.Until(deadline))
if err != nil {
return
}
if f.Type == "connected" {
atomic.AddInt32(&connectedCount, 1)
return
}
}
}()
}
wg.Wait()
// They serialize via the lock; at least one must have attached.
require.GreaterOrEqual(t, atomic.LoadInt32(&connectedCount), int32(1))
// The session is still reclaimable and the shell survived the storm.
winner := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "takeover=1&since=0")
ptyWaitFrame(t, winner, "connected", 8*time.Second)
ptyWriteStdin(t, winner, "echo still_alive_after_storm\n")
ptyOutputContains(t, winner, "still_alive_after_storm", 8*time.Second)
}
func TestPTYWS_ResizeFrame(t *testing.T) {
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not found")
}
srv := newPTYTestServer(t)
defer srv.Close()
id := ptyCreateSession(t, srv)
conn := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "")
ptyWaitFrame(t, conn, "connected", 10*time.Second)
require.NoError(t, conn.WriteJSON(model.ClientFrame{
Type: "resize",
Cols: 120,
Rows: 40,
}))
time.Sleep(100 * time.Millisecond)
ptyWriteStdin(t, conn, "stty size\n")
ptyOutputContains(t, conn, "40 120", 8*time.Second)
}
func TestPTYWS_PipeModeConnectedFrame(t *testing.T) {
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not found")
}
srv := newPTYTestServer(t)
defer srv.Close()
id := ptyCreateSession(t, srv)
conn := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "pty=0")
f := ptyWaitFrame(t, conn, "connected", 10*time.Second)
require.Equal(t, "pipe", f.Mode)
}
func TestPTYWS_RESTGetStatus(t *testing.T) {
srv := newPTYTestServer(t)
defer srv.Close()
id := ptyCreateSession(t, srv)
resp, err := http.Get(srv.URL + "/pty/" + id)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var s model.PTYSessionStatusResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&s))
require.Equal(t, id, s.SessionID)
require.False(t, s.Running)
}
func TestPTYWS_RESTDeleteSession(t *testing.T) {
srv := newPTYTestServer(t)
defer srv.Close()
id := ptyCreateSession(t, srv)
req, err := http.NewRequest(http.MethodDelete, srv.URL+"/pty/"+id, nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
_ = resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
req2, _ := http.NewRequest(http.MethodDelete, srv.URL+"/pty/"+id, nil)
resp2, err := http.DefaultClient.Do(req2)
require.NoError(t, err)
_ = resp2.Body.Close()
require.Equal(t, http.StatusNotFound, resp2.StatusCode)
}