Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,8 @@ jobs:
npm run i18n:extract || true
git diff --exit-code src/locales/en.json || (echo "::error::New i18n keys found — run 'npm run i18n:sync' and commit the result" && exit 1)

- name: Test frontend
run: npm test

- name: Build frontend
run: npm run build
1 change: 1 addition & 0 deletions frontend-svelte/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "node --test src/**/*.test.js",
"preview": "vite preview",
"i18n:extract": "node scripts/i18n-extract.js"
},
Expand Down
42 changes: 20 additions & 22 deletions frontend-svelte/src/components/TmuxPaneModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
passthrough — xterm onData chunks are mapped to the backend's
`/tmux/pane/keys` endpoint (named keys for control sequences, literal text
otherwise), so an operator can answer first-run dialogs or interrupt a
wedged REPL without SSH + `tmux attach`. Sends are serialized through a
promise chain to preserve keystroke order.
wedged REPL without SSH + `tmux attach`. Sends start immediately and repeat
unacknowledged events; the daemon orders and de-duplicates them by sequence.

Props:
show: bool — modal open state (bind from parent)
Expand All @@ -25,6 +25,7 @@
import { onDestroy } from 'svelte';
import Modal from './Modal.svelte';
import { api, sse } from '../lib/api.js';
import { createPaneInputSender } from '../lib/paneInputSender.js';

export let show = false;
export let agent = '';
Expand Down Expand Up @@ -55,9 +56,7 @@
let inputEnabled = false;
let inputError = '';
let onDataDisposable = null;
// Serialize sends so fast typing can't reorder keystrokes (fetches
// racing each other would scramble e.g. "ls" into "sl").
let sendChain = Promise.resolve();
let paneInputSender = null;

// xterm onData control sequences → backend named keys (tmux names).
const KEY_SEQUENCES = {
Expand All @@ -81,22 +80,8 @@
'\x1b[6~': 'NPage',
};

// Bumped on teardown (close/switch) so queued keystrokes from a previous
// pane are dropped instead of landing on the newly-selected agent/label.
let sendEpoch = 0;
function queueSend(payload) {
// Snapshot the target at enqueue time — a queued link of the chain must
// hit the pane the keystroke was typed for, not the live agent/label.
const targetAgent = agent;
const targetLabel = label;
const epoch = sendEpoch;
sendChain = sendChain
.then(() => {
if (epoch !== sendEpoch) return; // pane torn down/switched — drop
return api('POST', `/agents/${encodeURIComponent(targetAgent)}/tmux/pane/keys`, { ...payload, label: targetLabel });
})
.then(() => { if (epoch === sendEpoch && inputError) inputError = ''; })
.catch((e) => { if (epoch === sendEpoch) inputError = `send failed: ${e?.message || e}`; });
paneInputSender?.enqueue(payload);
}

function handleTerminalData(data) {
Expand Down Expand Up @@ -222,6 +207,20 @@

async function mount() {
statusMessage = 'Loading terminal…';
// Capture this mount's target. Requests initiated before a close/switch
// must finish against the old pane, never the newly selected one.
const targetAgent = agent;
const targetLabel = label;
paneInputSender = createPaneInputSender({
send: (body, options) => api(
'POST',
`/agents/${encodeURIComponent(targetAgent)}/tmux/pane/keys`,
{ ...body, label: targetLabel },
options,
),
onSuccess: () => { if (inputError) inputError = ''; },
onError: (e) => { inputError = `send failed: ${e?.message || e}`; },
});
try {
const [{ Terminal }, { FitAddon }] = await Promise.all([
import('@xterm/xterm'),
Expand Down Expand Up @@ -294,6 +293,7 @@
if (sseSource) { try { sseSource.close(); } catch {} sseSource = null; }
if (resizeObserver) { try { resizeObserver.disconnect(); } catch {} resizeObserver = null; }
if (onDataDisposable) { try { onDataDisposable.dispose(); } catch {} onDataDisposable = null; }
if (paneInputSender) { paneInputSender.dispose(); paneInputSender = null; }
if (terminal) { try { terminal.dispose(); } catch {} terminal = null; }
fitAddon = null;
statusMessage = '';
Expand All @@ -302,8 +302,6 @@
lastReqRows = 0;
inputEnabled = false;
inputError = '';
sendEpoch++; // invalidate any keystrokes still queued for the old pane
sendChain = Promise.resolve();
}

onDestroy(teardown);
Expand Down
3 changes: 2 additions & 1 deletion frontend-svelte/src/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ function authRedirectTarget(payload) {
return `${path}?next=${encodeURIComponent(next || '/')}`;
}

export async function api(method, path, body) {
export async function api(method, path, body, { keepalive = false } = {}) {
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
keepalive,
};
if (body) opts.body = JSON.stringify(body);
const resp = await fetch(`${API}${path}`, opts);
Expand Down
80 changes: 80 additions & 0 deletions frontend-svelte/src/lib/paneInputSender.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
const SUBMIT_KEY = 'Enter';

function newClientId() {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
return `pane-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}

/**
* Deliver pane input immediately while preserving order at the daemon.
*
* Every request repeats all events that have not yet been acknowledged. This
* makes a later request (especially Enter) a cumulative retry of any slower
* text requests ahead of it. The daemon de-duplicates events by client/seq.
* Enter requests use fetch keepalive so a submission already initiated by the
* keypress may finish after the dashboard tab closes.
*/
export function createPaneInputSender({ send, onError = () => {}, onSuccess = () => {} }) {
const clientId = newClientId();
let nextSeq = 0;
let ackedSeq = 0;
let pending = [];
let uiActive = true;

function acceptAck(result) {
const ack = Number(result?.acked_seq || 0);
if (Number.isInteger(ack) && ack > ackedSeq) {
ackedSeq = ack;
pending = pending.filter((event) => event.seq > ackedSeq);
}
// A stale prefix acknowledgement must not clear a failure for a newer
// event (notably an exhausted Enter) that is still pending.
if (uiActive && pending.length === 0) onSuccess();
}

function transmit(events, keepalive, retriesLeft = 0) {
const requestMaxSeq = events.at(-1)?.seq || 0;
let request;
try {
// Call send synchronously: Enter must start its keepalive request in
// the key event handler, not from a promise queued behind text.
request = send({ client_id: clientId, events }, { keepalive });
} catch (error) {
request = Promise.reject(error);
}
return Promise.resolve(request)
.then(acceptAck)
.catch((error) => {
if (retriesLeft > 0) {
const retryEvents = pending.map((event) => ({ ...event }));
if (retryEvents.length) return transmit(retryEvents, true, retriesLeft - 1);
}
// A newer cumulative request may already have applied every
// event in this failed request. Its late rejection is stale,
// not a current send failure.
if (requestMaxSeq <= ackedSeq) return undefined;
if (uiActive) onError(error);
return undefined;
});
}

function enqueue(payload) {
const event = {
seq: ++nextSeq,
text: payload.text || '',
key: payload.key || '',
};
pending.push(event);
const events = pending.map((item) => ({ ...item }));
const submitting = event.key === SUBMIT_KEY;
void transmit(events, submitting, submitting ? 2 : 0);
}

function dispose() {
// Requests were started synchronously and target the pane captured by
// the component's send callback. Let them finish; only silence stale UI.
uiActive = false;
}

return { enqueue, dispose };
}
147 changes: 147 additions & 0 deletions frontend-svelte/src/lib/paneInputSender.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { createPaneInputSender } from './paneInputSender.js';

function deferred() {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}

test('rapid text then Enter starts a cumulative keepalive submit immediately', async () => {
const calls = [];
const requests = [];
const sender = createPaneInputSender({
send(body, options) {
const request = deferred();
calls.push({ body, options });
requests.push(request);
return request.promise;
},
});

sender.enqueue({ text: 'rapid' });
sender.enqueue({ key: 'Enter' });

assert.equal(calls.length, 2, 'Enter is not promise-chained behind text');
assert.equal(calls[0].options.keepalive, false);
assert.equal(calls[1].options.keepalive, true);
assert.deepEqual(calls[1].body.events, [
{ seq: 1, text: 'rapid', key: '' },
{ seq: 2, text: '', key: 'Enter' },
]);

sender.dispose();
requests[1].resolve({ acked_seq: 2 });
requests[0].resolve({ acked_seq: 1 });
await Promise.all(requests.map((request) => request.promise));
});

test('an out-of-order acknowledgement prunes cumulative retries monotonically', async () => {
const calls = [];
const requests = [];
const sender = createPaneInputSender({
send(body, options) {
const request = deferred();
calls.push({ body, options });
requests.push(request);
return request.promise;
},
});

sender.enqueue({ text: 'a' });
sender.enqueue({ text: 'b' });
requests[1].resolve({ acked_seq: 2 });
await requests[1].promise;
await Promise.resolve();

sender.enqueue({ key: 'Enter' });
assert.deepEqual(calls[2].body.events, [
{ seq: 3, text: '', key: 'Enter' },
]);

requests[0].resolve({ acked_seq: 1 });
requests[2].resolve({ acked_seq: 3 });
await Promise.all(requests.map((request) => request.promise));
});

test('a failed final submit retries its unacknowledged cumulative batch', async () => {
const calls = [];
const sender = createPaneInputSender({
send(body, options) {
calls.push({ body, options });
if (body.events.at(-1).key === 'Enter' && calls.length < 4) {
return Promise.reject(new Error('transient network failure'));
}
return Promise.resolve({ acked_seq: body.events.at(-1).seq });
},
});

sender.enqueue({ text: 'answer' });
await Promise.resolve();
sender.enqueue({ key: 'Enter' });
await new Promise((resolve) => setTimeout(resolve, 0));

const submits = calls.filter((call) => call.body.events.at(-1).key === 'Enter');
assert.equal(submits.length, 3);
assert.ok(submits.every((call) => call.options.keepalive));
});

test('a late old success cannot clear an exhausted Enter failure', async () => {
const oldText = deferred();
const callbacks = [];
let callCount = 0;
const sender = createPaneInputSender({
send(body) {
callCount += 1;
if (callCount === 1) return oldText.promise;
assert.equal(body.events.at(-1).key, 'Enter');
return Promise.reject(new Error('submit exhausted'));
},
onError: () => callbacks.push('error'),
onSuccess: () => callbacks.push('success'),
});

sender.enqueue({ text: 'visible' });
sender.enqueue({ key: 'Enter' });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(callCount, 4, 'original Enter plus two bounded retries');
assert.deepEqual(callbacks, ['error']);

oldText.resolve({ acked_seq: 1 });
await oldText.promise;
await Promise.resolve();
assert.deepEqual(callbacks, ['error']);
});

test('a late old failure is suppressed after a newer Enter succeeds', async () => {
const oldText = deferred();
const submit = deferred();
const callbacks = [];
let callCount = 0;
const sender = createPaneInputSender({
send() {
callCount += 1;
return callCount === 1 ? oldText.promise : submit.promise;
},
onError: () => callbacks.push('error'),
onSuccess: () => callbacks.push('success'),
});

sender.enqueue({ text: 'answer' });
sender.enqueue({ key: 'Enter' });
submit.resolve({ acked_seq: 2 });
await submit.promise;
await Promise.resolve();
assert.deepEqual(callbacks, ['success']);

oldText.reject(new Error('stale prefix failure'));
await Promise.resolve();
await Promise.resolve();
assert.deepEqual(callbacks, ['success']);
});
Loading
Loading