fix: terminal input reaches the shell in the order it was typed

This commit is contained in:
redtea committed 2026-10-14 15:18:14 +03:00
1 parent cb2673d941
commit 225b7ecfcc
3 files changed
+56 -7

No files matched your search

+7
View File
@@ -49,6 +49,9 @@ type panelInMsg struct {
D string `json:"d,omitempty"` D string `json:"d,omitempty"`
Cols uint16 `json:"cols,omitempty"` Cols uint16 `json:"cols,omitempty"`
Rows uint16 `json:"rows,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. // 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) http.Error(w, "bad request", http.StatusBadRequest)
return return
} }
if m.Seq > 0 && !sess.TakeSeq(m.Seq) {
w.WriteHeader(http.StatusNoContent) // a repeat of one already taken
return
}
switch m.T { switch m.T {
case "i": case "i":
sess.PushIn(proto.InFrame{Type: proto.FrameStdin, Data: m.D}) sess.PushIn(proto.InFrame{Type: proto.FrameStdin, Data: m.D})
+14
View File
@@ -45,6 +45,7 @@ type Session struct {
mu sync.Mutex mu sync.Mutex
inBuf []proto.InFrame inBuf []proto.InFrame
inSignal chan struct{} // size-1; pulsed when inBuf gains data 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 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. // Done returns a channel closed when the session ends.
func (s *Session) Done() <-chan struct{} { return s.closed } 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. // PushIn appends a downstream frame (panel -> agent) and wakes the agent poll.
func (s *Session) PushIn(f proto.InFrame) { func (s *Session) PushIn(f proto.InFrame) {
s.mu.Lock() s.mu.Lock()
+35 -7
View File
@@ -162,11 +162,42 @@
term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n'); 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; if (closed || !sid) return;
const bytes = new TextEncoder().encode(data); const last = inQueue[inQueue.length - 1];
post(base + sid + '/in', { t: 'i', d: bytesToB64(bytes) }); 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. // What the keyboard types gets the CTRL / ALT of the extra keys, if armed.
term.onData(function (data) { send(withMods(data)); }); term.onData(function (data) { send(withMods(data)); });
@@ -240,10 +271,7 @@
b.addEventListener('contextmenu', function (e) { e.preventDefault(); }); b.addEventListener('contextmenu', function (e) { e.preventDefault(); });
}); });
function sendResize() { function sendResize() { queueIn({ t: 'r', cols: term.cols, rows: term.rows }); }
if (closed || !sid) return;
post(base + sid + '/in', { t: 'r', cols: term.cols, rows: term.rows });
}
function refit() { function refit() {
if (touch && window.visualViewport) { if (touch && window.visualViewport) {
// Follow the visible area, so the extra keys sit right above the // Follow the visible area, so the extra keys sit right above the