From 31f27bcdc69786a94aca502eaeb4e184b2003742 Mon Sep 17 00:00:00 2001 From: kylecarbs Date: Tue, 26 Apr 2022 20:24:35 +0000 Subject: [PATCH 1/6] feat: Add web terminal with reconnecting TTYs This adds a web terminal that can reconnect to resume sessions! No more disconnects, and no more bad bufferring! --- .vscode/settings.json | 1 + agent/agent.go | 216 +++++++++++++++++++++++++++++++-- agent/agent_test.go | 48 +++++++- agent/conn.go | 21 ++++ cli/agent.go | 4 +- cli/configssh_test.go | 4 +- cli/ssh_test.go | 8 +- coderd/coderd.go | 1 + coderd/workspaceagents.go | 131 ++++++++++++++++++++ coderd/workspaceagents_test.go | 87 ++++++++++++- codersdk/workspaceagents.go | 33 +++++ go.mod | 1 + go.sum | 2 + site/webpack.dev.ts | 5 +- 14 files changed, 539 insertions(+), 23 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 495f16f083531..8bc8adc06bea3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -47,6 +47,7 @@ "ptty", "ptytest", "retrier", + "rpty", "sdkproto", "Signup", "stretchr", diff --git a/agent/agent.go b/agent/agent.go index eefebc8b8239a..4330a0eaadc7b 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "crypto/rsa" + "encoding/json" "errors" "fmt" "io" @@ -12,10 +13,14 @@ import ( "os/exec" "os/user" "runtime" + "strconv" "strings" "sync" "time" + "github.com/google/uuid" + "github.com/smallnest/ringbuffer" + gsyslog "github.com/hashicorp/go-syslog" "go.uber.org/atomic" @@ -33,6 +38,11 @@ import ( "golang.org/x/xerrors" ) +type Options struct { + ReconnectingPTYTimeout time.Duration + Logger slog.Logger +} + type Metadata struct { OwnerEmail string `json:"owner_email"` OwnerUsername string `json:"owner_username"` @@ -42,13 +52,20 @@ type Metadata struct { type Dialer func(ctx context.Context, logger slog.Logger) (Metadata, *peerbroker.Listener, error) -func New(dialer Dialer, logger slog.Logger) io.Closer { +func New(dialer Dialer, options *Options) io.Closer { + if options == nil { + options = &Options{} + } + if options.ReconnectingPTYTimeout == 0 { + options.ReconnectingPTYTimeout = 5 * time.Minute + } ctx, cancelFunc := context.WithCancel(context.Background()) server := &agent{ - dialer: dialer, - logger: logger, - closeCancel: cancelFunc, - closed: make(chan struct{}), + dialer: dialer, + reconnectingPTYTimeout: options.ReconnectingPTYTimeout, + logger: options.Logger, + closeCancel: cancelFunc, + closed: make(chan struct{}), } server.init(ctx) return server @@ -58,6 +75,9 @@ type agent struct { dialer Dialer logger slog.Logger + reconnectingPTYs sync.Map + reconnectingPTYTimeout time.Duration + connCloseWait sync.WaitGroup closeCancel context.CancelFunc closeMutex sync.Mutex @@ -196,6 +216,8 @@ func (a *agent) handlePeerConn(ctx context.Context, conn *peer.Conn) { switch channel.Protocol() { case "ssh": go a.sshServer.HandleConn(channel.NetConn()) + case "reconnecting-pty": + go a.handleReconnectingPTY(ctx, channel.Label(), channel.NetConn()) default: a.logger.Warn(ctx, "unhandled protocol from channel", slog.F("protocol", channel.Protocol()), @@ -282,22 +304,25 @@ func (a *agent) init(ctx context.Context) { go a.run(ctx) } -func (a *agent) handleSSHSession(session ssh.Session) error { +// createCommand processes raw command input with OpenSSH-like behavior. +// If the rawCommand provided is empty, it will default to the users shell. +// This injects environment variables specified by the user at launch too. +func (a *agent) createCommand(ctx context.Context, rawCommand string, env []string) (*exec.Cmd, error) { currentUser, err := user.Current() if err != nil { - return xerrors.Errorf("get current user: %w", err) + return nil, xerrors.Errorf("get current user: %w", err) } username := currentUser.Username shell, err := usershell.Get(username) if err != nil { - return xerrors.Errorf("get user shell: %w", err) + return nil, xerrors.Errorf("get user shell: %w", err) } // gliderlabs/ssh returns a command slice of zero // when a shell is requested. - command := session.RawCommand() - if len(session.Command()) == 0 { + command := rawCommand + if len(command) == 0 { command = shell } @@ -307,11 +332,11 @@ func (a *agent) handleSSHSession(session ssh.Session) error { if runtime.GOOS == "windows" { caller = "/c" } - cmd := exec.CommandContext(session.Context(), shell, caller, command) - cmd.Env = append(os.Environ(), session.Environ()...) + cmd := exec.CommandContext(ctx, shell, caller, command) + cmd.Env = append(os.Environ(), env...) executablePath, err := os.Executable() if err != nil { - return xerrors.Errorf("getting os executable: %w", err) + return nil, xerrors.Errorf("getting os executable: %w", err) } // Git on Windows resolves with UNIX-style paths. // If using backslashes, it's unable to find the executable. @@ -332,6 +357,14 @@ func (a *agent) handleSSHSession(session ssh.Session) error { } } } + return cmd, nil +} + +func (a *agent) handleSSHSession(session ssh.Session) error { + cmd, err := a.createCommand(session.Context(), session.RawCommand(), session.Environ()) + if err != nil { + return err + } sshPty, windowSize, isPty := session.Pty() if isPty { @@ -381,6 +414,144 @@ func (a *agent) handleSSHSession(session ssh.Session) error { return cmd.Wait() } +func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn net.Conn) { + defer conn.Close() + + idParts := strings.Split(rawID, ":") + if len(idParts) != 3 { + a.logger.Warn(ctx, "client sent invalid id format", slog.F("raw-id", rawID)) + return + } + id := idParts[0] + // Enforce a consistent format for IDs. + _, err := uuid.Parse(id) + if err != nil { + a.logger.Warn(ctx, "client sent reconnection token that isn't a uuid", slog.F("id", id), slog.Error(err)) + return + } + height, err := strconv.Atoi(idParts[1]) + if err != nil { + a.logger.Warn(ctx, "client sent invalid height", slog.F("id", id), slog.F("height", idParts[1])) + return + } + width, err := strconv.Atoi(idParts[2]) + if err != nil { + a.logger.Warn(ctx, "client sent invalid width", slog.F("id", id), slog.F("width", idParts[2])) + return + } + + var rpty *reconnectingPTY + rawRPTY, ok := a.reconnectingPTYs.Load(id) + if ok { + rpty, ok = rawRPTY.(*reconnectingPTY) + if !ok { + a.logger.Warn(ctx, "found invalid type in reconnecting pty map", slog.F("id", id)) + } + } else { + // Empty command will default to the users shell! + cmd, err := a.createCommand(ctx, "", nil) + if err != nil { + a.logger.Warn(ctx, "create reconnecting pty command", slog.Error(err)) + return + } + ptty, _, err := pty.Start(cmd) + if err != nil { + a.logger.Warn(ctx, "start reconnecting pty command", slog.F("id", id)) + } + + a.closeMutex.Lock() + a.connCloseWait.Add(1) + a.closeMutex.Unlock() + rpty = &reconnectingPTY{ + activeConns: make(map[string]net.Conn), + ptty: ptty, + timeout: time.NewTimer(a.reconnectingPTYTimeout), + // Default to buffer 1MB. + ringBuffer: ringbuffer.New(1 << 20), + } + a.reconnectingPTYs.Store(id, rpty) + go func() { + // Close if the inactive timeout occurs, or the context ends. + select { + case <-rpty.timeout.C: + a.logger.Info(ctx, "killing reconnecting pty due to inactivity", slog.F("id", id)) + case <-ctx.Done(): + } + rpty.Close() + }() + go func() { + buffer := make([]byte, 32*1024) + for { + read, err := rpty.ptty.Output().Read(buffer) + if err != nil { + rpty.Close() + break + } + part := buffer[:read] + _, err = rpty.ringBuffer.Write(part) + if err != nil { + a.logger.Error(ctx, "reconnecting pty write buffer", slog.Error(err), slog.F("id", id)) + break + } + rpty.activeConnsMutex.Lock() + for _, conn := range rpty.activeConns { + _, _ = conn.Write(part) + } + rpty.activeConnsMutex.Unlock() + } + // If we break from the loop, the reconnecting PTY ended. + a.reconnectingPTYs.Delete(id) + a.connCloseWait.Done() + }() + } + err = rpty.ptty.Resize(uint16(height), uint16(width)) + if err != nil { + // We can continue after this, it's not fatal! + a.logger.Error(ctx, "resize reconnecting pty", slog.F("id", id), slog.Error(err)) + } + + _, err = conn.Write(rpty.ringBuffer.Bytes()) + if err != nil { + a.logger.Warn(ctx, "write reconnecting pty buffer", slog.F("id", id), slog.Error(err)) + return + } + connectionID := uuid.NewString() + rpty.activeConnsMutex.Lock() + rpty.activeConns[connectionID] = conn + rpty.activeConnsMutex.Unlock() + defer func() { + rpty.activeConnsMutex.Lock() + delete(rpty.activeConns, connectionID) + rpty.activeConnsMutex.Unlock() + }() + decoder := json.NewDecoder(conn) + var req ReconnectingPTYRequest + for { + err = decoder.Decode(&req) + if xerrors.Is(err, io.EOF) { + return + } + if err != nil { + a.logger.Warn(ctx, "reconnecting pty buffer read error", slog.F("id", id), slog.Error(err)) + return + } + _, err = rpty.ptty.Input().Write([]byte(req.Data)) + if err != nil { + a.logger.Warn(ctx, "write to reconnecting pty", slog.F("id", id), slog.Error(err)) + return + } + // Check if a resize needs to happen! + if req.Height == 0 || req.Width == 0 { + continue + } + err = rpty.ptty.Resize(req.Height, req.Width) + if err != nil { + // We can continue after this, it's not fatal! + a.logger.Error(ctx, "resize reconnecting pty", slog.F("id", id), slog.Error(err)) + } + } +} + // isClosed returns whether the API is closed or not. func (a *agent) isClosed() bool { select { @@ -403,3 +574,22 @@ func (a *agent) Close() error { a.connCloseWait.Wait() return nil } + +type reconnectingPTY struct { + activeConnsMutex sync.Mutex + activeConns map[string]net.Conn + + ringBuffer *ringbuffer.RingBuffer + timeout *time.Timer + ptty pty.PTY +} + +func (r *reconnectingPTY) Close() { + r.activeConnsMutex.Lock() + defer r.activeConnsMutex.Unlock() + for _, conn := range r.activeConns { + _ = conn.Close() + } + _ = r.ptty.Close() + r.ringBuffer.Reset() +} diff --git a/agent/agent_test.go b/agent/agent_test.go index 2c3b53f8bd7e2..2692111ef8722 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -2,6 +2,7 @@ package agent_test import ( "context" + "encoding/json" "fmt" "io" "net" @@ -14,6 +15,7 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/pion/webrtc/v3" "github.com/pkg/sftp" "github.com/stretchr/testify/require" @@ -188,6 +190,44 @@ func TestAgent(t *testing.T) { }, 15*time.Second, 100*time.Millisecond) require.Equal(t, content, strings.TrimSpace(gotContent)) }) + + t.Run("ReconnectingPTY", func(t *testing.T) { + t.Parallel() + conn := setupAgent(t, agent.Metadata{}) + id := uuid.NewString() + netConn, err := conn.ReconnectingPTY(id, 100, 100) + require.NoError(t, err) + + data, err := json.Marshal(agent.ReconnectingPTYRequest{ + Data: "echo test\r\n", + }) + require.NoError(t, err) + _, err = netConn.Write(data) + require.NoError(t, err) + + findEcho := func() { + for { + read, err := netConn.Read(data) + require.NoError(t, err) + if strings.Contains(string(data[:read]), "test") { + break + } + } + } + + // Once for typing the command... + findEcho() + // And another time for the actual output. + findEcho() + + _ = netConn.Close() + netConn, err = conn.ReconnectingPTY(id, 100, 100) + require.NoError(t, err) + + // Same output again! + findEcho() + findEcho() + }) } func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exec.Cmd { @@ -227,12 +267,14 @@ func setupSSHSession(t *testing.T, options agent.Metadata) *ssh.Session { return session } -func setupAgent(t *testing.T, options agent.Metadata) *agent.Conn { +func setupAgent(t *testing.T, metadata agent.Metadata) *agent.Conn { client, server := provisionersdk.TransportPipe() closer := agent.New(func(ctx context.Context, logger slog.Logger) (agent.Metadata, *peerbroker.Listener, error) { listener, err := peerbroker.Listen(server, nil) - return options, listener, err - }, slogtest.Make(t, nil).Leveled(slog.LevelDebug)) + return metadata, listener, err + }, &agent.Options{ + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + }) t.Cleanup(func() { _ = client.Close() _ = server.Close() diff --git a/agent/conn.go b/agent/conn.go index 8ec49843e381c..81a6315af26de 100644 --- a/agent/conn.go +++ b/agent/conn.go @@ -2,6 +2,7 @@ package agent import ( "context" + "fmt" "net" "golang.org/x/crypto/ssh" @@ -11,6 +12,14 @@ import ( "github.com/coder/coder/peerbroker/proto" ) +// ReconnectingPTYRequest is sent from the client to the server +// to pipe data to a PTY. +type ReconnectingPTYRequest struct { + Data string `json:"data"` + Height uint16 `json:"height"` + Width uint16 `json:"width"` +} + // Conn wraps a peer connection with helper functions to // communicate with the agent. type Conn struct { @@ -20,6 +29,18 @@ type Conn struct { *peer.Conn } +// ReconnectingPTY returns a connection serving a TTY that can +// be reconnected to via ID. +func (c *Conn) ReconnectingPTY(id string, height, width uint16) (net.Conn, error) { + channel, err := c.Dial(context.Background(), fmt.Sprintf("%s:%d:%d", id, height, width), &peer.ChannelOptions{ + Protocol: "reconnecting-pty", + }) + if err != nil { + return nil, xerrors.Errorf("pty: %w", err) + } + return channel.NetConn(), nil +} + // SSH dials the built-in SSH server. func (c *Conn) SSH() (net.Conn, error) { channel, err := c.Dial(context.Background(), "ssh", &peer.ChannelOptions{ diff --git a/cli/agent.go b/cli/agent.go index cb1a456105752..754550b9384d4 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -125,7 +125,9 @@ func workspaceAgent() *cobra.Command { return xerrors.Errorf("writing agent url to config: %w", err) } - closer := agent.New(client.ListenWorkspaceAgent, logger) + closer := agent.New(client.ListenWorkspaceAgent, &agent.Options{ + Logger: logger, + }) <-cmd.Context().Done() return closer.Close() }, diff --git a/cli/configssh_test.go b/cli/configssh_test.go index 70a6e4feb77f0..61b83c0781844 100644 --- a/cli/configssh_test.go +++ b/cli/configssh_test.go @@ -71,7 +71,9 @@ func TestConfigSSH(t *testing.T) { coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) agentClient := codersdk.New(client.URL) agentClient.SessionToken = authToken - agentCloser := agent.New(agentClient.ListenWorkspaceAgent, slogtest.Make(t, nil)) + agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{ + Logger: slogtest.Make(t, nil), + }) t.Cleanup(func() { _ = agentCloser.Close() }) diff --git a/cli/ssh_test.go b/cli/ssh_test.go index 095720b35c19d..868f813b1c675 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -69,7 +69,9 @@ func TestSSH(t *testing.T) { coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) agentClient := codersdk.New(client.URL) agentClient.SessionToken = agentToken - agentCloser := agent.New(agentClient.ListenWorkspaceAgent, slogtest.Make(t, nil).Leveled(slog.LevelDebug)) + agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{ + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + }) t.Cleanup(func() { _ = agentCloser.Close() }) @@ -112,7 +114,9 @@ func TestSSH(t *testing.T) { coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) agentClient := codersdk.New(client.URL) agentClient.SessionToken = agentToken - agentCloser := agent.New(agentClient.ListenWorkspaceAgent, slogtest.Make(t, nil).Leveled(slog.LevelDebug)) + agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{ + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + }) t.Cleanup(func() { _ = agentCloser.Close() }) diff --git a/coderd/coderd.go b/coderd/coderd.go index 6f255826088c4..e85bc63f4984f 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -216,6 +216,7 @@ func New(options *Options) (http.Handler, func()) { r.Get("/", api.workspaceAgent) r.Get("/dial", api.workspaceAgentDial) r.Get("/turn", api.workspaceAgentTurn) + r.Get("/pty", api.workspaceAgentPTY) r.Get("/iceservers", api.workspaceAgentICEServers) }) }) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 4873d13420ed8..25842de8d2838 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -10,6 +10,7 @@ import ( "strconv" "time" + "github.com/google/uuid" "github.com/hashicorp/yamux" "golang.org/x/xerrors" "nhooyr.io/websocket" @@ -19,7 +20,9 @@ import ( "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/turnconn" "github.com/coder/coder/codersdk" + "github.com/coder/coder/peer" "github.com/coder/coder/peerbroker" "github.com/coder/coder/peerbroker/proto" "github.com/coder/coder/provisionersdk" @@ -320,6 +323,134 @@ func (api *api) workspaceAgentTurn(rw http.ResponseWriter, r *http.Request) { api.Logger.Debug(r.Context(), "completed turn connection", slog.F("remote-address", r.RemoteAddr), slog.F("local-address", localAddress)) } +// workspaceAgentPTY spawns a PTY and pipes it over a WebSocket. +// This is used for the web terminal. +func (api *api) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) { + api.websocketWaitMutex.Lock() + api.websocketWaitGroup.Add(1) + api.websocketWaitMutex.Unlock() + defer api.websocketWaitGroup.Done() + + workspaceAgent := httpmw.WorkspaceAgentParam(r) + apiAgent, err := convertWorkspaceAgent(workspaceAgent, api.AgentConnectionUpdateFrequency) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("convert workspace agent: %s", err), + }) + return + } + if apiAgent.Status != codersdk.WorkspaceAgentConnected { + httpapi.Write(rw, http.StatusPreconditionRequired, httpapi.Response{ + Message: fmt.Sprintf("agent must be in the connected state: %s", apiAgent.Status), + }) + return + } + + reconnect, err := uuid.Parse(r.URL.Query().Get("reconnect")) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: fmt.Sprintf("reconnection must be a uuid: %s", err), + }) + return + } + height, err := strconv.Atoi(r.URL.Query().Get("height")) + if err != nil { + height = 80 + } + width, err := strconv.Atoi(r.URL.Query().Get("width")) + if err != nil { + width = 80 + } + + conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{ + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: fmt.Sprintf("accept websocket: %s", err), + }) + return + } + defer func() { + _ = conn.Close(websocket.StatusNormalClosure, "ended") + }() + // Accept text connections, because it's more developer friendly. + wsNetConn := websocket.NetConn(r.Context(), conn, websocket.MessageText) + agentConn, err := api.dialWorkspaceAgent(r, workspaceAgent.ID) + if err != nil { + return + } + defer agentConn.Close() + ptNetConn, err := agentConn.ReconnectingPTY(reconnect.String(), uint16(height), uint16(width)) + if err != nil { + _ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial: %s", err)) + return + } + defer ptNetConn.Close() + // Pipe the ends together! + go func() { + _, _ = io.Copy(wsNetConn, ptNetConn) + }() + _, _ = io.Copy(ptNetConn, wsNetConn) +} + +// dialWorkspaceAgent connects to a workspace agent by ID. +func (api *api) dialWorkspaceAgent(r *http.Request, agentID uuid.UUID) (*agent.Conn, error) { + client, server := provisionersdk.TransportPipe() + go func() { + _ = peerbroker.ProxyListen(r.Context(), server, peerbroker.ProxyOptions{ + ChannelID: agentID.String(), + Logger: api.Logger.Named("peerbroker-proxy-dial"), + Pubsub: api.Pubsub, + }) + _ = client.Close() + _ = server.Close() + }() + + peerClient := proto.NewDRPCPeerBrokerClient(provisionersdk.Conn(client)) + stream, err := peerClient.NegotiateConnection(r.Context()) + if err != nil { + return nil, xerrors.Errorf("negotiate: %w", err) + } + options := &peer.ConnOptions{} + options.SettingEngine.SetSrflxAcceptanceMinWait(0) + options.SettingEngine.SetRelayAcceptanceMinWait(0) + // Use the ProxyDialer for the TURN server. + // This is required for connections where P2P is not enabled. + options.SettingEngine.SetICEProxyDialer(turnconn.ProxyDialer(func() (c net.Conn, err error) { + clientPipe, serverPipe := net.Pipe() + go func() { + <-r.Context().Done() + _ = clientPipe.Close() + _ = serverPipe.Close() + }() + localAddress, _ := r.Context().Value(http.LocalAddrContextKey).(*net.TCPAddr) + remoteAddress := &net.TCPAddr{ + IP: net.ParseIP(r.RemoteAddr), + } + // By default requests have the remote address and port. + host, port, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return nil, xerrors.Errorf("split remote address: %w", err) + } + remoteAddress.IP = net.ParseIP(host) + remoteAddress.Port, err = strconv.Atoi(port) + if err != nil { + return nil, xerrors.Errorf("convert remote port: %w", err) + } + api.TURNServer.Accept(clientPipe, remoteAddress, localAddress) + return serverPipe, nil + })) + peerConn, err := peerbroker.Dial(stream, append(api.ICEServers, turnconn.Proxy), options) + if err != nil { + return nil, xerrors.Errorf("dial: %w", err) + } + return &agent.Conn{ + Negotiator: peerClient, + Conn: peerConn, + }, nil +} + func convertWorkspaceAgent(dbAgent database.WorkspaceAgent, agentUpdateFrequency time.Duration) (codersdk.WorkspaceAgent, error) { var envs map[string]string if dbAgent.EnvironmentVariables.Valid { diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 108d02e2f2f81..2b740dcb0fbd7 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -2,6 +2,8 @@ package coderd_test import ( "context" + "encoding/json" + "strings" "testing" "github.com/google/uuid" @@ -90,7 +92,9 @@ func TestWorkspaceAgentListen(t *testing.T) { agentClient := codersdk.New(client.URL) agentClient.SessionToken = authToken - agentCloser := agent.New(agentClient.ListenWorkspaceAgent, slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug)) + agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{ + Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug), + }) t.Cleanup(func() { _ = agentCloser.Close() }) @@ -138,7 +142,9 @@ func TestWorkspaceAgentTURN(t *testing.T) { agentClient := codersdk.New(client.URL) agentClient.SessionToken = authToken - agentCloser := agent.New(agentClient.ListenWorkspaceAgent, slogtest.Make(t, nil)) + agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{ + Logger: slogtest.Make(t, nil), + }) t.Cleanup(func() { _ = agentCloser.Close() }) @@ -156,3 +162,80 @@ func TestWorkspaceAgentTURN(t *testing.T) { _, err = conn.Ping() require.NoError(t, err) } + +func TestWorkspaceAgentPTY(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + daemonCloser := coderdtest.NewProvisionerDaemon(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: echo.ProvisionComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }}, + }}, + }, + }, + }}, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + daemonCloser.Close() + + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = authToken + agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{ + Logger: slogtest.Make(t, nil), + }) + t.Cleanup(func() { + _ = agentCloser.Close() + }) + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) + + conn, err := client.WorkspaceAgentReconnectingPTY(context.Background(), resources[0].Agents[0].ID, uuid.New(), 80, 80) + require.NoError(t, err) + defer conn.Close() + + // First attempt to resize the TTY. + // The websocket will close if it fails! + data, err := json.Marshal(agent.ReconnectingPTYRequest{ + Height: 250, + Width: 250, + }) + require.NoError(t, err) + _, err = conn.Write(data) + require.NoError(t, err) + + data, err = json.Marshal(agent.ReconnectingPTYRequest{ + Data: "echo test\r\n", + }) + require.NoError(t, err) + _, err = conn.Write(data) + require.NoError(t, err) + + findEcho := func() { + for { + read, err := conn.Read(data) + require.NoError(t, err) + if strings.Contains(string(data[:read]), "test") { + return + } + } + } + + findEcho() + findEcho() +} diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 284a363240cce..7f96b111b0057 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -338,6 +338,39 @@ func (c *Client) WorkspaceAgent(ctx context.Context, id uuid.UUID) (WorkspaceAge return workspaceAgent, json.NewDecoder(res.Body).Decode(&workspaceAgent) } +// WorkspaceAgentReconnectingPTY spawns a PTY that reconnects using the token provided. +// It communicates using `agent.ReconnectingPTYRequest` marshaled as JSON. +// Responses are PTY output that can be rendered. +func (c *Client) WorkspaceAgentReconnectingPTY(ctx context.Context, agentID, reconnect uuid.UUID, height, width int) (net.Conn, error) { + serverURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/pty?reconnect=%s&height=%d&width=%d", agentID, reconnect, height, width)) + if err != nil { + return nil, xerrors.Errorf("parse url: %w", err) + } + jar, err := cookiejar.New(nil) + if err != nil { + return nil, xerrors.Errorf("create cookie jar: %w", err) + } + jar.SetCookies(serverURL, []*http.Cookie{{ + Name: httpmw.AuthCookie, + Value: c.SessionToken, + }}) + httpClient := &http.Client{ + Jar: jar, + } + conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{ + HTTPClient: httpClient, + // Need to disable compression to avoid a data-race. + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + if res == nil { + return nil, err + } + return nil, readBodyAsError(res) + } + return websocket.NetConn(ctx, conn, websocket.MessageText), nil +} + func (c *Client) turnProxyDialer(ctx context.Context, httpClient *http.Client, path string) proxy.Dialer { return turnconn.ProxyDialer(func() (net.Conn, error) { turnURL, err := c.URL.Parse(path) diff --git a/go.mod b/go.mod index 90ea7cc1ae26f..c613a30132baf 100644 --- a/go.mod +++ b/go.mod @@ -92,6 +92,7 @@ require ( github.com/pkg/sftp v1.13.4 github.com/quasilyte/go-ruleguard/dsl v0.3.19 github.com/robfig/cron/v3 v3.0.1 + github.com/smallnest/ringbuffer v0.0.0-20210227121335-0a58434b36f2 github.com/spf13/cobra v1.4.0 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.7.1 diff --git a/go.sum b/go.sum index 822bdd8dea232..394249d0dfa0b 100644 --- a/go.sum +++ b/go.sum @@ -1493,6 +1493,8 @@ github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrf github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/smallnest/ringbuffer v0.0.0-20210227121335-0a58434b36f2 h1:co1YnJJ6rDvcodJzcXObchJMfHclIROMulsWObuNfTY= +github.com/smallnest/ringbuffer v0.0.0-20210227121335-0a58434b36f2/go.mod h1:mXcZNMJHswhQDDJZIjdtJoG97JIwIa/HdcHNM3w15T0= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= diff --git a/site/webpack.dev.ts b/site/webpack.dev.ts index 642f358f4a9a8..47e78b236f391 100644 --- a/site/webpack.dev.ts +++ b/site/webpack.dev.ts @@ -60,7 +60,10 @@ const config: Configuration = { hot: true, port: process.env.PORT || 8080, proxy: { - "/api": "http://localhost:3000", + "/api": { + target: "http://localhost:3000", + ws: true, + }, }, static: ["./static"], }, From 15d843e423a1851c0ca32dd8a7dc1cae5314ae35 Mon Sep 17 00:00:00 2001 From: kylecarbs Date: Wed, 27 Apr 2022 14:42:36 +0000 Subject: [PATCH 2/6] Add xstate service --- site/src/api/index.ts | 15 ++ site/src/api/types.ts | 24 +- .../xServices/terminal/terminalXService.ts | 222 ++++++++++++++++++ 3 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 site/src/xServices/terminal/terminalXService.ts diff --git a/site/src/api/index.ts b/site/src/api/index.ts index cd6a387f6d99c..34188e2a69252 100644 --- a/site/src/api/index.ts +++ b/site/src/api/index.ts @@ -85,6 +85,21 @@ export const getUsers = async (): Promise => { }) } +export const getOrganizations = async (): Promise => { + const response = await axios.get("/api/v2/users/me/organizations") + return response.data +} + +export const getWorkspace = async (organizationID: string, workspaceName: string): Promise => { + const response = await axios.get(`/api/v2/organizations/${organizationID}/workspaces/me/${workspaceName}`) + return response.data +} + +export const getWorkspaceResources = async (workspaceBuildID: string): Promise => { + const response = await axios.get(`/api/v2/workspacebuilds/${workspaceBuildID}/resources`) + return response.data +} + export const getBuildInfo = async (): Promise => { const response = await axios.get("/api/v2/buildinfo") return response.data diff --git a/site/src/api/types.ts b/site/src/api/types.ts index 7b95a64743174..f374a65c9c219 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -55,9 +55,10 @@ export interface CreateWorkspaceRequest { template_id: string } -/** - * @remarks Keep in sync with codersdk/workspaces.go - */ +export interface WorkspaceBuild { + id: string +} + export interface Workspace { id: string created_at: string @@ -67,6 +68,17 @@ export interface Workspace { name: string autostart_schedule: string autostop_schedule: string + latest_build: WorkspaceBuild +} + +export interface WorkspaceResource { + id: string + agents: WorkspaceAgent[] +} + +export interface WorkspaceAgent { + id: string + name: string } export interface APIKeyResponse { @@ -102,3 +114,9 @@ export interface UpdateProfileRequest { readonly email: string readonly name: string } + +export interface ReconnectingPTYRequest { + readonly data: string + readonly height: number + readonly width: number +} diff --git a/site/src/xServices/terminal/terminalXService.ts b/site/src/xServices/terminal/terminalXService.ts new file mode 100644 index 0000000000000..90297bcb3dd81 --- /dev/null +++ b/site/src/xServices/terminal/terminalXService.ts @@ -0,0 +1,222 @@ +import { assign, createMachine } from "xstate" +import * as API from "../../api" +import * as Types from "../../api/types" + +// TypeScript doesn't have the randomUUID type on Crypto yet. See: +// https://github.com/denoland/deno/issues/12754#issuecomment-970386235 +declare global { + interface Crypto { + randomUUID: () => string + } +} + +export interface TerminalContext { + organizationsError?: Error | unknown + organizations?: Types.Organization[] + workspaceError?: Error | unknown + workspace?: Types.Workspace + workspaceAgent?: Types.WorkspaceAgent + workspaceAgentError?: Error | unknown + + reconnection: string + websocket?: WebSocket +} + +export type TerminalEvent = + | { type: "CONNECT" } + | { type: "WRITE"; data: Types.ReconnectingPTYRequest } + | { type: "READ"; data: string } + +export const terminalMachine = + /** @xstate-layout N4IgpgJg5mDOIC5QBcwCcC2BLAdgQwBsBlZPVAOhmWVygHk0o8csAvMrAex1gGIJuYcrgBunANZCqDJi3Y1u8JCAAOnWFgU5EoAB6IALAYAM5AwE5LANgAcAJgDsNqwGYrxqwBoQAT0QBWAEZTBysw8wc3W2NAg38AX3jvVExcQhIyKTBqWhlmNg5FXnQ0TjRyFQIyADMyjEpsvLlCnh1VdU0ubWV9BCNTC2t7J1d3L19EaPJIlzdzY39h20Tk9Gx8YlIKKhocKAB1MvFYFTwAYzB+QWEcMUkG5EO0Y9OLtrUNLTbe4ONzckClnMLjsBjcdg83j8CDc-jMszccRcCzsVgSSRAKXW6S2WRyeyeL3OlxKZQqVWQtUwD0JJ2J7w6Xx6iF+-0BlhBYKsEPG0P8BkC5BsCIMDgcBhsxkcdhWmLWaU2mQeuwORzpFwAgjAcMgrjghKIJHjaa8wFqwDqGZ8ut8WVZAnYhQ6LKKDFZrFDEHZkeR-AigsibGK7OYZRisQqMttsiqTcTzTrimhSuVKjU6jS1aaE8grZ1uLaEIF7Y6bM7zK73eZeYZjAMgUH7UHUeZZRGNlGhGduPqziq9QbbkbyN2cL3c8oPvnunovQ7HeYHe4bHFFjZ-J6i27yHXd-b5qN-OjVqkO7iRz2wH3aEmU+T09TR+O80zZwg7PPyIvUcYV0ebOum6ooK7h1oE4qBEEgLgW28pnkqT5XqgEC8PsABKACSAAqACiL42sy74uEY5AltybrulKDibvMX5AuY66-vYooOLBp44kqpJoLwADCdAAHL8ThPFYfhBaER+DHkHYjagi4K7ETYNE2Duu6-kxFj+BEiQYjgnAQHAbTthx0b4vQjD5PIXRKKAU6viAvQGHYm5WE55DybMcTCmEgQweGcEmXisZZvSk6MgRb4+TuoLVsCdigos1ETAgAY7mEti+X6aKWGx2KKqZwXPOqZrahOtnheJb4Og6Tqgg4gIOKiwoGJuLhHtMIqhC45j+E4uWRueiHXnsYkzg5LLrlY0zcv4riQQ6DjGC4QEgkKCJtX8s0uIEbj9fBFBDcho2Ft6bhCjNYrcqGqIbslgT2EKanVg48z2DYe2BeQEBYLAh2QMdhGxAY0keKi7oSrNELOclvUuO5wpRAxApBqx-nsflQhcQDb6nVNzh2L1oQhvFaKbgKU3dUCTiSo1LioyeeWdtj41Fkpd0OHRQJjEGgLOKjiRAA */ + createMachine( + { + tsTypes: {} as import("./terminalXService.typegen").Typegen0, + schema: { + context: { + reconnection: crypto.randomUUID(), + } as TerminalContext, + events: {} as TerminalEvent, + services: {} as { + getOrganizations: { + data: Types.Organization[] + } + getWorkspace: { + data: Types.Workspace + } + getWorkspaceAgent: { + data: Types.WorkspaceAgent + } + connect: { + data: WebSocket + } + }, + }, + id: "terminalState", + initial: "gettingOrganizations", + states: { + gettingOrganizations: { + invoke: { + src: "getOrganizations", + id: "getOrganizations", + onDone: [ + { + actions: ["assignOrganizations", "clearOrganizationsError"], + target: "gettingWorkspace", + }, + ], + onError: [ + { + actions: "assignOrganizationsError", + target: "error", + }, + ], + }, + tags: "loading", + }, + gettingWorkspace: { + invoke: { + src: "getWorkspace", + id: "getWorkspace", + onDone: [ + { + actions: ["assignWorkspace", "clearWorkspaceError"], + target: "gettingWorkspaceAgent", + }, + ], + onError: [ + { + actions: "assignWorkspaceError", + target: "error", + }, + ], + }, + }, + gettingWorkspaceAgent: { + invoke: { + src: "getWorkspaceAgent", + id: "getWorkspaceAgent", + onDone: [ + { + actions: ["assignWorkspaceAgent", "clearWorkspaceAgentError"], + target: "connecting", + }, + ], + onError: [ + { + actions: "assignWorkspaceAgentError", + target: "error", + }, + ], + }, + }, + connecting: { + invoke: { + src: "connect", + id: "connect", + onDone: [ + { + actions: ["assignWebsocket", "clearWebsocketError"], + target: "connected", + }, + ], + onError: [ + { + actions: "assignWebsocketError", + target: "error", + }, + ], + }, + }, + connected: { + on: { + WRITE: { + actions: "sendMessage", + }, + }, + }, + disconnected: {}, + error: { + on: { + CONNECT: { + target: "gettingOrganizations", + }, + }, + }, + }, + }, + { + services: { + getOrganizations: API.getOrganizations, + getWorkspace: (context: TerminalContext) => { + return API.getWorkspace(context.organizations![0].id, "") + }, + getWorkspaceAgent: async (context: TerminalContext) => { + const resources = await API.getWorkspaceResources(context.workspace!.latest_build.id) + for (let i = 0; i < resources.length; i++) { + const resource = resources[i] + if (resource.agents.length <= 0) { + continue + } + return resource.agents[0] + } + throw new Error("no agent found with id") + }, + connect: (context: TerminalContext) => (send) => { + return new Promise((resolve, reject) => { + const socket = new WebSocket(`/api/v2/workspaceagents/${context.workspaceAgent!.id}/pty`) + socket.addEventListener("open", () => { + resolve(socket) + }) + socket.addEventListener("error", (event) => { + reject("socket errored") + }) + socket.addEventListener("close", (event) => { + reject(event.reason) + }) + socket.addEventListener("message", (event) => { + send({ + type: "READ", + data: event.data, + }) + }) + }) + }, + }, + actions: { + assignOrganizations: assign({ + organizations: (_, event) => event.data, + }), + assignOrganizationsError: assign({ + organizationsError: (_, event) => event.data, + }), + clearOrganizationsError: assign((context: TerminalContext) => ({ + ...context, + organizationsError: undefined, + })), + assignWorkspace: assign({ + workspace: (_, event) => event.data, + }), + assignWorkspaceError: assign({ + workspaceError: (_, event) => event.data, + }), + clearWorkspaceError: assign((context: TerminalContext) => ({ + ...context, + workspaceError: undefined, + })), + assignWorkspaceAgent: assign({ + workspaceAgent: (_, event) => event.data, + }), + assignWorkspaceAgentError: assign({ + workspaceAgentError: (_, event) => event.data, + }), + clearWorkspaceAgentError: assign((context: TerminalContext) => ({ + ...context, + workspaceAgentError: undefined, + })), + sendMessage: (context, event) => { + context.websocket!.send(JSON.stringify(event.data)) + }, + }, + }, + ) From cb5ae98b47bd53cf07e7073d4bddcc2ab02f1007 Mon Sep 17 00:00:00 2001 From: kylecarbs Date: Thu, 28 Apr 2022 05:09:27 +0000 Subject: [PATCH 3/6] Add the webpage for accessing a web terminal --- .vscode/settings.json | 3 + agent/agent.go | 91 ++++++-- agent/agent_test.go | 21 +- coderd/workspaceagents.go | 2 +- codersdk/workspaceagents.go | 2 +- go.mod | 2 +- go.sum | 4 +- pty/pty_other.go | 4 +- site/package.json | 4 + site/src/AppRouter.tsx | 14 ++ site/src/api/index.ts | 4 +- site/src/api/types.ts | 8 +- .../TemplatePage/TemplatePage.tsx | 4 +- site/src/pages/TerminalPage/TerminalPage.tsx | 194 ++++++++++++++++++ site/src/testHelpers/entities.ts | 3 + .../xServices/terminal/terminalXService.ts | 119 ++++++++--- site/yarn.lock | 28 +++ 17 files changed, 431 insertions(+), 76 deletions(-) create mode 100644 site/src/pages/TerminalPage/TerminalPage.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 8bc8adc06bea3..6f7ea5c69fce3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "cSpell.words": [ + "circbuf", "cliflag", "cliui", "coderd", @@ -61,8 +62,10 @@ "unconvert", "Untar", "VMID", + "weblinks", "webrtc", "xerrors", + "xstate", "yamux" ], "emeraldwalk.runonsave": { diff --git a/agent/agent.go b/agent/agent.go index 4330a0eaadc7b..9346464c45fcf 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -18,8 +18,8 @@ import ( "sync" "time" + "github.com/armon/circbuf" "github.com/google/uuid" - "github.com/smallnest/ringbuffer" gsyslog "github.com/hashicorp/go-syslog" "go.uber.org/atomic" @@ -417,6 +417,8 @@ func (a *agent) handleSSHSession(session ssh.Session) error { func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn net.Conn) { defer conn.Close() + // The ID format is referenced in conn.go. + // :: idParts := strings.Split(rawID, ":") if len(idParts) != 3 { a.logger.Warn(ctx, "client sent invalid id format", slog.F("raw-id", rawID)) @@ -429,6 +431,7 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn ne a.logger.Warn(ctx, "client sent reconnection token that isn't a uuid", slog.F("id", id), slog.Error(err)) return } + // Parse the initial terminal dimensions. height, err := strconv.Atoi(idParts[1]) if err != nil { a.logger.Warn(ctx, "client sent invalid height", slog.F("id", id), slog.F("height", idParts[1])) @@ -454,41 +457,55 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn ne a.logger.Warn(ctx, "create reconnecting pty command", slog.Error(err)) return } - ptty, _, err := pty.Start(cmd) + cmd.Env = append(cmd.Env, "TERM=xterm-256color") + + ptty, process, err := pty.Start(cmd) if err != nil { a.logger.Warn(ctx, "start reconnecting pty command", slog.F("id", id)) } + // Default to buffer 64KB. + circularBuffer, err := circbuf.NewBuffer(64 * 1024) + if err != nil { + a.logger.Warn(ctx, "create circular buffer", slog.Error(err)) + return + } + a.closeMutex.Lock() a.connCloseWait.Add(1) a.closeMutex.Unlock() + ctx, cancelFunc := context.WithCancel(ctx) rpty = &reconnectingPTY{ activeConns: make(map[string]net.Conn), ptty: ptty, - timeout: time.NewTimer(a.reconnectingPTYTimeout), - // Default to buffer 1MB. - ringBuffer: ringbuffer.New(1 << 20), + // Timeouts created with an after func can be reset! + timeout: time.AfterFunc(a.reconnectingPTYTimeout, cancelFunc), + circularBuffer: circularBuffer, } a.reconnectingPTYs.Store(id, rpty) go func() { - // Close if the inactive timeout occurs, or the context ends. - select { - case <-rpty.timeout.C: - a.logger.Info(ctx, "killing reconnecting pty due to inactivity", slog.F("id", id)) - case <-ctx.Done(): - } + // When the context has been completed either: + // 1. The timeout completed. + // 2. The parent context was cancelled. + <-ctx.Done() + _ = process.Kill() + }() + go func() { + // If the process dies randomly, we should + // close the pty. + _, _ = process.Wait() rpty.Close() }() go func() { - buffer := make([]byte, 32*1024) + buffer := make([]byte, 1024) for { read, err := rpty.ptty.Output().Read(buffer) if err != nil { - rpty.Close() + // When the PTY is closed, this is triggered. break } part := buffer[:read] - _, err = rpty.ringBuffer.Write(part) + _, err = rpty.circularBuffer.Write(part) if err != nil { a.logger.Error(ctx, "reconnecting pty write buffer", slog.Error(err), slog.F("id", id)) break @@ -499,27 +516,56 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn ne } rpty.activeConnsMutex.Unlock() } - // If we break from the loop, the reconnecting PTY ended. + + // Cleanup the process, PTY, and delete it's + // ID from memory. + _ = process.Kill() + rpty.Close() a.reconnectingPTYs.Delete(id) a.connCloseWait.Done() }() } + // Resize the PTY to initial height + width. err = rpty.ptty.Resize(uint16(height), uint16(width)) if err != nil { // We can continue after this, it's not fatal! a.logger.Error(ctx, "resize reconnecting pty", slog.F("id", id), slog.Error(err)) } - - _, err = conn.Write(rpty.ringBuffer.Bytes()) + // Write any previously stored data for the TTY. + _, err = conn.Write(rpty.circularBuffer.Bytes()) if err != nil { a.logger.Warn(ctx, "write reconnecting pty buffer", slog.F("id", id), slog.Error(err)) return } connectionID := uuid.NewString() + // Multiple connections to the same TTY are permitted. + // This could easily be used for terminal sharing, but + // we do it because it's a nice user experience to + // copy/paste a terminal URL and have it _just work_. rpty.activeConnsMutex.Lock() rpty.activeConns[connectionID] = conn rpty.activeConnsMutex.Unlock() + // Resetting this timeout prevents the PTY from exiting. + rpty.timeout.Reset(a.reconnectingPTYTimeout) + + heartbeat := time.NewTimer(a.reconnectingPTYTimeout / 2) + defer heartbeat.Stop() + go func() { + // Keep updating the activity while this + // connection is alive! + for { + select { + case <-ctx.Done(): + return + case <-heartbeat.C: + } + rpty.timeout.Reset(a.reconnectingPTYTimeout) + } + }() defer func() { + // After this connection ends, remove it from + // the PTYs active connections. If it isn't + // removed, all PTY data will be sent to it. rpty.activeConnsMutex.Lock() delete(rpty.activeConns, connectionID) rpty.activeConnsMutex.Unlock() @@ -579,11 +625,13 @@ type reconnectingPTY struct { activeConnsMutex sync.Mutex activeConns map[string]net.Conn - ringBuffer *ringbuffer.RingBuffer - timeout *time.Timer - ptty pty.PTY + circularBuffer *circbuf.Buffer + timeout *time.Timer + ptty pty.PTY } +// Close ends all connections to the reconnecting +// PTY and clear the circular buffer. func (r *reconnectingPTY) Close() { r.activeConnsMutex.Lock() defer r.activeConnsMutex.Unlock() @@ -591,5 +639,6 @@ func (r *reconnectingPTY) Close() { _ = conn.Close() } _ = r.ptty.Close() - r.ringBuffer.Reset() + r.circularBuffer.Reset() + r.timeout.Stop() } diff --git a/agent/agent_test.go b/agent/agent_test.go index 2692111ef8722..bd26fae7f0a69 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -133,7 +133,7 @@ func TestAgent(t *testing.T) { t.Run("SFTP", func(t *testing.T) { t.Parallel() - sshClient, err := setupAgent(t, agent.Metadata{}).SSHClient() + sshClient, err := setupAgent(t, agent.Metadata{}, 0).SSHClient() require.NoError(t, err) client, err := sftp.NewClient(sshClient) require.NoError(t, err) @@ -170,7 +170,7 @@ func TestAgent(t *testing.T) { content := "somethingnice" setupAgent(t, agent.Metadata{ StartupScript: "echo " + content + " > " + tempPath, - }) + }, 0) var gotContent string require.Eventually(t, func() bool { content, err := os.ReadFile(tempPath) @@ -193,7 +193,13 @@ func TestAgent(t *testing.T) { t.Run("ReconnectingPTY", func(t *testing.T) { t.Parallel() - conn := setupAgent(t, agent.Metadata{}) + if runtime.GOOS == "windows" { + // This might be our implementation, or ConPTY itself. + // It's difficult to find extensive tests for it, so + // it seems like it could be either. + t.Skip("ConPTY appears to be inconsistent on Windows.") + } + conn := setupAgent(t, agent.Metadata{}, 0) id := uuid.NewString() netConn, err := conn.ReconnectingPTY(id, 100, 100) require.NoError(t, err) @@ -231,7 +237,7 @@ func TestAgent(t *testing.T) { } func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exec.Cmd { - agentConn := setupAgent(t, agent.Metadata{}) + agentConn := setupAgent(t, agent.Metadata{}, 0) listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) go func() { @@ -260,20 +266,21 @@ func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exe } func setupSSHSession(t *testing.T, options agent.Metadata) *ssh.Session { - sshClient, err := setupAgent(t, options).SSHClient() + sshClient, err := setupAgent(t, options, 0).SSHClient() require.NoError(t, err) session, err := sshClient.NewSession() require.NoError(t, err) return session } -func setupAgent(t *testing.T, metadata agent.Metadata) *agent.Conn { +func setupAgent(t *testing.T, metadata agent.Metadata, ptyTimeout time.Duration) *agent.Conn { client, server := provisionersdk.TransportPipe() closer := agent.New(func(ctx context.Context, logger slog.Logger) (agent.Metadata, *peerbroker.Listener, error) { listener, err := peerbroker.Listen(server, nil) return metadata, listener, err }, &agent.Options{ - Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + ReconnectingPTYTimeout: ptyTimeout, }) t.Cleanup(func() { _ = client.Close() diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 25842de8d2838..e6e64b9bdc515 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -375,7 +375,7 @@ func (api *api) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) { _ = conn.Close(websocket.StatusNormalClosure, "ended") }() // Accept text connections, because it's more developer friendly. - wsNetConn := websocket.NetConn(r.Context(), conn, websocket.MessageText) + wsNetConn := websocket.NetConn(r.Context(), conn, websocket.MessageBinary) agentConn, err := api.dialWorkspaceAgent(r, workspaceAgent.ID) if err != nil { return diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 7f96b111b0057..905a2239e5dad 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -368,7 +368,7 @@ func (c *Client) WorkspaceAgentReconnectingPTY(ctx context.Context, agentID, rec } return nil, readBodyAsError(res) } - return websocket.NetConn(ctx, conn, websocket.MessageText), nil + return websocket.NetConn(ctx, conn, websocket.MessageBinary), nil } func (c *Client) turnProxyDialer(ctx context.Context, httpClient *http.Client, path string) proxy.Dialer { diff --git a/go.mod b/go.mod index c613a30132baf..62554d0b0bc26 100644 --- a/go.mod +++ b/go.mod @@ -43,6 +43,7 @@ require ( cdr.dev/slog v1.4.1 cloud.google.com/go/compute v1.6.1 github.com/AlecAivazis/survey/v2 v2.3.4 + github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 github.com/awalterschulze/gographviz v2.0.3+incompatible github.com/bgentry/speakeasy v0.1.0 github.com/briandowns/spinner v1.18.1 @@ -92,7 +93,6 @@ require ( github.com/pkg/sftp v1.13.4 github.com/quasilyte/go-ruleguard/dsl v0.3.19 github.com/robfig/cron/v3 v3.0.1 - github.com/smallnest/ringbuffer v0.0.0-20210227121335-0a58434b36f2 github.com/spf13/cobra v1.4.0 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.7.1 diff --git a/go.sum b/go.sum index 394249d0dfa0b..d048c8e9e781b 100644 --- a/go.sum +++ b/go.sum @@ -182,6 +182,8 @@ github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/ github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 h1:7Ip0wMmLHLRJdrloDxZfhMm0xrLXZS8+COSu2bXmEQs= +github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-metrics v0.3.0/go.mod h1:zXjbSimjXTd7vOpY8B0/2LpvNvDoXBuplAD+gJD3GYs= @@ -1493,8 +1495,6 @@ github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrf github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/smallnest/ringbuffer v0.0.0-20210227121335-0a58434b36f2 h1:co1YnJJ6rDvcodJzcXObchJMfHclIROMulsWObuNfTY= -github.com/smallnest/ringbuffer v0.0.0-20210227121335-0a58434b36f2/go.mod h1:mXcZNMJHswhQDDJZIjdtJoG97JIwIa/HdcHNM3w15T0= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= diff --git a/pty/pty_other.go b/pty/pty_other.go index 5884eb13cc85d..b826bd3a3398f 100644 --- a/pty/pty_other.go +++ b/pty/pty_other.go @@ -46,8 +46,8 @@ func (p *otherPty) Resize(height uint16, width uint16) error { p.mutex.Lock() defer p.mutex.Unlock() return pty.Setsize(p.pty, &pty.Winsize{ - Rows: width, - Cols: height, + Rows: height, + Cols: width, }) } diff --git a/site/package.json b/site/package.json index 3563f439b090a..edd969113b0ff 100644 --- a/site/package.json +++ b/site/package.json @@ -42,6 +42,10 @@ "react-router-dom": "6.3.0", "swr": "1.2.2", "xstate": "4.31.0", + "xterm-addon-fit": "^0.5.0", + "xterm-addon-web-links": "^0.5.1", + "xterm-addon-webgl": "^0.11.4", + "xterm-for-react": "^1.0.4", "yup": "0.32.11" }, "devDependencies": { diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 4016b010d2f0c..0a21158ef2265 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -17,6 +17,7 @@ import { SettingsPage } from "./pages/SettingsPage/SettingsPage" import { CreateWorkspacePage } from "./pages/TemplatesPages/OrganizationPage/TemplatePage/CreateWorkspacePage" import { TemplatePage } from "./pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage" import { TemplatesPage } from "./pages/TemplatesPages/TemplatesPage" +import { TerminalPage } from "./pages/TerminalPage/TerminalPage" import { UsersPage } from "./pages/UsersPage/UsersPage" import { WorkspacePage } from "./pages/WorkspacesPage/WorkspacesPage" @@ -115,6 +116,19 @@ export const AppRouter: React.FC = () => ( } /> + + + + + + } + /> + + + {/* Using path="*"" means "match anything", so this route acts like a catch-all for URLs that we don't have explicit routes for. */} diff --git a/site/src/api/index.ts b/site/src/api/index.ts index 34188e2a69252..09af5bf970767 100644 --- a/site/src/api/index.ts +++ b/site/src/api/index.ts @@ -91,7 +91,9 @@ export const getOrganizations = async (): Promise => { } export const getWorkspace = async (organizationID: string, workspaceName: string): Promise => { - const response = await axios.get(`/api/v2/organizations/${organizationID}/workspaces/me/${workspaceName}`) + const response = await axios.get( + `/api/v2/organizations/${organizationID}/workspaces/me/${workspaceName}`, + ) return response.data } diff --git a/site/src/api/types.ts b/site/src/api/types.ts index f374a65c9c219..c3ef4bbb9727e 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -73,7 +73,7 @@ export interface Workspace { export interface WorkspaceResource { id: string - agents: WorkspaceAgent[] + agents?: WorkspaceAgent[] } export interface WorkspaceAgent { @@ -116,7 +116,7 @@ export interface UpdateProfileRequest { } export interface ReconnectingPTYRequest { - readonly data: string - readonly height: number - readonly width: number + readonly data?: string + readonly height?: number + readonly width?: number } diff --git a/site/src/pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage.tsx b/site/src/pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage.tsx index fa3347fb65819..a7ea381efdcc2 100644 --- a/site/src/pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage.tsx +++ b/site/src/pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage.tsx @@ -3,7 +3,7 @@ import { makeStyles } from "@material-ui/core/styles" import React from "react" import { Link, useNavigate, useParams } from "react-router-dom" import useSWR from "swr" -import { Organization, Template, Workspace } from "../../../../api/types" +import { Organization, Template, Workspace, WorkspaceBuild } from "../../../../api/types" import { EmptyState } from "../../../../components/EmptyState/EmptyState" import { ErrorSummary } from "../../../../components/ErrorSummary/ErrorSummary" import { Header } from "../../../../components/Header/Header" @@ -64,7 +64,7 @@ export const TemplatePage: React.FC = () => { { key: "name", name: "Name", - renderer: (nameField: string, workspace: Workspace) => { + renderer: (nameField: string | WorkspaceBuild, workspace: Workspace) => { return {nameField} }, }, diff --git a/site/src/pages/TerminalPage/TerminalPage.tsx b/site/src/pages/TerminalPage/TerminalPage.tsx new file mode 100644 index 0000000000000..feb749d89cb13 --- /dev/null +++ b/site/src/pages/TerminalPage/TerminalPage.tsx @@ -0,0 +1,194 @@ +import { makeStyles } from "@material-ui/core/styles" +import { useMachine } from "@xstate/react" +import React from "react" +import { useLocation, useNavigate, useParams } from "react-router-dom" +import { FitAddon } from "xterm-addon-fit" +import { WebLinksAddon } from "xterm-addon-web-links" +import { XTerm } from "xterm-for-react" +import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" +import { terminalMachine } from "../../xServices/terminal/terminalXService" + +// TypeScript doesn't have the randomUUID type on Crypto yet. See: +// https://github.com/denoland/deno/issues/12754#issuecomment-970386235 +declare global { + interface Crypto { + randomUUID: () => string + } +} + +export const TerminalPage: React.FC = () => { + const location = useLocation() + const navigate = useNavigate() + const styles = useStyles() + const { workspace } = useParams() + const xtermRef = React.useRef(null) + const [fitAddon, weblinksAddon] = React.useMemo(() => { + return [new FitAddon(), new WebLinksAddon()] + }, []) + // The reconnection token is a unique token that identifies + // a terminal session. It's generated by the client to reduce + // a round-trip, and must be a UUIDv4. + const [reconnectionToken] = React.useState(() => { + const search = new URLSearchParams(location.search) + let reconnect = search.get("reconnect") + if (reconnect == null) { + reconnect = crypto.randomUUID() + } + return reconnect + }) + const [terminalState, sendEvent] = useMachine(terminalMachine, { + actions: { + readMessage: (_, event) => { + xtermRef.current?.terminal.write(new Uint8Array(event.data)) + }, + }, + }) + const isConnected = terminalState.matches("connected") + + // Triggers the initial terminal connection using + // the reconnection token and workspace name found + // from the router. + React.useEffect(() => { + const search = new URLSearchParams(location.search) + search.set("reconnect", reconnectionToken) + navigate( + { + search: search.toString(), + }, + { + replace: true, + }, + ) + sendEvent({ + type: "CONNECT", + reconnection: reconnectionToken, + workspaceName: workspace!, + }) + }, [sendEvent, reconnectionToken]) + + // Listen for window resize events and trigger a "fit" + // of the terminal. This ensures it's maximized to the screen. + React.useEffect(() => { + const listener = () => { + // This will trigger a resize event on the terminal. + fitAddon.fit() + } + window.addEventListener("resize", listener) + return () => { + window.removeEventListener("resize", listener) + } + }, [xtermRef]) + + // Apply terminal options based on connection state. + React.useEffect(() => { + if (!xtermRef.current || !xtermRef.current.terminal) { + return + } + + if (!isConnected) { + // Disable user input when not connected. + xtermRef.current.terminal.options = { + disableStdin: true, + } + return + } + // The terminal should be cleared on each reconnect + // because all data is re-rendered from the backend. + xtermRef.current.terminal.clear() + // Focusing on connection allows users to reload the + // page and start typing immediately. + xtermRef.current.terminal.focus() + xtermRef.current.terminal.options = { + disableStdin: false, + } + + // We have to fit twice here. It's unknown why, but + // the first fit will overflow slightly in some + // scenarios. Applying a second fit resolves this. + fitAddon.fit() + fitAddon.fit() + + // Update the terminal size post-fit. + sendEvent({ + type: "WRITE", + request: { + height: xtermRef.current.terminal.rows, + width: xtermRef.current.terminal.cols, + }, + }) + }, [isConnected]) + + return ( + <> +
+ { + sendEvent({ + type: "WRITE", + request: { + data: data, + }, + }) + }} + onResize={(event) => { + sendEvent({ + type: "WRITE", + request: { + height: event.rows, + width: event.cols, + }, + }) + }} + /> + + ) +} + +const useStyles = makeStyles(() => ({ + overlay: { + position: "absolute", + pointerEvents: "none", + top: 0, + left: 0, + bottom: 0, + right: 0, + zIndex: 1, + backgroundColor: "rgba(0, 0, 0, 0.5)", + "&.connected": { + opacity: 0, + }, + }, + terminal: { + width: "100vw", + height: "100vh", + // These styles attempt to mimic the VS Code scrollbar. + "& .xterm": { + padding: 4, + width: "100vw", + height: "100vh", + }, + "& .xterm-viewport::-webkit-scrollbar": { + width: "10px", + }, + "& .xterm-viewport::-webkit-scrollbar-track": { + backgroundColor: "inherit", + }, + "& .xterm-viewport::-webkit-scrollbar-thumb": { + minHeight: 20, + backgroundColor: "rgba(255, 255, 255, 0.18)", + }, + }, +})) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index dec91f2ae934a..196839db9500e 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -90,6 +90,9 @@ export const MockWorkspace: Workspace = { owner_id: MockUser.id, autostart_schedule: MockWorkspaceAutostartEnabled.schedule, autostop_schedule: MockWorkspaceAutostopEnabled.schedule, + latest_build: { + id: "test-workspace-build", + }, } export const MockUserAgent: UserAgent = { diff --git a/site/src/xServices/terminal/terminalXService.ts b/site/src/xServices/terminal/terminalXService.ts index 90297bcb3dd81..61c0d8848821c 100644 --- a/site/src/xServices/terminal/terminalXService.ts +++ b/site/src/xServices/terminal/terminalXService.ts @@ -2,14 +2,6 @@ import { assign, createMachine } from "xstate" import * as API from "../../api" import * as Types from "../../api/types" -// TypeScript doesn't have the randomUUID type on Crypto yet. See: -// https://github.com/denoland/deno/issues/12754#issuecomment-970386235 -declare global { - interface Crypto { - randomUUID: () => string - } -} - export interface TerminalContext { organizationsError?: Error | unknown organizations?: Types.Organization[] @@ -17,25 +9,27 @@ export interface TerminalContext { workspace?: Types.Workspace workspaceAgent?: Types.WorkspaceAgent workspaceAgentError?: Error | unknown - - reconnection: string websocket?: WebSocket + websocketError?: Error | unknown + + // Assigned by connecting! + workspaceName?: string + reconnection?: string } export type TerminalEvent = - | { type: "CONNECT" } - | { type: "WRITE"; data: Types.ReconnectingPTYRequest } - | { type: "READ"; data: string } + | { type: "CONNECT"; reconnection?: string; workspaceName?: string } + | { type: "WRITE"; request: Types.ReconnectingPTYRequest } + | { type: "READ"; data: ArrayBuffer } + | { type: "DISCONNECT" } export const terminalMachine = - /** @xstate-layout N4IgpgJg5mDOIC5QBcwCcC2BLAdgQwBsBlZPVAOhmWVygHk0o8csAvMrAex1gGIJuYcrgBunANZCqDJi3Y1u8JCAAOnWFgU5EoAB6IALAYAM5AwE5LANgAcAJgDsNqwGYrxqwBoQAT0QBWAEZTBysw8wc3W2NAg38AX3jvVExcQhIyKTBqWhlmNg5FXnQ0TjRyFQIyADMyjEpsvLlCnh1VdU0ubWV9BCNTC2t7J1d3L19EaPJIlzdzY39h20Tk9Gx8YlIKKhocKAB1MvFYFTwAYzB+QWEcMUkG5EO0Y9OLtrUNLTbe4ONzckClnMLjsBjcdg83j8CDc-jMszccRcCzsVgSSRAKXW6S2WRyeyeL3OlxKZQqVWQtUwD0JJ2J7w6Xx6iF+-0BlhBYKsEPG0P8BkC5BsCIMDgcBhsxkcdhWmLWaU2mQeuwORzpFwAgjAcMgrjghKIJHjaa8wFqwDqGZ8ut8WVZAnYhQ6LKKDFZrFDEHZkeR-AigsibGK7OYZRisQqMttsiqTcTzTrimhSuVKjU6jS1aaE8grZ1uLaEIF7Y6bM7zK73eZeYZjAMgUH7UHUeZZRGNlGhGduPqziq9QbbkbyN2cL3c8oPvnunovQ7HeYHe4bHFFjZ-J6i27yHXd-b5qN-OjVqkO7iRz2wH3aEmU+T09TR+O80zZwg7PPyIvUcYV0ebOum6ooK7h1oE4qBEEgLgW28pnkqT5XqgEC8PsABKACSAAqACiL42sy74uEY5AltybrulKDibvMX5AuY66-vYooOLBp44kqpJoLwADCdAAHL8ThPFYfhBaER+DHkHYjagi4K7ETYNE2Duu6-kxFj+BEiQYjgnAQHAbTthx0b4vQjD5PIXRKKAU6viAvQGHYm5WE55DybMcTCmEgQweGcEmXisZZvSk6MgRb4+TuoLVsCdigos1ETAgAY7mEti+X6aKWGx2KKqZwXPOqZrahOtnheJb4Og6Tqgg4gIOKiwoGJuLhHtMIqhC45j+E4uWRueiHXnsYkzg5LLrlY0zcv4riQQ6DjGC4QEgkKCJtX8s0uIEbj9fBFBDcho2Ft6bhCjNYrcqGqIbslgT2EKanVg48z2DYe2BeQEBYLAh2QMdhGxAY0keKi7oSrNELOclvUuO5wpRAxApBqx-nsflQhcQDb6nVNzh2L1oQhvFaKbgKU3dUCTiSo1LioyeeWdtj41Fkpd0OHRQJjEGgLOKjiRAA */ + /** @xstate-layout N4IgpgJg5mDOIC5QBcwCcC2BLAdgQwBsBlZPVAOhmWVygHk0o8csAvMrAex1gGIJuYcrgBunANZCqDJi3Y1u8JCAAOnWFgU5EoAB6IA7AE4AzOSMBGAEwBWCyYAsANgs2bVgDQgAnohNGbcgsnEIcHCwAOBwAGCKMHAF8Er1RMXEISMikwaloZZjYORV50NE40chUCMgAzcoxKHPy5Ip4dVXVNLm1lfQQIiLMIpxMbQYjYo2jXL18EawtyAwGwk1HTMdGklPRsfGJSCioaHCgAdXLxWBU8AGMwfkFhHDFJRuQLtCub+-a1DS07T69icVnILisDhspiccQ2sz8y3INmiqNGsMcBmiNm2IFSewyh2yuVOn2+dwepXKlWqyDqmHeZOuFL+nUBvUQILBEKhMLhowRCAMTkCIzWDkcEyMBhsBlx+PSByy7xO50uzPuAEEYDhkI8cEJRBJiUyfmBtWBdayAd0gZyjCFyFZbKjnC4jKYLIK7GYYqirCYBk5olYDIlknjdorMkccqrTRSLbqSmgyhUqrV6oz1Wak8hrV1uHb5g6nE6XdE3RYPSYvT5EWCA2s7E4sbZhfKo-sY0JbtwDbdVfrDS9jeQ+zgB-nlP9Cz09IhIcLyCZIUYotEDCZNxEDN7peY1ms4gYrNNBp20t2ieP+2BB7QU2maZmGROpwX2QuEEuy6uHOuMRbjue71ggdiLPY4phiYMoWNYl4EkqFDvveqAQLwZwAEoAJIACoAKKfraHI-gYkTkCGAFYmG0TbnWcw2A4YKmGskJno44RWIh0Y3qhg6QLwWEEZqAAixFFqRobViusKngYMpWEGgpQmWUFsSEcSOHRPHXsq-Hobwok4UQADCdAAHIWQRpl4RJ84gH0SmtuQDgTNWMJTDKJgqUEq4RNCljTOs3ERgqekUBAWCwAZgnmVZNl2TObIkd+zplrEYanvY0SwrCESCs6BiuaidGrgGTgekYSQRjgnAQHA7ThYSyrHHkjAFPI3RKKAs5fo5iAxIEXHMSMErWNiTiFa4K6ldi1ayu4FjhjsV4tbGJJql8GpgPZxYWNMDiubBdh2Ju7pGIK-jFW6rYiq4bZOLp63EvGOaJjq069Slknfq4jrOii51udiMpXbC5ATKi1ghNEljNs9yG9neD6nHtUlnhErmgqeIyosKazejNZ7+qMi3BiYiM9rek5oZA6NpeR0ROmGpgnhT0qCmNSxHhKW4BiGERUzeUUxSj6EMwN4HM5ukLLXBViRKGNhXVj64etM0JOG5YTC1kktORlp7hA4CtK2DYEALQQ8M5Gm4rUymDpNVAA */ createMachine( { tsTypes: {} as import("./terminalXService.typegen").Typegen0, schema: { - context: { - reconnection: crypto.randomUUID(), - } as TerminalContext, + context: {} as TerminalContext, events: {} as TerminalEvent, services: {} as { getOrganizations: { @@ -53,7 +47,7 @@ export const terminalMachine = }, }, id: "terminalState", - initial: "gettingOrganizations", + initial: "disconnected", states: { gettingOrganizations: { invoke: { @@ -68,7 +62,7 @@ export const terminalMachine = onError: [ { actions: "assignOrganizationsError", - target: "error", + target: "disconnected", }, ], }, @@ -87,7 +81,7 @@ export const terminalMachine = onError: [ { actions: "assignWorkspaceError", - target: "error", + target: "disconnected", }, ], }, @@ -105,7 +99,7 @@ export const terminalMachine = onError: [ { actions: "assignWorkspaceAgentError", - target: "error", + target: "disconnected", }, ], }, @@ -123,7 +117,7 @@ export const terminalMachine = onError: [ { actions: "assignWebsocketError", - target: "error", + target: "disconnected", }, ], }, @@ -133,12 +127,19 @@ export const terminalMachine = WRITE: { actions: "sendMessage", }, + READ: { + actions: "readMessage", + }, + DISCONNECT: { + actions: "disconnect", + target: "disconnected", + }, }, }, - disconnected: {}, - error: { + disconnected: { on: { CONNECT: { + actions: "assignConnection", target: "gettingOrganizations", }, }, @@ -148,13 +149,22 @@ export const terminalMachine = { services: { getOrganizations: API.getOrganizations, - getWorkspace: (context: TerminalContext) => { - return API.getWorkspace(context.organizations![0].id, "") + getWorkspace: async (context: TerminalContext) => { + if (!context.organizations || !context.workspaceName) { + throw new Error("organizations or workspace not set") + } + return API.getWorkspace(context.organizations[0].id, context.workspaceName) }, getWorkspaceAgent: async (context: TerminalContext) => { - const resources = await API.getWorkspaceResources(context.workspace!.latest_build.id) + if (!context.workspace) { + throw new Error("workspace is not set") + } + const resources = await API.getWorkspaceResources(context.workspace.latest_build.id) for (let i = 0; i < resources.length; i++) { const resource = resources[i] + if (!resource.agents) { + continue + } if (resource.agents.length <= 0) { continue } @@ -164,15 +174,31 @@ export const terminalMachine = }, connect: (context: TerminalContext) => (send) => { return new Promise((resolve, reject) => { - const socket = new WebSocket(`/api/v2/workspaceagents/${context.workspaceAgent!.id}/pty`) + if (!context.workspaceAgent) { + return reject("workspace agent is not set") + } + let proto = location.protocol + if (proto === "https:") { + proto = "wss:" + } else { + proto = "ws:" + } + const socket = new WebSocket( + `${proto}//${location.host}/api/v2/workspaceagents/${context.workspaceAgent.id}/pty?reconnect=${ + context.reconnection + }`, + ) + socket.binaryType = "arraybuffer" socket.addEventListener("open", () => { resolve(socket) }) - socket.addEventListener("error", (event) => { + socket.addEventListener("error", () => { reject("socket errored") }) - socket.addEventListener("close", (event) => { - reject(event.reason) + socket.addEventListener("close", () => { + send({ + type: "DISCONNECT", + }) }) socket.addEventListener("message", (event) => { send({ @@ -184,13 +210,18 @@ export const terminalMachine = }, }, actions: { + assignConnection: assign((context, event) => ({ + ...context, + reconnection: event.reconnection ?? context.reconnection, + workspaceName: event.workspaceName ?? context.workspaceName, + })), assignOrganizations: assign({ organizations: (_, event) => event.data, }), assignOrganizationsError: assign({ organizationsError: (_, event) => event.data, }), - clearOrganizationsError: assign((context: TerminalContext) => ({ + clearOrganizationsError: assign((context) => ({ ...context, organizationsError: undefined, })), @@ -200,7 +231,7 @@ export const terminalMachine = assignWorkspaceError: assign({ workspaceError: (_, event) => event.data, }), - clearWorkspaceError: assign((context: TerminalContext) => ({ + clearWorkspaceError: assign((context) => ({ ...context, workspaceError: undefined, })), @@ -214,8 +245,28 @@ export const terminalMachine = ...context, workspaceAgentError: undefined, })), + assignWebsocket: assign({ + websocket: (_, event) => event.data, + }), + assignWebsocketError: assign({ + websocketError: (_, event) => event.data, + }), + clearWebsocketError: assign((context: TerminalContext) => ({ + ...context, + webSocketError: undefined, + })), sendMessage: (context, event) => { - context.websocket!.send(JSON.stringify(event.data)) + if (!context.websocket) { + throw new Error("websocket doesn't exist") + } + context.websocket.send(new TextEncoder().encode(JSON.stringify(event.request))) + }, + readMessage: () => { + // Override this with the terminal writer! + }, + disconnect: (context: TerminalContext) => { + // Code 1000 is a successful exit! + context.websocket?.close(1000) }, }, }, diff --git a/site/yarn.lock b/site/yarn.lock index 92b965980dcec..5c5a01d4dbacb 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -14163,6 +14163,34 @@ xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1: resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== +xterm-addon-fit@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.5.0.tgz#2d51b983b786a97dcd6cde805e700c7f913bc596" + integrity sha512-DsS9fqhXHacEmsPxBJZvfj2la30Iz9xk+UKjhQgnYNkrUIN5CYLbw7WEfz117c7+S86S/tpHPfvNxJsF5/G8wQ== + +xterm-addon-web-links@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/xterm-addon-web-links/-/xterm-addon-web-links-0.5.1.tgz#73bfa3ed567af98fba947f638bd12093ee2a0bc6" + integrity sha512-dBjbOIrCNmxAcUQkkSrKj9BM6yLpmqUpZ9SOCUuZe/sznPl4d8OBZQClK7VcdZ0vf0+5i5Fce2rUUrew/XTZTg== + +xterm-addon-webgl@^0.11.4: + version "0.11.4" + resolved "https://registry.yarnpkg.com/xterm-addon-webgl/-/xterm-addon-webgl-0.11.4.tgz#e22f3ec0cafca3d4adcabb89bb7c16efaaf3c8db" + integrity sha512-/a/VFeftc+etGXQYWaaks977j1P7/wickBXn15zDxZzXYYMT9RN17ztqyIDVLXg9krtg28+icKK6lvgIYghJ0w== + +xterm-for-react@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/xterm-for-react/-/xterm-for-react-1.0.4.tgz#6b35b9b037a0f9d979e7b57bb1d7c6ab7565b380" + integrity sha512-DCkLR9ZXeW907YyyaCTk/3Ol34VRHfCnf3MAPOkj3dUNA85sDqHvTXN8efw4g7bx7gWdJQRsEpGt2tJOXKG3EQ== + dependencies: + prop-types "^15.7.2" + xterm "^4.5.0" + +xterm@^4.5.0: + version "4.18.0" + resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.18.0.tgz#a1f6ab2c330c3918fb094ae5f4c2562987398ea1" + integrity sha512-JQoc1S0dti6SQfI0bK1AZvGnAxH4MVw45ZPFSO6FHTInAiau3Ix77fSxNx3mX4eh9OL4AYa8+4C8f5UvnSfppQ== + y18n@^4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" From 229c7e480d12bc80c971b055bf6daef7390a9959 Mon Sep 17 00:00:00 2001 From: kylecarbs Date: Thu, 28 Apr 2022 21:30:51 +0000 Subject: [PATCH 4/6] Add terminal page tests --- agent/agent.go | 2 +- site/jest.config.js | 3 + site/package.json | 3 + site/src/api/index.ts | 8 +- .../pages/TerminalPage/TerminalPage.test.tsx | 156 ++++++++++++++ site/src/pages/TerminalPage/TerminalPage.tsx | 193 +++++++++++------- site/src/testHelpers/entities.ts | 12 ++ site/src/testHelpers/handlers.ts | 11 + site/src/testHelpers/styleMock.ts | 1 + .../xServices/terminal/terminalXService.ts | 13 +- site/yarn.lock | 39 +++- 11 files changed, 358 insertions(+), 83 deletions(-) create mode 100644 site/src/pages/TerminalPage/TerminalPage.test.tsx create mode 100644 site/src/testHelpers/styleMock.ts diff --git a/agent/agent.go b/agent/agent.go index 9346464c45fcf..71972c88717f4 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -486,7 +486,7 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn ne go func() { // When the context has been completed either: // 1. The timeout completed. - // 2. The parent context was cancelled. + // 2. The parent context was canceled. <-ctx.Done() _ = process.Kill() }() diff --git a/site/jest.config.js b/site/jest.config.js index e53c2d01d06e8..5cc3ea0bbbc8b 100644 --- a/site/jest.config.js +++ b/site/jest.config.js @@ -28,6 +28,9 @@ module.exports = { testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", testPathIgnorePatterns: ["/node_modules/", "/__tests__/fakes", "/e2e/"], moduleDirectories: ["node_modules", ""], + moduleNameMapper: { + "\\.css$": "/src/testHelpers/styleMock.ts", + }, }, { displayName: "lint", diff --git a/site/package.json b/site/package.json index edd969113b0ff..35aa04b78d33d 100644 --- a/site/package.json +++ b/site/package.json @@ -42,6 +42,7 @@ "react-router-dom": "6.3.0", "swr": "1.2.2", "xstate": "4.31.0", + "xterm": "^4.18.0", "xterm-addon-fit": "^0.5.0", "xterm-addon-web-links": "^0.5.1", "xterm-addon-webgl": "^0.11.4", @@ -84,8 +85,10 @@ "eslint-plugin-react-hooks": "4.4.0", "html-webpack-plugin": "5.5.0", "jest": "27.5.1", + "jest-canvas-mock": "^2.4.0", "jest-junit": "13.1.0", "jest-runner-eslint": "1.0.0", + "jest-websocket-mock": "^2.3.0", "mini-css-extract-plugin": "2.6.0", "msw": "0.39.2", "prettier": "2.6.2", diff --git a/site/src/api/index.ts b/site/src/api/index.ts index 09af5bf970767..a05aa72140022 100644 --- a/site/src/api/index.ts +++ b/site/src/api/index.ts @@ -90,9 +90,13 @@ export const getOrganizations = async (): Promise => { return response.data } -export const getWorkspace = async (organizationID: string, workspaceName: string): Promise => { +export const getWorkspace = async ( + organizationID: string, + username = "me", + workspaceName: string, +): Promise => { const response = await axios.get( - `/api/v2/organizations/${organizationID}/workspaces/me/${workspaceName}`, + `/api/v2/organizations/${organizationID}/workspaces/${username}/${workspaceName}`, ) return response.data } diff --git a/site/src/pages/TerminalPage/TerminalPage.test.tsx b/site/src/pages/TerminalPage/TerminalPage.test.tsx new file mode 100644 index 0000000000000..808d65c405998 --- /dev/null +++ b/site/src/pages/TerminalPage/TerminalPage.test.tsx @@ -0,0 +1,156 @@ +import { waitFor } from "@testing-library/react" +import crypto from "crypto" +import "jest-canvas-mock" +import WS from "jest-websocket-mock" +import { rest } from "msw" +import React from "react" +import { Route, Routes } from "react-router-dom" +import { TextDecoder, TextEncoder } from "util" +import { ReconnectingPTYRequest } from "../../api/types" +import { history, MockWorkspaceAgent, render } from "../../testHelpers" +import { server } from "../../testHelpers/server" +import { Language, TerminalPage } from "./TerminalPage" + +Object.defineProperty(window, "matchMedia", { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // deprecated + removeListener: jest.fn(), // deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}) + +Object.defineProperty(window, "crypto", { + value: { + randomUUID: () => crypto.randomUUID(), + }, +}) + +Object.defineProperty(window, "TextEncoder", { + value: TextEncoder, +}) + +const renderTerminal = () => { + return render( + + } /> + , + ) +} + +const expectTerminalText = (container: HTMLElement, text: string) => { + return waitFor(() => { + const elements = container.getElementsByClassName("xterm-rows") + if (elements.length < 1) { + throw new Error("no xterm-rows") + } + const row = elements[0] as HTMLDivElement + if (!row.textContent) { + throw new Error("no text content") + } + expect(row.textContent).toContain(text) + }) +} + +describe("TerminalPage", () => { + beforeEach(() => { + history.push("/some-user/my-workspace/terminal") + }) + + it("shows an error if fetching organizations fails", async () => { + // Given + server.use( + rest.get("/api/v2/users/me/organizations", async (req, res, ctx) => { + return res(ctx.status(500), ctx.json({ message: "nope" })) + }), + ) + + // When + const { container } = renderTerminal() + + // Then + await expectTerminalText(container, Language.organizationsErrorMessagePrefix) + }) + + it("shows an error if fetching workspace fails", async () => { + // Given + server.use( + rest.get("/api/v2/organizations/:organizationId/workspaces/:userName/:workspaceName", (req, res, ctx) => { + return res(ctx.status(500), ctx.json({ id: "workspace-id" })) + }), + ) + + // When + const { container } = renderTerminal() + + // Then + await expectTerminalText(container, Language.workspaceErrorMessagePrefix) + }) + + it("shows an error if fetching workspace agent fails", async () => { + // Given + server.use( + rest.get("/api/v2/workspacebuilds/:workspaceId/resources", (req, res, ctx) => { + return res(ctx.status(500), ctx.json({ message: "nope" })) + }), + ) + + // When + const { container } = renderTerminal() + + // Then + await expectTerminalText(container, Language.workspaceAgentErrorMessagePrefix) + }) + + it("shows an error if the websocket fails", async () => { + // Given + server.use( + rest.get("/api/v2/workspaceagents/:agentId/pty", (req, res, ctx) => { + return res(ctx.status(500), ctx.json({})) + }), + ) + + // When + const { container } = renderTerminal() + + // Then + await expectTerminalText(container, Language.websocketErrorMessagePrefix) + }) + + it("renders data from the backend", async () => { + // Given + const server = new WS("ws://localhost/api/v2/workspaceagents/" + MockWorkspaceAgent.id + "/pty") + const text = "something to render" + + // When + const { container } = renderTerminal() + + // Then + await server.connected + server.send(text) + await expectTerminalText(container, text) + server.close() + }) + + it("resizes on connect", async () => { + // Given + const server = new WS("ws://localhost/api/v2/workspaceagents/" + MockWorkspaceAgent.id + "/pty") + + // When + renderTerminal() + + // Then + await server.connected + const msg = await server.nextMessage + const req: ReconnectingPTYRequest = JSON.parse(new TextDecoder().decode(msg as Uint8Array)) + + expect(req.height).toBeGreaterThan(0) + expect(req.width).toBeGreaterThan(0) + server.close() + }) +}) diff --git a/site/src/pages/TerminalPage/TerminalPage.tsx b/site/src/pages/TerminalPage/TerminalPage.tsx index feb749d89cb13..deed7f5d26f4a 100644 --- a/site/src/pages/TerminalPage/TerminalPage.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.tsx @@ -2,12 +2,20 @@ import { makeStyles } from "@material-ui/core/styles" import { useMachine } from "@xstate/react" import React from "react" import { useLocation, useNavigate, useParams } from "react-router-dom" +import * as XTerm from "xterm" import { FitAddon } from "xterm-addon-fit" import { WebLinksAddon } from "xterm-addon-web-links" -import { XTerm } from "xterm-for-react" +import "xterm/css/xterm.css" import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" import { terminalMachine } from "../../xServices/terminal/terminalXService" +export const Language = { + organizationsErrorMessagePrefix: "Unable to fetch organizations: ", + workspaceErrorMessagePrefix: "Unable to fetch workspace: ", + workspaceAgentErrorMessagePrefix: "Unable to fetch workspace agent: ", + websocketErrorMessagePrefix: "WebSocket failed: ", +} + // TypeScript doesn't have the randomUUID type on Crypto yet. See: // https://github.com/denoland/deno/issues/12754#issuecomment-970386235 declare global { @@ -16,22 +24,23 @@ declare global { } } -export const TerminalPage: React.FC = () => { +export const TerminalPage: React.FC<{ + readonly renderer?: XTerm.RendererType +}> = ({ renderer }) => { const location = useLocation() const navigate = useNavigate() const styles = useStyles() - const { workspace } = useParams() - const xtermRef = React.useRef(null) - const [fitAddon, weblinksAddon] = React.useMemo(() => { - return [new FitAddon(), new WebLinksAddon()] - }, []) + const { username, workspace } = useParams() + const xtermRef = React.useRef(null) + const [terminal, setTerminal] = React.useState(null) + const [fitAddon, setFitAddon] = React.useState(null) // The reconnection token is a unique token that identifies // a terminal session. It's generated by the client to reduce // a round-trip, and must be a UUIDv4. const [reconnectionToken] = React.useState(() => { const search = new URLSearchParams(location.search) let reconnect = search.get("reconnect") - if (reconnect == null) { + if (reconnect === null) { reconnect = crypto.randomUUID() } return reconnect @@ -39,12 +48,69 @@ export const TerminalPage: React.FC = () => { const [terminalState, sendEvent] = useMachine(terminalMachine, { actions: { readMessage: (_, event) => { - xtermRef.current?.terminal.write(new Uint8Array(event.data)) + if (typeof event.data === "string") { + // This exclusively occurs when testing. + // "jest-websocket-mock" doesn't support ArrayBuffer. + terminal?.write(event.data) + } else { + terminal?.write(new Uint8Array(event.data)) + } }, }, }) const isConnected = terminalState.matches("connected") + // Create the terminal! + React.useEffect(() => { + if (!xtermRef.current) { + return + } + const terminal = new XTerm.Terminal({ + allowTransparency: true, + disableStdin: false, + fontFamily: MONOSPACE_FONT_FAMILY, + fontSize: 16, + theme: { + // This is a slight off-black. + // It's really easy on the eyes! + background: "#1F1F1F", + }, + rendererType: renderer, + }) + const fitAddon = new FitAddon() + setFitAddon(fitAddon) + terminal.loadAddon(fitAddon) + terminal.loadAddon(new WebLinksAddon()) + terminal.onData((data) => { + sendEvent({ + type: "WRITE", + request: { + data: data, + }, + }) + }) + terminal.onResize((event) => { + sendEvent({ + type: "WRITE", + request: { + height: event.rows, + width: event.cols, + }, + }) + }) + setTerminal(terminal) + terminal.open(xtermRef.current) + const listener = () => { + // This will trigger a resize event on the terminal. + fitAddon.fit() + } + window.addEventListener("resize", listener) + return () => { + window.removeEventListener("resize", listener) + terminal.dispose() + } + }, [renderer, sendEvent, xtermRef]) + // Triggers the initial terminal connection using // the reconnection token and workspace name found // from the router. @@ -62,97 +128,79 @@ export const TerminalPage: React.FC = () => { sendEvent({ type: "CONNECT", reconnection: reconnectionToken, - workspaceName: workspace!, + workspaceName: workspace, + username: username, }) - }, [sendEvent, reconnectionToken]) - - // Listen for window resize events and trigger a "fit" - // of the terminal. This ensures it's maximized to the screen. - React.useEffect(() => { - const listener = () => { - // This will trigger a resize event on the terminal. - fitAddon.fit() - } - window.addEventListener("resize", listener) - return () => { - window.removeEventListener("resize", listener) - } - }, [xtermRef]) + }, [location.search, navigate, workspace, username, sendEvent, reconnectionToken]) // Apply terminal options based on connection state. React.useEffect(() => { - if (!xtermRef.current || !xtermRef.current.terminal) { + if (!terminal || !fitAddon) { return } + // We have to fit twice here. It's unknown why, but + // the first fit will overflow slightly in some + // scenarios. Applying a second fit resolves this. + fitAddon.fit() + fitAddon.fit() + if (!isConnected) { // Disable user input when not connected. - xtermRef.current.terminal.options = { + terminal.options = { disableStdin: true, } + if (terminalState.context.organizationsError instanceof Error) { + terminal.writeln(Language.organizationsErrorMessagePrefix + terminalState.context.organizationsError.message) + } + if (terminalState.context.workspaceError instanceof Error) { + terminal.writeln(Language.workspaceErrorMessagePrefix + terminalState.context.workspaceError.message) + } + if (terminalState.context.workspaceAgentError instanceof Error) { + terminal.writeln(Language.workspaceAgentErrorMessagePrefix + terminalState.context.workspaceAgentError.message) + } + if (terminalState.context.websocketError instanceof Error) { + terminal.writeln(Language.websocketErrorMessagePrefix + terminalState.context.websocketError.message) + } return } + // The terminal should be cleared on each reconnect // because all data is re-rendered from the backend. - xtermRef.current.terminal.clear() + terminal.clear() + // Focusing on connection allows users to reload the // page and start typing immediately. - xtermRef.current.terminal.focus() - xtermRef.current.terminal.options = { + terminal.focus() + terminal.options = { disableStdin: false, } - // We have to fit twice here. It's unknown why, but - // the first fit will overflow slightly in some - // scenarios. Applying a second fit resolves this. - fitAddon.fit() - fitAddon.fit() - // Update the terminal size post-fit. sendEvent({ type: "WRITE", request: { - height: xtermRef.current.terminal.rows, - width: xtermRef.current.terminal.cols, + height: terminal.rows, + width: terminal.cols, }, }) - }, [isConnected]) + }, [ + terminalState.context.workspaceError, + terminalState.context.organizationsError, + terminalState.context.workspaceAgentError, + terminalState.context.websocketError, + terminal, + fitAddon, + isConnected, + sendEvent, + ]) return ( <> + {/* This overlay makes it more obvious that the terminal is disconnected. */} + {/* It's nice for situations where Coder restarts, and they are temporarily disconnected. */}
- { - sendEvent({ - type: "WRITE", - request: { - data: data, - }, - }) - }} - onResize={(event) => { - sendEvent({ - type: "WRITE", - request: { - height: event.rows, - width: event.cols, - }, - }) - }} - /> +
) } @@ -180,6 +228,11 @@ const useStyles = makeStyles(() => ({ width: "100vw", height: "100vh", }, + "& .xterm-viewport": { + // This is required to force full-width on the terminal. + // Otherwise there's a small white bar to the right of the scrollbar. + width: "auto !important", + }, "& .xterm-viewport::-webkit-scrollbar": { width: "10px", }, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 196839db9500e..818ca22dfea5f 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -7,7 +7,9 @@ import { UserAgent, UserResponse, Workspace, + WorkspaceAgent, WorkspaceAutostartRequest, + WorkspaceResource, } from "../api/types" import { AuthMethods } from "../api/typesGenerated" @@ -95,6 +97,16 @@ export const MockWorkspace: Workspace = { }, } +export const MockWorkspaceAgent: WorkspaceAgent = { + id: "test-workspace-agent", + name: "a-workspace-agent", +} + +export const MockWorkspaceResource: WorkspaceResource = { + id: "test-workspace-resource", + agents: [MockWorkspaceAgent], +} + export const MockUserAgent: UserAgent = { browser: "Chrome 99.0.4844", device: "Other", diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 22e4818085a50..de34eeeb620c9 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -27,6 +27,9 @@ export const handlers = [ rest.post("/api/v2/users/me/workspaces", async (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockWorkspace)) }), + rest.get("/api/v2/users/me/organizations", (req, res, ctx) => { + return res(ctx.status(200), ctx.json([M.MockOrganization])) + }), rest.get("/api/v2/users/me/organizations/:organizationId", async (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockOrganization)) }), @@ -47,6 +50,9 @@ export const handlers = [ }), // workspaces + rest.get("/api/v2/organizations/:organizationId/workspaces/:userName/:workspaceName", (req, res, ctx) => { + return res(ctx.status(200), ctx.json(M.MockWorkspace)) + }), rest.get("/api/v2/workspaces/:workspaceId", async (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockWorkspace)) }), @@ -56,4 +62,9 @@ export const handlers = [ rest.put("/api/v2/workspaces/:workspaceId/autostop", async (req, res, ctx) => { return res(ctx.status(200)) }), + + // workspace builds + rest.get("/api/v2/workspacebuilds/:workspaceBuildId/resources", (req, res, ctx) => { + return res(ctx.status(200), ctx.json([M.MockWorkspaceResource])) + }), ] diff --git a/site/src/testHelpers/styleMock.ts b/site/src/testHelpers/styleMock.ts new file mode 100644 index 0000000000000..b1c6ea436a540 --- /dev/null +++ b/site/src/testHelpers/styleMock.ts @@ -0,0 +1 @@ +export default {} diff --git a/site/src/xServices/terminal/terminalXService.ts b/site/src/xServices/terminal/terminalXService.ts index 61c0d8848821c..e1e6dae6bb5bf 100644 --- a/site/src/xServices/terminal/terminalXService.ts +++ b/site/src/xServices/terminal/terminalXService.ts @@ -13,18 +13,19 @@ export interface TerminalContext { websocketError?: Error | unknown // Assigned by connecting! + username?: string workspaceName?: string reconnection?: string } export type TerminalEvent = - | { type: "CONNECT"; reconnection?: string; workspaceName?: string } + | { type: "CONNECT"; reconnection?: string; workspaceName?: string; username?: string } | { type: "WRITE"; request: Types.ReconnectingPTYRequest } | { type: "READ"; data: ArrayBuffer } | { type: "DISCONNECT" } export const terminalMachine = - /** @xstate-layout N4IgpgJg5mDOIC5QBcwCcC2BLAdgQwBsBlZPVAOhmWVygHk0o8csAvMrAex1gGIJuYcrgBunANZCqDJi3Y1u8JCAAOnWFgU5EoAB6IA7AE4AzOSMBGAEwBWCyYAsANgs2bVgDQgAnohNGbcgsnEIcHCwAOBwAGCKMHAF8Er1RMXEISMikwaloZZjYORV50NE40chUCMgAzcoxKHPy5Ip4dVXVNLm1lfQQIiLMIpxMbQYjYo2jXL18EawtyAwGwk1HTMdGklPRsfGJSCioaHCgAdXLxWBU8AGMwfkFhHDFJRuQLtCub+-a1DS07T69icVnILisDhspiccQ2sz8y3INmiqNGsMcBmiNm2IFSewyh2yuVOn2+dwepXKlWqyDqmHeZOuFL+nUBvUQILBEKhMLhowRCAMTkCIzWDkcEyMBhsBlx+PSByy7xO50uzPuAEEYDhkI8cEJRBJiUyfmBtWBdayAd0gZyjCFyFZbKjnC4jKYLIK7GYYqirCYBk5olYDIlknjdorMkccqrTRSLbqSmgyhUqrV6oz1Wak8hrV1uHb5g6nE6XdE3RYPSYvT5EWCA2s7E4sbZhfKo-sY0JbtwDbdVfrDS9jeQ+zgB-nlP9Cz09IhIcLyCZIUYotEDCZNxEDN7peY1ms4gYrNNBp20t2ieP+2BB7QU2maZmGROpwX2QuEEuy6uHOuMRbjue71ggdiLPY4phiYMoWNYl4EkqFDvveqAQLwZwAEoAJIACoAKKfraHI-gYkTkCGAFYmG0TbnWcw2A4YKmGskJno44RWIh0Y3qhg6QLwWEEZqAAixFFqRobViusKngYMpWEGgpQmWUFsSEcSOHRPHXsq-Hobwok4UQADCdAAHIWQRpl4RJ84gH0SmtuQDgTNWMJTDKJgqUEq4RNCljTOs3ERgqekUBAWCwAZgnmVZNl2TObIkd+zplrEYanvY0SwrCESCs6BiuaidGrgGTgekYSQRjgnAQHA7ThYSyrHHkjAFPI3RKKAs5fo5iAxIEXHMSMErWNiTiFa4K6ldi1ayu4FjhjsV4tbGJJql8GpgPZxYWNMDiubBdh2Ju7pGIK-jFW6rYiq4bZOLp63EvGOaJjq069Slknfq4jrOii51udiMpXbC5ATKi1ghNEljNs9yG9neD6nHtUlnhErmgqeIyosKazejNZ7+qMi3BiYiM9rek5oZA6NpeR0ROmGpgnhT0qCmNSxHhKW4BiGERUzeUUxSj6EMwN4HM5ukLLXBViRKGNhXVj64etM0JOG5YTC1kktORlp7hA4CtK2DYEALQQ8M5Gm4rUymDpNVAA */ + /** @xstate-layout N4IgpgJg5mDOIC5QBcwCcC2BLAdgQwBsBlZPVAOhmWVygHk0o8csAvMrAex1gGIJuYcrgBunANZCqDJi3Y1u8JCAAOnWFgU5EoAB6IA7AE4AzOSMBGAEwBWCyYAsANgs2bVgDQgAnohNGbcgsnEIcHCwAOBwAGCKMHAF8Er1RMXEISMikwaloZZjYORV50NE40chUCMgAzcoxKHPy5Ip4dVXVNLm1lfQQIiLMIpxMbQYjYo2jXL18EawtyAwGwk1HTMdGklPRsfGJSCioaHCgAdXLxWBU8AGMwfkFhHDFJRuQLtCub+-a1DS07T69icVnILisDhspiccQ2sz8y3INmiqNGsMcBmiNm2IFSewyh2yuVOn2+dwepXKlWqyDqmHeZOuFL+nUBvUQILBEKhMLhowRCAMTkCIzWDkcEyMBhsBlx+PSByy7xO50uzPuAEEYDhkI8cEJRBJiUyfmBtWBdayAd0gZyjCFyFZbKjnC4jKYLIK7GYYqirCYBk5olYDIlknjdorMkccqrTRSLbqSmgyhUqrV6oz1Wak8hrV1uHb5g6nE6XdE3RYPSYvT5EWCA2s7E4sbZhfKo-sY0JbtwDbdVfrDS9jeQ+zgB-nlP9Cz09IhIcLyCZIUYotEDCZNxEDN7peY1ms4gYrNNBp20t2ieP+2BB7QU2maZmGROpwX2QuEEuy6uHOuMRbjue71ggdiLPY4phiYMoWNYl4EkqFDvveqAQLwZwAEoAJIACoAKKfraHI-gYkTkCGAFYmG0TbnWcw2A4YKmGskJno44RWIh0Y3qhg6QLwWEEZqAAixFFqRobViusKngYMpWEGgpQmWUFsSEcSOHRPHXsq-Hobwok4UQADCdAAHIWQRpl4RJ84gH0SmtuQDgTNWMJTDKJgqUEq4RNCljTOs3ERgqekUBAWCwAZgnmVZNl2TObIkd+zplrEYanvY0SwrCESCs6BiuaidGrgGTgekYSQRjgnAQHA7ThYSyrHHkjAFPI3RKKAs5fo5iAxIEXHMSMErWNiTiFa4K6ldi1ayu4FjhjsV4tbGJJql8GpgPZxYWNMDiubBdh2Ju7pGIK-jFW6rYiq4bZOLp63EvGOaJjq069Slknfq4jrOii51udiMpXbC5ATKi1ghNEljNs9yG9neD6nHtUlnhErmgqeIyosKazejNZ7+qMi3BiYiM9rek5oZA6NpeR0ROmGpgnhT0qCmNSxHhKW4BiGERUzeUUxSj6EMwN4HM5ukLLXBViRKGNhXVj64etM0JOG5YTC1kktORlp7hA4CtK2DYEALQQ8M5FKQd27OJTNVAA */ createMachine( { tsTypes: {} as import("./terminalXService.typegen").Typegen0, @@ -153,7 +154,7 @@ export const terminalMachine = if (!context.organizations || !context.workspaceName) { throw new Error("organizations or workspace not set") } - return API.getWorkspace(context.organizations[0].id, context.workspaceName) + return API.getWorkspace(context.organizations[0].id, context.username, context.workspaceName) }, getWorkspaceAgent: async (context: TerminalContext) => { if (!context.workspace) { @@ -184,16 +185,14 @@ export const terminalMachine = proto = "ws:" } const socket = new WebSocket( - `${proto}//${location.host}/api/v2/workspaceagents/${context.workspaceAgent.id}/pty?reconnect=${ - context.reconnection - }`, + `${proto}//${location.host}/api/v2/workspaceagents/${context.workspaceAgent.id}/pty?reconnect=${context.reconnection}`, ) socket.binaryType = "arraybuffer" socket.addEventListener("open", () => { resolve(socket) }) socket.addEventListener("error", () => { - reject("socket errored") + reject(new Error("socket errored")) }) socket.addEventListener("close", () => { send({ diff --git a/site/yarn.lock b/site/yarn.lock index 5c5a01d4dbacb..05fb98d1e0ad4 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -5117,7 +5117,7 @@ color-name@1.1.3: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= -color-name@^1.0.0, color-name@~1.1.4: +color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== @@ -5620,6 +5620,11 @@ cssesc@^3.0.0: resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== +cssfontparser@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/cssfontparser/-/cssfontparser-1.2.1.tgz#f4022fc8f9700c68029d542084afbaf425a3f3e3" + integrity sha1-9AIvyPlwDGgCnVQghK+69CWj8+M= + cssnano-preset-default@^5.2.5: version "5.2.5" resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-5.2.5.tgz#267ded811a3e1664d78707f5355fcd89feeb38ac" @@ -8544,6 +8549,14 @@ iterate-value@^1.0.2: es-get-iterator "^1.0.2" iterate-iterator "^1.0.1" +jest-canvas-mock@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/jest-canvas-mock/-/jest-canvas-mock-2.4.0.tgz#947b71442d7719f8e055decaecdb334809465341" + integrity sha512-mmMpZzpmLzn5vepIaHk5HoH3Ka4WykbSoLuG/EKoJd0x0ID/t+INo1l8ByfcUJuDM+RIsL4QDg/gDnBbrj2/IQ== + dependencies: + cssfontparser "^1.2.1" + moo-color "^1.0.2" + jest-changed-files@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-27.5.1.tgz#a348aed00ec9bf671cc58a66fcbe7c3dfd6a68f5" @@ -8626,7 +8639,7 @@ jest-config@^27.5.1: slash "^3.0.0" strip-json-comments "^3.1.1" -jest-diff@^27.2.5, jest-diff@^27.5.1: +jest-diff@^27.0.2, jest-diff@^27.2.5, jest-diff@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.5.1.tgz#a07f5011ac9e6643cf8a95a462b7b1ecf6680def" integrity sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw== @@ -9007,6 +9020,14 @@ jest-watcher@^27.5.1: jest-util "^27.5.1" string-length "^4.0.1" +jest-websocket-mock@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/jest-websocket-mock/-/jest-websocket-mock-2.3.0.tgz#317e7d7f8ba54ba632a7300777b02b7ebb606845" + integrity sha512-kXhRRApRdT4hLG/4rhsfcR0Ke0OzqIsDj0P5t0dl5aiAftShSgoRqp/0pyjS5bh+b9GrIzmfkrV2cn9LxxvSvA== + dependencies: + jest-diff "^27.0.2" + mock-socket "^9.1.0" + jest-worker@^25.1.0: version "25.5.0" resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-25.5.0.tgz#2611d071b79cea0f43ee57a3d118593ac1547db1" @@ -9865,6 +9886,18 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +mock-socket@^9.1.0: + version "9.1.3" + resolved "https://registry.yarnpkg.com/mock-socket/-/mock-socket-9.1.3.tgz#bcb106c6b345001fa7619466fcf2f8f5a156b10f" + integrity sha512-uz8lx8c5wuJYJ21f5UtovqpV0+KJuVwE7cVOLNhrl2QW/CvmstOLRfjXnLSbfFHZtJtiaSGQu0oCJA8SmRcK6A== + +moo-color@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.3.tgz#d56435f8359c8284d83ac58016df7427febece74" + integrity sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ== + dependencies: + color-name "^1.1.4" + move-concurrently@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92" @@ -14186,7 +14219,7 @@ xterm-for-react@^1.0.4: prop-types "^15.7.2" xterm "^4.5.0" -xterm@^4.5.0: +xterm@^4.18.0, xterm@^4.5.0: version "4.18.0" resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.18.0.tgz#a1f6ab2c330c3918fb094ae5f4c2562987398ea1" integrity sha512-JQoc1S0dti6SQfI0bK1AZvGnAxH4MVw45ZPFSO6FHTInAiau3Ix77fSxNx3mX4eh9OL4AYa8+4C8f5UvnSfppQ== From 3e1a0a4e673037122e7c67736bb9f8dabad5d54b Mon Sep 17 00:00:00 2001 From: kylecarbs Date: Fri, 29 Apr 2022 14:00:42 +0000 Subject: [PATCH 5/6] Use Ticker instead of Timer --- agent/agent.go | 4 +++- site/src/pages/TerminalPage/TerminalPage.tsx | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/agent/agent.go b/agent/agent.go index 71972c88717f4..c497a36cc3cb0 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -548,7 +548,9 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn ne // Resetting this timeout prevents the PTY from exiting. rpty.timeout.Reset(a.reconnectingPTYTimeout) - heartbeat := time.NewTimer(a.reconnectingPTYTimeout / 2) + ctx, cancelFunc := context.WithCancel(ctx) + defer cancelFunc() + heartbeat := time.NewTicker(a.reconnectingPTYTimeout / 2) defer heartbeat.Stop() go func() { // Keep updating the activity while this diff --git a/site/src/pages/TerminalPage/TerminalPage.tsx b/site/src/pages/TerminalPage/TerminalPage.tsx index deed7f5d26f4a..444ade2a821ec 100644 --- a/site/src/pages/TerminalPage/TerminalPage.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.tsx @@ -222,6 +222,7 @@ const useStyles = makeStyles(() => ({ terminal: { width: "100vw", height: "100vh", + overflow: "hidden", // These styles attempt to mimic the VS Code scrollbar. "& .xterm": { padding: 4, From 4ef71062f9f6c8eaa99bb35c023979b46f59210c Mon Sep 17 00:00:00 2001 From: kylecarbs Date: Fri, 29 Apr 2022 14:20:29 +0000 Subject: [PATCH 6/6] Active Windows mode on Windows --- agent/agent.go | 2 + site/package.json | 2 + site/src/api/types.ts | 1 + .../pages/TerminalPage/TerminalPage.test.tsx | 7 -- site/src/pages/TerminalPage/TerminalPage.tsx | 66 +++++++++---------- site/src/testHelpers/entities.ts | 1 + .../xServices/terminal/terminalXService.ts | 50 ++++++-------- site/yarn.lock | 5 ++ 8 files changed, 63 insertions(+), 71 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index c497a36cc3cb0..678f78fbf6f22 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -484,6 +484,8 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn ne } a.reconnectingPTYs.Store(id, rpty) go func() { + // CommandContext isn't respected for Windows PTYs right now, + // so we need to manually track the lifecycle. // When the context has been completed either: // 1. The timeout completed. // 2. The parent context was canceled. diff --git a/site/package.json b/site/package.json index b36d2951ee242..50a2744f22610 100644 --- a/site/package.json +++ b/site/package.json @@ -41,6 +41,7 @@ "react-dom": "17.0.2", "react-router-dom": "6.3.0", "swr": "1.2.2", + "uuid": "^8.3.2", "xstate": "4.31.0", "xterm": "^4.18.0", "xterm-addon-fit": "^0.5.0", @@ -65,6 +66,7 @@ "@types/react": "17.0.44", "@types/react-dom": "17.0.16", "@types/superagent": "4.1.15", + "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "5.21.0", "@typescript-eslint/parser": "5.21.0", "@xstate/cli": "0.1.7", diff --git a/site/src/api/types.ts b/site/src/api/types.ts index 6b9283548e4cd..1c730ce0ce35f 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -87,6 +87,7 @@ export interface WorkspaceResource { export interface WorkspaceAgent { id: string name: string + operating_system: string } export interface APIKeyResponse { diff --git a/site/src/pages/TerminalPage/TerminalPage.test.tsx b/site/src/pages/TerminalPage/TerminalPage.test.tsx index 808d65c405998..270e28cc1db7a 100644 --- a/site/src/pages/TerminalPage/TerminalPage.test.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.test.tsx @@ -1,5 +1,4 @@ import { waitFor } from "@testing-library/react" -import crypto from "crypto" import "jest-canvas-mock" import WS from "jest-websocket-mock" import { rest } from "msw" @@ -25,12 +24,6 @@ Object.defineProperty(window, "matchMedia", { })), }) -Object.defineProperty(window, "crypto", { - value: { - randomUUID: () => crypto.randomUUID(), - }, -}) - Object.defineProperty(window, "TextEncoder", { value: TextEncoder, }) diff --git a/site/src/pages/TerminalPage/TerminalPage.tsx b/site/src/pages/TerminalPage/TerminalPage.tsx index 444ade2a821ec..25e1d67d307bb 100644 --- a/site/src/pages/TerminalPage/TerminalPage.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.tsx @@ -2,6 +2,7 @@ import { makeStyles } from "@material-ui/core/styles" import { useMachine } from "@xstate/react" import React from "react" import { useLocation, useNavigate, useParams } from "react-router-dom" +import { v4 as uuidv4 } from "uuid" import * as XTerm from "xterm" import { FitAddon } from "xterm-addon-fit" import { WebLinksAddon } from "xterm-addon-web-links" @@ -16,14 +17,6 @@ export const Language = { websocketErrorMessagePrefix: "WebSocket failed: ", } -// TypeScript doesn't have the randomUUID type on Crypto yet. See: -// https://github.com/denoland/deno/issues/12754#issuecomment-970386235 -declare global { - interface Crypto { - randomUUID: () => string - } -} - export const TerminalPage: React.FC<{ readonly renderer?: XTerm.RendererType }> = ({ renderer }) => { @@ -39,13 +32,14 @@ export const TerminalPage: React.FC<{ // a round-trip, and must be a UUIDv4. const [reconnectionToken] = React.useState(() => { const search = new URLSearchParams(location.search) - let reconnect = search.get("reconnect") - if (reconnect === null) { - reconnect = crypto.randomUUID() - } - return reconnect + return search.get("reconnect") ?? uuidv4() }) const [terminalState, sendEvent] = useMachine(terminalMachine, { + context: { + reconnection: reconnectionToken, + workspaceName: workspace, + username: username, + }, actions: { readMessage: (_, event) => { if (typeof event.data === "string") { @@ -59,6 +53,8 @@ export const TerminalPage: React.FC<{ }, }) const isConnected = terminalState.matches("connected") + const { organizationsError, workspaceError, workspaceAgentError, workspaceAgent, websocketError } = + terminalState.context // Create the terminal! React.useEffect(() => { @@ -125,13 +121,7 @@ export const TerminalPage: React.FC<{ replace: true, }, ) - sendEvent({ - type: "CONNECT", - reconnection: reconnectionToken, - workspaceName: workspace, - username: username, - }) - }, [location.search, navigate, workspace, username, sendEvent, reconnectionToken]) + }, [location.search, navigate, reconnectionToken]) // Apply terminal options based on connection state. React.useEffect(() => { @@ -150,17 +140,17 @@ export const TerminalPage: React.FC<{ terminal.options = { disableStdin: true, } - if (terminalState.context.organizationsError instanceof Error) { - terminal.writeln(Language.organizationsErrorMessagePrefix + terminalState.context.organizationsError.message) + if (organizationsError instanceof Error) { + terminal.writeln(Language.organizationsErrorMessagePrefix + organizationsError.message) } - if (terminalState.context.workspaceError instanceof Error) { - terminal.writeln(Language.workspaceErrorMessagePrefix + terminalState.context.workspaceError.message) + if (workspaceError instanceof Error) { + terminal.writeln(Language.workspaceErrorMessagePrefix + workspaceError.message) } - if (terminalState.context.workspaceAgentError instanceof Error) { - terminal.writeln(Language.workspaceAgentErrorMessagePrefix + terminalState.context.workspaceAgentError.message) + if (workspaceAgentError instanceof Error) { + terminal.writeln(Language.workspaceAgentErrorMessagePrefix + workspaceAgentError.message) } - if (terminalState.context.websocketError instanceof Error) { - terminal.writeln(Language.websocketErrorMessagePrefix + terminalState.context.websocketError.message) + if (websocketError instanceof Error) { + terminal.writeln(Language.websocketErrorMessagePrefix + websocketError.message) } return } @@ -174,6 +164,7 @@ export const TerminalPage: React.FC<{ terminal.focus() terminal.options = { disableStdin: false, + windowsMode: workspaceAgent?.operating_system === "windows", } // Update the terminal size post-fit. @@ -185,10 +176,11 @@ export const TerminalPage: React.FC<{ }, }) }, [ - terminalState.context.workspaceError, - terminalState.context.organizationsError, - terminalState.context.workspaceAgentError, - terminalState.context.websocketError, + workspaceError, + organizationsError, + workspaceAgentError, + websocketError, + workspaceAgent, terminal, fitAddon, isConnected, @@ -199,7 +191,9 @@ export const TerminalPage: React.FC<{ <> {/* This overlay makes it more obvious that the terminal is disconnected. */} {/* It's nice for situations where Coder restarts, and they are temporarily disconnected. */} -
+
+ Disconnected +
) @@ -214,6 +208,12 @@ const useStyles = makeStyles(() => ({ bottom: 0, right: 0, zIndex: 1, + alignItems: "center", + justifyContent: "center", + display: "flex", + color: "white", + fontFamily: MONOSPACE_FONT_FAMILY, + fontSize: 18, backgroundColor: "rgba(0, 0, 0, 0.5)", "&.connected": { opacity: 0, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 9545d1dd1b2be..35a5e48c2579c 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -102,6 +102,7 @@ export const MockWorkspace: Workspace = { export const MockWorkspaceAgent: WorkspaceAgent = { id: "test-workspace-agent", name: "a-workspace-agent", + operating_system: "linux", } export const MockWorkspaceResource: WorkspaceResource = { diff --git a/site/src/xServices/terminal/terminalXService.ts b/site/src/xServices/terminal/terminalXService.ts index 564713a9aa016..a3088368429e4 100644 --- a/site/src/xServices/terminal/terminalXService.ts +++ b/site/src/xServices/terminal/terminalXService.ts @@ -48,7 +48,7 @@ export const terminalMachine = }, }, id: "terminalState", - initial: "disconnected", + initial: "gettingOrganizations", states: { gettingOrganizations: { invoke: { @@ -150,13 +150,13 @@ export const terminalMachine = { services: { getOrganizations: API.getOrganizations, - getWorkspace: async (context: TerminalContext) => { + getWorkspace: async (context) => { if (!context.organizations || !context.workspaceName) { throw new Error("organizations or workspace not set") } return API.getWorkspace(context.organizations[0].id, context.username, context.workspaceName) }, - getWorkspaceAgent: async (context: TerminalContext) => { + getWorkspaceAgent: async (context) => { if (!context.workspace || !context.workspaceName) { throw new Error("workspace or workspace name is not set") } @@ -167,38 +167,29 @@ export const terminalMachine = const agentName = workspaceNameParts[1] const resources = await API.getWorkspaceResources(context.workspace.latest_build.id) - for (let i = 0; i < resources.length; i++) { - const resource = resources[i] - if (!resource.agents) { - continue - } - if (resource.agents.length <= 0) { - continue - } - if (!agentName) { - return resource.agents[0] - } - for (let a = 0; a < resource.agents.length; a++) { - const agent = resource.agents[a] - if (agent.name !== agentName) { - continue + + const agent = resources + .map((resource) => { + if (!resource.agents || resource.agents.length < 1) { + return } - return agent - } + if (!agentName) { + return resource.agents[0] + } + return resource.agents.find((agent) => agent.name === agentName) + }) + .filter((a) => a)[0] + if (!agent) { + throw new Error("no agent found with id") } - throw new Error("no agent found with id") + return agent }, - connect: (context: TerminalContext) => (send) => { + connect: (context) => (send) => { return new Promise((resolve, reject) => { if (!context.workspaceAgent) { return reject("workspace agent is not set") } - let proto = location.protocol - if (proto === "https:") { - proto = "wss:" - } else { - proto = "ws:" - } + const proto = location.protocol === "https:" ? "wss:" : "ws:" const socket = new WebSocket( `${proto}//${location.host}/api/v2/workspaceagents/${context.workspaceAgent.id}/pty?reconnect=${context.reconnection}`, ) @@ -275,9 +266,6 @@ export const terminalMachine = } context.websocket.send(new TextEncoder().encode(JSON.stringify(event.request))) }, - readMessage: () => { - // Override this with the terminal writer! - }, disconnect: (context: TerminalContext) => { // Code 1000 is a successful exit! context.websocket?.close(1000) diff --git a/site/yarn.lock b/site/yarn.lock index 9b4ae58b47de3..c2b1e3366e52c 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -3194,6 +3194,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d" integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ== +"@types/uuid@^8.3.4": + version "8.3.4" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc" + integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw== + "@types/webpack-env@^1.16.0": version "1.16.3" resolved "https://registry.yarnpkg.com/@types/webpack-env/-/webpack-env-1.16.3.tgz#b776327a73e561b71e7881d0cd6d34a1424db86a"