main
py 172 lines 5.8 KB
Raw
1 # Copyright 2026 Google LLC
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15 import json
16 import logging
17 import os
18 import signal
19 import sys
20 import termios
21 import threading
22 import time
23 import tty
24 from urllib.parse import urlparse
25
26 import websocket
27
28 from colab_cli.state import SessionState
29
30 logger = logging.getLogger(__name__)
31
32 # Global flag to stop the read thread when the websocket closes
33 _is_running = False
34 _last_error = None
35
36 # When stdin is piped and reaches EOF, we send "exit\n" to the remote shell and
37 # then wait this many seconds for any remaining output (the shell's goodbye,
38 # tmux teardown messages, etc.) to flush before closing the websocket from the
39 # client side. Empirically 0.5s is enough for the typical /colab/tty backend
40 # wrapped in tmux + bash; bumping it just delays exit, lowering it risks
41 # truncating tail output.
42 PIPED_EOF_GRACE_SECONDS = 0.5
43
44
45 def on_message(ws, message):
46 """Callback for when a message is received from the server."""
47 try:
48 data = json.loads(message)
49 if "data" in data:
50 # The backend sends raw ANSI escape sequences and string content.
51 # We write it directly to stdout buffer to avoid python print() formatting.
52 sys.stdout.buffer.write(data["data"].encode("utf-8"))
53 sys.stdout.buffer.flush()
54 except Exception as e:
55 logger.debug(f"Error parsing message: {e}")
56
57
58 def on_error(ws, error):
59 """Callback for when a websocket error occurs."""
60 global _last_error
61 _last_error = error
62 logger.error(f"WebSocket Error: {error}")
63
64
65 def on_close(ws, close_status_code, close_msg):
66 """Callback for when the websocket is closed."""
67 global _is_running
68 _is_running = False
69
70
71 def send_terminal_size(ws):
72 """Sends the current terminal size to the remote backend."""
73 try:
74 size = os.get_terminal_size()
75 payload = json.dumps({"cols": size.columns, "rows": size.lines})
76 ws.send(payload)
77 except Exception as e:
78 logger.debug(f"Failed to send terminal size: {e}")
79
80
81 def on_open(ws):
82 """Callback for when the websocket connection is opened."""
83 global _is_running
84 _is_running = True
85
86 # Send initial terminal size
87 send_terminal_size(ws)
88
89 # Setup the background thread to read from stdin
90 def read_stdin():
91 is_tty = sys.stdin.isatty()
92 while _is_running:
93 try:
94 # Read a single character (or escape sequence byte)
95 char = sys.stdin.read(1)
96 if not char:
97 if not is_tty:
98 # Piped input has reached EOF. The remote /colab/tty
99 # endpoint wraps bash in tmux which intercepts \x04
100 # (Ctrl-D) as a literal character, so it never exits.
101 # Instead send "exit\n" so bash voluntarily terminates,
102 # wait a short grace period for the shell's goodbye
103 # output to drain back to us, then close the websocket
104 # ourselves to guarantee the client unblocks.
105 try:
106 ws.send(json.dumps({"data": "exit\n"}))
107 except Exception:
108 pass
109 time.sleep(PIPED_EOF_GRACE_SECONDS)
110 try:
111 ws.close()
112 except Exception:
113 pass
114 break
115 ws.send(json.dumps({"data": char}))
116 except Exception:
117 break
118
119 thread = threading.Thread(target=read_stdin, daemon=True)
120 thread.start()
121
122
123 def connect_console(session: SessionState):
124 """
125 Connects to the Colab TTY endpoint and sets up a raw terminal session.
126 """
127 global _is_running, _last_error
128 _last_error = None
129
130 # Construct the WebSocket URL from the base URL
131 parsed = urlparse(session.url)
132 ws_scheme = "wss" if parsed.scheme == "https" else "ws"
133 ws_url = f"{ws_scheme}://{parsed.netloc}/colab/tty?colab-runtime-proxy-token={session.token}"
134
135 is_tty = sys.stdin.isatty()
136 fd = sys.stdin.fileno() if is_tty else None
137 old_settings = termios.tcgetattr(fd) if is_tty else None
138
139 ws = websocket.WebSocketApp(
140 url=ws_url,
141 on_open=on_open,
142 on_message=on_message,
143 on_error=on_error,
144 on_close=on_close,
145 )
146
147 def handle_sigwinch(signum, frame):
148 """Handle window resize events."""
149 if _is_running:
150 send_terminal_size(ws)
151
152 try:
153 if is_tty:
154 tty.setraw(fd, termios.TCSANOW)
155 signal.signal(signal.SIGWINCH, handle_sigwinch)
156
157 # This is a blocking call until the connection is closed
158 ws.run_forever()
159
160 if _last_error:
161 # Re-raise or wrap terminal errors
162 err_msg = str(_last_error)
163 if "404" in err_msg or "401" in err_msg:
164 # We raise a standard exception that the caller can recognize
165 raise RuntimeError(f"Connection failed: {err_msg}")
166 finally:
167 if is_tty:
168 # Always ensure the terminal is restored to its original state
169 termios.tcsetattr(fd, termios.TCSANOW, old_settings)
170 # Restore the default signal handler for resize
171 signal.signal(signal.SIGWINCH, signal.SIG_DFL)
172 print("\r\nConnection closed.")