Skip to content

feat: Add web terminal with reconnecting TTYs #1186

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Apr 29, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Active Windows mode on Windows
  • Loading branch information
kylecarbs committed Apr 29, 2022
commit 4ef71062f9f6c8eaa99bb35c023979b46f59210c
2 changes: 2 additions & 0 deletions agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
1 change: 1 addition & 0 deletions site/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export interface WorkspaceResource {
export interface WorkspaceAgent {
id: string
name: string
operating_system: string
}

export interface APIKeyResponse {
Expand Down
7 changes: 0 additions & 7 deletions site/src/pages/TerminalPage/TerminalPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -25,12 +24,6 @@ Object.defineProperty(window, "matchMedia", {
})),
})

Object.defineProperty(window, "crypto", {
value: {
randomUUID: () => crypto.randomUUID(),
},
})

Object.defineProperty(window, "TextEncoder", {
value: TextEncoder,
})
Expand Down
66 changes: 33 additions & 33 deletions site/src/pages/TerminalPage/TerminalPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 }) => {
Expand All @@ -39,13 +32,14 @@ export const TerminalPage: React.FC<{
// a round-trip, and must be a UUIDv4.
const [reconnectionToken] = React.useState<string>(() => {
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") {
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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
}
Expand All @@ -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.
Expand All @@ -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,
Expand All @@ -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. */}
<div className={`${styles.overlay} ${isConnected ? "connected" : ""}`} />
<div className={`${styles.overlay} ${isConnected ? "connected" : ""}`}>
<span>Disconnected</span>
</div>
<div className={styles.terminal} ref={xtermRef} data-testid="terminal" />
</>
)
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions site/src/testHelpers/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
50 changes: 19 additions & 31 deletions site/src/xServices/terminal/terminalXService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const terminalMachine =
},
},
id: "terminalState",
initial: "disconnected",
initial: "gettingOrganizations",
states: {
gettingOrganizations: {
invoke: {
Expand Down Expand Up @@ -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")
}
Expand All @@ -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<WebSocket>((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}`,
)
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions site/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down