diff --git a/internal/server/handlers_panel_term.go b/internal/server/handlers_panel_term.go index ee5e2e4..474d733 100644 --- a/internal/server/handlers_panel_term.go +++ b/internal/server/handlers_panel_term.go @@ -49,6 +49,9 @@ type panelInMsg struct { D string `json:"d,omitempty"` Cols uint16 `json:"cols,omitempty"` Rows uint16 `json:"rows,omitempty"` + // Seq numbers the messages of a session from 1; 0 from a page that + // predates it. + Seq uint64 `json:"seq,omitempty"` } // POST /api/panel/term/{sid}/in - send keystrokes/resize to the agent PTY. @@ -63,6 +66,10 @@ func (s *Server) handlePanelIn(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad request", http.StatusBadRequest) return } + if m.Seq > 0 && !sess.TakeSeq(m.Seq) { + w.WriteHeader(http.StatusNoContent) // a repeat of one already taken + return + } switch m.T { case "i": sess.PushIn(proto.InFrame{Type: proto.FrameStdin, Data: m.D}) diff --git a/internal/server/hub.go b/internal/server/hub.go index 49c2db6..377283c 100644 --- a/internal/server/hub.go +++ b/internal/server/hub.go @@ -45,6 +45,7 @@ type Session struct { mu sync.Mutex inBuf []proto.InFrame inSignal chan struct{} // size-1; pulsed when inBuf gains data + inSeq uint64 // number of the last browser message taken out chan proto.OutMessage // agent -> panel @@ -250,6 +251,19 @@ func (s *Session) close() { s.closeOnce.Do(func() { close(s.closed) }) } // Done returns a channel closed when the session ends. func (s *Session) Done() <-chan struct{} { return s.closed } +// TakeSeq reports whether the browser message numbered seq is new, and takes +// note of it. The browser sends them one at a time in rising order and sends +// one again when it got no answer, so a number not above the last is a repeat. +func (s *Session) TakeSeq(seq uint64) bool { + s.mu.Lock() + defer s.mu.Unlock() + if seq <= s.inSeq { + return false + } + s.inSeq = seq + return true +} + // PushIn appends a downstream frame (panel -> agent) and wakes the agent poll. func (s *Session) PushIn(f proto.InFrame) { s.mu.Lock() diff --git a/internal/server/web/templates/terminal.html b/internal/server/web/templates/terminal.html index 89d3197..c62ccd1 100644 --- a/internal/server/web/templates/terminal.html +++ b/internal/server/web/templates/terminal.html @@ -162,11 +162,42 @@ term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n'); } - function send(data) { + // Input goes out one request at a time, so on a slow link a keystroke can + // never overtake the one before it; what is typed meanwhile is sent together + // next. A request that fails goes again under the same number, and the + // server drops one it already has. + let inSeq = 0, inBusy = false; + const inQueue = []; + function queueIn(m) { if (closed || !sid) return; - const bytes = new TextEncoder().encode(data); - post(base + sid + '/in', { t: 'i', d: bytesToB64(bytes) }); + const last = inQueue[inQueue.length - 1]; + if (last && last.t === 'i' && m.t === 'i') last.text += m.text; + else if (last && last.t === 'r' && m.t === 'r') inQueue[inQueue.length - 1] = m; + else inQueue.push(m); + pumpIn(); } + async function pumpIn() { + if (inBusy || !inQueue.length) return; + inBusy = true; + while (inQueue.length && !closed) { + const m = inQueue.shift(); + const body = m.t === 'i' + ? { t: 'i', d: bytesToB64(new TextEncoder().encode(m.text)), seq: ++inSeq } + : { t: 'r', cols: m.cols, rows: m.rows, seq: ++inSeq }; + for (let wait = 300; !closed; wait = Math.min(wait * 2, 5000)) { + let r = null; + try { + r = await fetch(base + sid + '/in', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) + }); + } catch (e) { /* no answer: send it again */ } + if (r && r.status < 500) break; // taken, or refused for good + await new Promise(res => setTimeout(res, wait)); + } + } + inBusy = false; + } + function send(data) { queueIn({ t: 'i', text: data }); } // What the keyboard types gets the CTRL / ALT of the extra keys, if armed. term.onData(function (data) { send(withMods(data)); }); @@ -240,10 +271,7 @@ b.addEventListener('contextmenu', function (e) { e.preventDefault(); }); }); - function sendResize() { - if (closed || !sid) return; - post(base + sid + '/in', { t: 'r', cols: term.cols, rows: term.rows }); - } + function sendResize() { queueIn({ t: 'r', cols: term.cols, rows: term.rows }); } function refit() { if (touch && window.visualViewport) { // Follow the visible area, so the extra keys sit right above the