Feature(Go): Web terminal relay with PTY mode and graceful close (Phase 6)

This commit is contained in:
yuanyuanxiang
2026-05-18 15:03:42 +02:00
committed by yuanyuanxiang
parent 6485e800d6
commit d7f38ecfdb
7 changed files with 696 additions and 96 deletions

View File

@@ -77,6 +77,21 @@ type Device struct {
// JSON messages.
cursorSeen bool
lastCursorIndex byte
// Terminal session state — at most one web terminal per device (MVP
// constraint shared with the C++ server). All three fields are
// guarded by hub.mu.
//
// terminalPending: COMMAND_SHELL has been sent, waiting for the device's
// sub-conn to arrive and announce itself via TOKEN_TERMINAL_START /
// TOKEN_SHELL_START.
// terminalConn: the shell sub-conn ctx after binding. Nil before BIND
// and after teardown.
// terminalIsPTY: distinguishes Linux/macOS/ConPTY (true) from the legacy
// Windows cmd-pipe path. PTY mode supports resize; cmd-pipe ignores it.
terminalPending bool
terminalConn *connection.Context
terminalIsPTY bool
}
// ScreenCache is a read-only snapshot of a device's last-seen screen state,
@@ -171,6 +186,18 @@ type EventHandler interface {
// Duplicates (same index as the previous frame) are filtered out by the
// hub before reaching subscribers.
OnCursorChange(deviceID string, index byte)
// OnTerminalReady fires once the device's shell sub-conn is bound and
// the server has sent COMMAND_NEXT to start its output read loop.
// isPTY=true means PTY mode (Linux/macOS or ConPTY); false means the
// legacy Windows cmd-pipe path which doesn't support resize.
OnTerminalReady(deviceID string, isPTY bool)
// OnTerminalData ships one chunk of raw shell output (already wrapped
// in the WS-binary "TRM1" magic header) to terminal viewers.
OnTerminalData(deviceID string, packet []byte)
// OnTerminalClosed fires when the session ends — either because the
// device sent TOKEN_TERMINAL_CLOSE, the sub-conn dropped, or the
// server explicitly tore it down.
OnTerminalClosed(deviceID string, reason string)
}
// Hub is a thread-safe registry of online devices.
@@ -187,13 +214,19 @@ type Hub struct {
// having to walk every device. Empty when no screen sessions exist.
screenIndex map[*connection.Context]string
screenIndexMu sync.RWMutex
// Parallel reverse index for terminal sub-conns. Same purpose: O(1)
// lookup from a raw ctx (e.g. on OnDisconnect) back to its device.
terminalIndex map[*connection.Context]string
terminalIndexMu sync.RWMutex
}
// New returns an empty Hub.
func New() *Hub {
return &Hub{
devices: make(map[string]*Device),
screenIndex: make(map[*connection.Context]string),
devices: make(map[string]*Device),
screenIndex: make(map[*connection.Context]string),
terminalIndex: make(map[*connection.Context]string),
}
}
@@ -547,6 +580,223 @@ func (h *Hub) UpdateLive(id string, rtt int, activeWindow string) {
}
}
// ----- Terminal session management (Phase 6) --------------------------------
// ErrTerminalBusy is returned by OpenTerminalSession when the device already
// has a pending or active terminal session — MVP enforces single-viewer.
var ErrTerminalBusy = errors.New("terminal already open by another viewer")
// OpenTerminalSession atomically marks a terminal session as pending for the
// device, then sends COMMAND_SHELL on the main TCP connection so the device
// will spawn a shell sub-conn. Returns nil if the request was sent. On any
// failure the pending flag is rolled back so retries are possible.
//
// Single-viewer constraint: if a pending or bound session already exists,
// returns ErrTerminalBusy. Mirrors C++ CWebService::HandleTermOpen
// (server/2015Remote/WebService.cpp:1838).
func (h *Hub) OpenTerminalSession(deviceID string) error {
if deviceID == "" {
return ErrDeviceOffline
}
h.mu.Lock()
d, ok := h.devices[deviceID]
if !ok || d.conn == nil {
h.mu.Unlock()
return ErrDeviceOffline
}
if d.terminalPending || d.terminalConn != nil {
h.mu.Unlock()
return ErrTerminalBusy
}
d.terminalPending = true
mainConn := d.conn
h.mu.Unlock()
if h.sender == nil {
// Roll back so a retry isn't permanently blocked.
h.mu.Lock()
d.terminalPending = false
h.mu.Unlock()
return ErrNoSender
}
if err := h.sender(mainConn, []byte{protocol.CommandShell}); err != nil {
h.mu.Lock()
d.terminalPending = false
h.mu.Unlock()
return err
}
return nil
}
// IsTerminalPending tells the TCP layer whether the next-arriving shell
// sub-conn should be claimed by the web terminal. The C++ side uses this
// in MessageHandle to decide between WebService takeover and opening an
// MFC dialog (server/2015Remote/2015RemoteDlg.cpp:5753).
func (h *Hub) IsTerminalPending(deviceID string) bool {
h.mu.RLock()
defer h.mu.RUnlock()
d, ok := h.devices[deviceID]
return ok && d.terminalPending
}
// BindTerminalConn promotes the pending session to an active one by
// associating the device's freshly-arrived shell sub-conn. Returns false
// if no pending session exists — callers should drop the orphan ctx.
//
// Subscribers receive OnTerminalReady AFTER binding so they can flip the
// browser into "ready" state immediately on the same TCP roundtrip that
// will deliver the first shell output.
func (h *Hub) BindTerminalConn(deviceID string, ctx *connection.Context, isPTY bool) bool {
if deviceID == "" || ctx == nil {
return false
}
h.mu.Lock()
d, ok := h.devices[deviceID]
if !ok || !d.terminalPending {
h.mu.Unlock()
return false
}
d.terminalConn = ctx
d.terminalIsPTY = isPTY
d.terminalPending = false
h.mu.Unlock()
h.terminalIndexMu.Lock()
h.terminalIndex[ctx] = deviceID
h.terminalIndexMu.Unlock()
for _, s := range h.snapshotSubscribers() {
s.OnTerminalReady(deviceID, isPTY)
}
return true
}
// TerminalDeviceID returns the device ID whose terminal sub-conn this
// context belongs to, or "" otherwise. The TCP layer uses this on every
// inbound packet on a sub-conn — when non-empty, the bytes are raw shell
// output and bypass the usual command-byte switch.
func (h *Hub) TerminalDeviceID(ctx *connection.Context) string {
h.terminalIndexMu.RLock()
defer h.terminalIndexMu.RUnlock()
return h.terminalIndex[ctx]
}
// UnbindTerminalConn removes the terminal mapping (called from the TCP
// disconnect path for any sub-conn ctx). Fires OnTerminalClosed once if
// the unbind actually removed something — so subscribers can update the
// browser even on unexpected device-side drops.
func (h *Hub) UnbindTerminalConn(ctx *connection.Context) {
h.terminalIndexMu.Lock()
deviceID, tracked := h.terminalIndex[ctx]
if !tracked {
h.terminalIndexMu.Unlock()
return
}
delete(h.terminalIndex, ctx)
h.terminalIndexMu.Unlock()
h.mu.Lock()
if d, ok := h.devices[deviceID]; ok && d.terminalConn == ctx {
d.terminalConn = nil
d.terminalPending = false
d.terminalIsPTY = false
}
h.mu.Unlock()
for _, s := range h.snapshotSubscribers() {
s.OnTerminalClosed(deviceID, "disconnected")
}
}
// SendToTerminal forwards bytes (typically xterm.js keystrokes) to the
// device's shell sub-conn. Returns ErrDeviceOffline if no session is
// active for this device.
func (h *Hub) SendToTerminal(id string, data []byte) error {
h.mu.RLock()
d, ok := h.devices[id]
var tc *connection.Context
if ok {
tc = d.terminalConn
}
h.mu.RUnlock()
if !ok || tc == nil {
return ErrDeviceOffline
}
if h.sender == nil {
return ErrNoSender
}
return h.sender(tc, data)
}
// TerminalIsPTY reports whether the active session is PTY mode (the
// resize command only applies in PTY mode — legacy cmd-pipe ignores it).
func (h *Hub) TerminalIsPTY(id string) bool {
h.mu.RLock()
defer h.mu.RUnlock()
d, ok := h.devices[id]
return ok && d.terminalConn != nil && d.terminalIsPTY
}
// CloseTerminalSession tears down the session from the server side
// (typically when the requesting browser sends term_close or disconnects).
// Mirrors CloseScreen's graceful pattern: drop the index synchronously,
// send COMMAND_BYE, then close after a short grace period so the client's
// IOCPClient reconnect logic doesn't fire.
func (h *Hub) CloseTerminalSession(deviceID string) {
h.mu.Lock()
d, ok := h.devices[deviceID]
if !ok {
h.mu.Unlock()
return
}
tc := d.terminalConn
// hadSession guards against firing spurious OnTerminalClosed events
// when there was nothing to tear down — relevant when the main-conn
// teardown path calls CloseTerminalSession unconditionally as part of
// device-offline cleanup, or when both OnDisconnect and an explicit
// browser term_close race for the same teardown.
hadSession := tc != nil || d.terminalPending
d.terminalConn = nil
d.terminalPending = false
d.terminalIsPTY = false
h.mu.Unlock()
if !hadSession {
return
}
for _, s := range h.snapshotSubscribers() {
s.OnTerminalClosed(deviceID, "closed")
}
if tc == nil {
return
}
h.terminalIndexMu.Lock()
delete(h.terminalIndex, tc)
h.terminalIndexMu.Unlock()
// Mirror Hub.CloseScreen: send COMMAND_BYE then close after 500 ms so
// the device exits its shell read loop instead of treating the FIN as
// a network blip and triggering reconnect.
if h.sender != nil {
_ = h.sender(tc, []byte{protocol.CommandBye})
}
go func(c *connection.Context) {
time.Sleep(500 * time.Millisecond)
c.Close()
}(tc)
}
// PublishTerminalData fans out one chunk of shell output to subscribers.
// Caller has already wrapped it in the "TRM1" magic header so the browser
// can demultiplex from screen frames over the shared WebSocket.
func (h *Hub) PublishTerminalData(deviceID string, packet []byte) {
for _, s := range h.snapshotSubscribers() {
s.OnTerminalData(deviceID, packet)
}
}
// Count returns the current number of online devices.
func (h *Hub) Count() int {
h.mu.RLock()