Feature(Go): Web terminal relay with PTY mode and graceful close (Phase 6)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -84,6 +84,12 @@ func (c *captureHandler) OnResolutionChange(_ string, _, _ int) {}
|
||||
|
||||
func (c *captureHandler) OnCursorChange(_ string, _ byte) {}
|
||||
|
||||
func (c *captureHandler) OnTerminalReady(_ string, _ bool) {}
|
||||
|
||||
func (c *captureHandler) OnTerminalData(_ string, _ []byte) {}
|
||||
|
||||
func (c *captureHandler) OnTerminalClosed(_ string, _ string) {}
|
||||
|
||||
func TestHubSubscribeEvents(t *testing.T) {
|
||||
h := New()
|
||||
c := &captureHandler{}
|
||||
|
||||
Reference in New Issue
Block a user