Merge pull request 'Reach the picker on a new socket, not a new page' (#411) from web-reconnect into session-switcher
tests / confined (mirror) (pull_request_target) Successful in 1m15s
tests / confined (proxy) (pull_request_target) Successful in 1m30s
tests / confined (providers) (pull_request_target) Successful in 2m15s
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / confined (launch) (pull_request_target) Successful in 2m42s
tests / confined (share) (pull_request_target) Successful in 2m29s
tests / confined (shellcheck) (pull_request_target) Successful in 1m46s
tests / runner-env (confined) (pull_request_target) Successful in 1m13s
tests / confined (remote) (pull_request_target) Successful in 4m34s
tests / confined (project) (pull_request_target) Successful in 5m21s
tests / privileged (pull_request_target) Successful in 6m0s
tests / confined (audit) (pull_request_target) Successful in 4m35s
tests / confined (static) (pull_request_target) Successful in 4m23s
tests / confined (config) (pull_request_target) Successful in 6m24s
tests / runner-env (privileged) (pull_request_target) Successful in 1m1s

Reviewed-on: #411
Reviewed-by: branderson <brad@branderson.io>
This commit was merged in pull request #411.
This commit is contained in:
2026-10-16 00:03:51 -07:00
11 changed files with 458 additions and 8 deletions
+4
View File
@@ -46,6 +46,10 @@ heading with the date it merges, and tags `main` with the same date.
- **Inside tmux the picker switches your client** to the session's own window
instead of attaching. tmux refuses to nest, so on your own machine the
picker could list every session and reach none of them.
- **The web front door's SESS button no longer reloads the page.** It opens a
new WebSocket instead, which is all ttyd needs to run a different command,
so the half a megabyte of inlined terminal is not fetched and re-run to
reach the picker. On a phone that was most of the wait.
### Changed
+1 -1
View File
@@ -101,7 +101,7 @@ why each is not a test:
| `forges/`, the forge guard | Open a pull request through each forge and read the body that arrived |
| any guard | Try the refused thing by the route an agent would take, not by the route the test takes |
| `bin/dejima-tty-suspend`, the launch | Ctrl-Z a real session and `fg` it: the tests use a stand-in for the container run |
| `web/` | Load it on a phone; nothing here can see a soft keyboard |
| `web/` | `cd web && node test/browser/sess-button.mjs`, then load it on a phone; nothing here can see a soft keyboard |
| `harnesses/` | Launch the harness against a real model |
## 5. The release pull request
+14
View File
@@ -1128,6 +1128,20 @@ Docker nothing, because the point of the number is not having to open
anything. What it gives up is noticing a session whose container died without
taking its launcher with it; every listing shows that one as gone.
**The SESS button does not reload the page.** ttyd takes the run id as a
WebSocket argument rather than as part of the page, so pressing it closes the
socket and opens another without that argument, which is `dejima-attach` with
no argument, which is the picker. The half a megabyte of inlined terminal
stays loaded and stays running; on a phone, fetching and re-running it was
most of the wait. Closing the socket is also what ends the session you are
leaving, exactly as navigating away used to: ttyd kills the command when its
connection goes.
The terminal is reset on the way, and the mode tracker with it. What the
socket carries away is the process, not the state it left behind in the
terminal - an alternate screen, mouse reporting, a cursor key mode - and the
picker would otherwise open into whatever the session had switched on.
**Inside tmux, the picker switches your client to the session's own window**
rather than attaching. tmux refuses to nest a session inside one of its own
panes, so on your own machine the picker would otherwise list every session
+18
View File
@@ -48,6 +48,24 @@ so they cannot quietly stop running.
The rest — gestures, the viewport manager, the toolbar — needs a real browser
and a real phone. `docs/remote-access.md` has the manual checklist.
One of them is automated, against a real browser and a mock ttyd:
```sh
node test/browser/sess-button.mjs
```
It drives the built page in Chromium and proves the thing the unit tests
cannot: that SESS reaches the picker on a new WebSocket, without the page
being fetched or run a second time. Three seconds, and it needs Playwright,
which a dejima sandbox has and this package deliberately does not depend on -
a browser is a strange thing to install to inline two files.
Deliberately not part of `npm test`, which CI runs in a `node:20-bookworm`
container with no browser in it — a test that cannot run there would have to
be skipped, and a skipped test is one nobody is reading. What keeps it from
rotting instead is [docs/release.md](../docs/release.md), whose manual list
names it for any release that touches `web/`.
## Layout
| File | Role |
+65 -3
View File
@@ -14,7 +14,7 @@
no path a /favicon.ico could be fetched from, so a link to one would 404 and
the tab would fall back to the generic icon. -->
<link rel="icon" type="image/png" href="data:image/png;base64,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">
<!-- Generated by web/build.mjs - do not edit. Sources: 0cc964e9a08f82488c268b4e95b19542a42c5ada3b927e7fac62f7acc46bde94 -->
<!-- Generated by web/build.mjs - do not edit. Sources: c43f6331189abbbe08955f8548a631132008ba6051a6274088be6f85c0f7d2a4 -->
<style>
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
@@ -1011,6 +1011,7 @@ body.mono-keys {
// onStatus(string), size() -> {cols, rows}}
function createClient(opts) {
var urls = endpoints(opts.location);
var location = opts.location;
var encoder = new TextEncoder();
var decoder = new TextDecoder();
var socket = null;
@@ -1108,9 +1109,45 @@ body.mono-keys {
};
}
// Point the connection at a different ttyd argument. ttyd reads --url-arg
// off the WebSocket request rather than off the page load, so a different
// command is a different socket and nothing else: the half a megabyte of
// terminal this page is does not have to be fetched, parsed and run again
// to reach one.
//
// The old socket's handlers are detached before it is closed, rather than
// a flag being read inside them. onclose is delivered asynchronously, so a
// handler left attached can arrive after the replacement socket exists and
// null the field holding it - and the reconnect it would schedule is for
// the url this call just replaced.
function switchTo(search) {
urls = endpoints({
protocol: location.protocol,
host: location.host,
pathname: location.pathname,
search: search
});
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
backoff = RECONNECT_MIN_MS;
if (socket) {
socket.onclose = null;
socket.onmessage = null;
socket.onerror = null;
socket.close();
socket = null;
}
connect();
}
return {
connect: connect,
urls: urls,
switchTo: switchTo,
// A function, not the object: switchTo replaces it, and a copy taken
// here would answer for the connection this client used to have.
urls: function () { return urls; },
isOpen: open,
sendInput: sendInput,
sendBinary: sendBinary,
@@ -1879,6 +1916,31 @@ body.mono-keys {
}
});
// The SESS button: back to dejima-attach with no run id, which is the
// picker. A new socket rather than a new page - ttyd takes the run id as a
// WebSocket argument, so the command changes without the half a megabyte of
// inlined terminal being fetched, parsed and run a second time. On a phone
// that was most of the wait.
//
// The terminal is reset first, and the mode tracker with it. What the socket
// carries away is the process, not the state it left in here: an alternate
// screen, mouse reporting, a cursor key mode. The picker would otherwise
// open into whatever the session it replaced had switched on, and the
// gestures would still be sending mouse reports to something that is not
// asking for them.
//
// replaceState rather than a history entry: the address bar has to stop
// saying ?arg=<run-id> or a reload would reopen the session just left, and
// going "back" to a session that ended is not a thing to offer.
function goToPicker(search) {
term.reset();
mouse.modes.clear();
if (window.history && window.history.replaceState) {
window.history.replaceState(null, '', window.location.pathname + search);
}
client.switchTo(search);
}
// Sticky Ctrl is applied here rather than in attachCustomKeyEventHandler
// because the target is a phone: soft keyboards overwhelmingly report
// keydown as "Unidentified" and deliver the real character through an input
@@ -1938,7 +2000,7 @@ body.mono-keys {
prefix: function () { return prefixByte; },
notify: notify,
search: function () { return window.location.search; },
navigate: function (search) { window.location.search = search; },
navigate: goToPicker,
allowKeyboard: keyboard.allow
});
toolbar.attach();
+2 -2
View File
@@ -5,12 +5,12 @@ f6f6611d0b3eacc39d10add9c4bbc56d4b3a70a47f022351a1c6eab68cd66e71 package-lock.j
f690fd9d35975bc977e3d323595a2a4aa1fca6ed50f213602f08adec3f5b2c92 src/gestures.js
e75dc9d5e311a3d9bf9dc373b651ad506afbd7a7cc7ad54aac6fdad94276b0a4 src/keyboard.js
2a070f63702b95b0c62963ec6b9ec4fa2b0b89b7553a8a889d68cdf3506f9ce3 src/keys.js
e5888ba8413fc2b442b44cade378f7393fd231dd5f79d44c58d1bc217ed7c807 src/main.js
05bc2e446c3baa57422c8fa036939a0df8cf35ac0cf02bb35233befafae5667f src/main.js
e3a550e2249b536c9bc3d90ee2735a9a2d8e6f772a6a631f724d289b96b1f557 src/modes.js
8446be09a1eca041a5a2e2068fdace32596713aef1d1fb74b522ea4eacc50ec3 src/mouse.js
cb5e33c0fa117176d9073bd9019f78b65bfe5535833bf1765ee542c700d67f1f src/osc52.js
94229c981f6287162d6ef774f71ef41d941494595f76f16bb742811ca32a767f src/page.css
e02823ce9d5d57615a0a8c0c7b91611932202758048b7eadabe4f1054aaf1a9f src/protocol.js
2490ec55a050d993364a62f590b51e63945ea5b1276832821d6d1d0854fd5c81 src/protocol.js
aac82e88c6a65617818c6589091cbb8c3bab50e82e98aabf8b583cbb3dfda7e7 src/sgr.js
34e34a8d258b8a7d2afde03287681b99d839994936c398ba5c7165a3ed7aec93 src/template.html
3c8e5520b694693126097d9fe17f89ff23ba15adb8532521c647907edba20215 src/toolbar.js
+26 -1
View File
@@ -139,6 +139,31 @@
}
});
// The SESS button: back to dejima-attach with no run id, which is the
// picker. A new socket rather than a new page - ttyd takes the run id as a
// WebSocket argument, so the command changes without the half a megabyte of
// inlined terminal being fetched, parsed and run a second time. On a phone
// that was most of the wait.
//
// The terminal is reset first, and the mode tracker with it. What the socket
// carries away is the process, not the state it left in here: an alternate
// screen, mouse reporting, a cursor key mode. The picker would otherwise
// open into whatever the session it replaced had switched on, and the
// gestures would still be sending mouse reports to something that is not
// asking for them.
//
// replaceState rather than a history entry: the address bar has to stop
// saying ?arg=<run-id> or a reload would reopen the session just left, and
// going "back" to a session that ended is not a thing to offer.
function goToPicker(search) {
term.reset();
mouse.modes.clear();
if (window.history && window.history.replaceState) {
window.history.replaceState(null, '', window.location.pathname + search);
}
client.switchTo(search);
}
// Sticky Ctrl is applied here rather than in attachCustomKeyEventHandler
// because the target is a phone: soft keyboards overwhelmingly report
// keydown as "Unidentified" and deliver the real character through an input
@@ -198,7 +223,7 @@
prefix: function () { return prefixByte; },
notify: notify,
search: function () { return window.location.search; },
navigate: function (search) { window.location.search = search; },
navigate: goToPicker,
allowKeyboard: keyboard.allow
});
toolbar.attach();
+38 -1
View File
@@ -41,6 +41,7 @@
// onStatus(string), size() -> {cols, rows}}
function createClient(opts) {
var urls = endpoints(opts.location);
var location = opts.location;
var encoder = new TextEncoder();
var decoder = new TextDecoder();
var socket = null;
@@ -138,9 +139,45 @@
};
}
// Point the connection at a different ttyd argument. ttyd reads --url-arg
// off the WebSocket request rather than off the page load, so a different
// command is a different socket and nothing else: the half a megabyte of
// terminal this page is does not have to be fetched, parsed and run again
// to reach one.
//
// The old socket's handlers are detached before it is closed, rather than
// a flag being read inside them. onclose is delivered asynchronously, so a
// handler left attached can arrive after the replacement socket exists and
// null the field holding it - and the reconnect it would schedule is for
// the url this call just replaced.
function switchTo(search) {
urls = endpoints({
protocol: location.protocol,
host: location.host,
pathname: location.pathname,
search: search
});
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
backoff = RECONNECT_MIN_MS;
if (socket) {
socket.onclose = null;
socket.onmessage = null;
socket.onerror = null;
socket.close();
socket = null;
}
connect();
}
return {
connect: connect,
urls: urls,
switchTo: switchTo,
// A function, not the object: switchTo replaces it, and a copy taken
// here would answer for the connection this client used to have.
urls: function () { return urls; },
isOpen: open,
sendInput: sendInput,
sendBinary: sendBinary,
+115
View File
@@ -0,0 +1,115 @@
// Enough of ttyd to drive the real page: the three paths its HTTP handler
// answers, and a WebSocket that spawns nothing but speaks the frame protocol
// src/protocol.js was written against.
//
// Hand-rolled rather than a dependency, because web/ ships with two and both
// are the terminal itself. The frames here are the ones this page sends and
// receives and no others: a text handshake, masked client frames under 64KB,
// and unmasked server frames.
import http from 'node:http';
import crypto from 'node:crypto';
import fs from 'node:fs';
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
function accept(key) {
return crypto.createHash('sha1').update(key + GUID).digest('base64');
}
// A server frame: FIN set, binary opcode, no mask, two length forms.
function frame(payload) {
const len = payload.length;
const head = len < 126 ? Buffer.from([0x82, len])
: Buffer.concat([Buffer.from([0x82, 126]), Buffer.from([len >> 8, len & 0xff])]);
return Buffer.concat([head, payload]);
}
// One client frame off the front of a buffer, or null when it is not all here
// yet. Only the opcodes this page sends: text, binary and close.
function unframe(buf) {
if (buf.length < 2) return null;
const masked = (buf[1] & 0x80) !== 0;
let len = buf[1] & 0x7f;
let off = 2;
if (len === 126) {
if (buf.length < 4) return null;
len = buf.readUInt16BE(2);
off = 4;
}
const maskLen = masked ? 4 : 0;
if (buf.length < off + maskLen + len) return null;
const mask = masked ? buf.subarray(off, off + 4) : null;
const body = Buffer.from(buf.subarray(off + maskLen, off + maskLen + len));
if (mask) for (let i = 0; i < body.length; i++) body[i] ^= mask[i % 4];
return { opcode: buf[0] & 0x0f, body, rest: buf.subarray(off + maskLen + len) };
}
// opts.onConnect(url) -> the text this "session" prints. Returning different
// text for the picker and for a run id is what lets a test tell which command
// ttyd would have run.
export async function startMockTtyd(indexPath, opts) {
const index = fs.readFileSync(indexPath);
const pageLoads = [];
const sockets = [];
const server = http.createServer((req, res) => {
const path = req.url.split('?')[0];
if (path === '/token') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ token: '' }));
return;
}
if (path === '/' || path === '/index.html') {
pageLoads.push(req.url);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(index);
return;
}
res.writeHead(404);
res.end();
});
server.on('upgrade', (req, socket) => {
sockets.push({ url: req.url, closed: false, input: '' });
const mine = sockets[sockets.length - 1];
socket.write(
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\nConnection: Upgrade\r\n' +
'Sec-WebSocket-Protocol: tty\r\n' +
'Sec-WebSocket-Accept: ' + accept(req.headers['sec-websocket-key']) + '\r\n\r\n');
let buf = Buffer.alloc(0);
let spawned = false;
socket.on('data', (chunk) => {
buf = Buffer.concat([buf, chunk]);
for (;;) {
const f = unframe(buf);
if (!f) break;
buf = f.rest;
if (f.opcode === 0x8) { socket.end(); return; }
// An input frame is '0' and then the bytes. Recorded so a test can see
// that what is typed after a switch reaches the new command rather
// than the socket that was replaced.
if (spawned && f.body.length > 1 && f.body[0] === 0x30) {
mine.input += f.body.subarray(1).toString();
}
// ttyd spawns the command when the first message - the one carrying
// the token and the size - arrives, and not before.
if (!spawned) {
spawned = true;
socket.write(frame(Buffer.concat([
Buffer.from('0'), Buffer.from(opts.onConnect(mine.url))
])));
}
}
});
socket.on('close', () => { mine.closed = true; });
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
return {
url: `http://127.0.0.1:${server.address().port}/`,
pageLoads,
sockets,
stop: () => new Promise((r) => server.close(r))
};
}
+94
View File
@@ -0,0 +1,94 @@
// What the SESS button does, against the real page in a real browser.
//
// Not part of `npm test`: it needs Chromium, which the CI image does not have
// and which `node --test` would fail on rather than skip. Run it by hand, on a
// machine with Playwright:
//
// node test/browser/sess-button.mjs
//
// It proves the one thing the unit tests cannot: that pressing SESS reaches
// the picker without the page being fetched and parsed a second time.
import { startMockTtyd } from './mock-ttyd.mjs';
// Playwright is not a dependency of this package and is deliberately not
// added as one: it would pull a browser into an install that exists to inline
// two files. Resolved where it is rather than where this happens to have been
// run - a dejima sandbox carries it at a fixed path, a host that has it has it
// on the module path.
async function loadChromium() {
for (const spec of ['playwright', '/opt/node-tools/lib/node_modules/playwright/index.mjs']) {
try {
return (await import(spec)).chromium;
} catch (e) { /* the next one, or the message below */ }
}
console.error('This check needs Playwright, which is not a dependency here.');
console.error('Run it inside a dejima sandbox, which has one, or install it:');
console.error(' npm i -g playwright && npx playwright install chromium');
process.exit(2);
}
const chromium = await loadChromium();
const RUN_ID = 'deadbeefdeadbeef';
const checks = [];
function check(name, ok, detail) {
checks.push({ name, ok, detail });
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${ok || !detail ? '' : ' <- ' + detail}`);
}
const ttyd = await startMockTtyd(new URL('../../index.html', import.meta.url).pathname, {
onConnect: (url) => (url.includes('arg=' + RUN_ID)
? 'SESSION ' + RUN_ID + ' attached\r\n'
: 'dejima | live sessions\r\n')
});
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(`${ttyd.url}?toolbar=1&arg=${RUN_ID}`);
await page.waitForFunction((id) => document.body.innerText.includes('SESSION ' + id), RUN_ID);
check('the run id reaches ttyd as a WebSocket argument',
ttyd.sockets.length === 1 && ttyd.sockets[0].url.includes('arg=' + RUN_ID),
JSON.stringify(ttyd.sockets.map((s) => s.url)));
const loadsBefore = ttyd.pageLoads.length;
// A marker on the window as well as the server's count: a reload served from
// the browser's cache would never reach the server, and this page is one big
// cacheable file.
await page.evaluate(() => { window.__survivedTheSwitch = true; });
await page.click('[data-action="sessions"]');
await page.waitForFunction(() => document.body.innerText.includes('live sessions'));
check('the page itself was never re-run',
await page.evaluate(() => window.__survivedTheSwitch === true));
check('the picker arrives without the page being fetched again',
ttyd.pageLoads.length === loadsBefore,
`${ttyd.pageLoads.length} loads, was ${loadsBefore}`);
check('a second socket is opened, with no arg on it',
ttyd.sockets.length === 2 && !ttyd.sockets[1].url.includes('arg='),
JSON.stringify(ttyd.sockets.map((s) => s.url)));
check('the first socket is closed, which is what ends the session left behind',
ttyd.sockets[0].closed === true);
check('the address bar stops naming the session, so a reload lands on the picker',
!(await page.evaluate(() => window.location.search)).includes('arg='),
await page.evaluate(() => window.location.search));
// The session's own output must not still be on screen under the picker: the
// socket carries away the process, not what it left in the terminal.
// Typing has to reach the command the new socket spawned. The terminal's
// handlers were wired to the client once, at startup, and the client is now
// holding a different socket.
await page.click('#terminal');
await page.keyboard.type('2');
await page.waitForFunction(() => true);
await new Promise((r) => setTimeout(r, 200));
check('what is typed after the switch reaches the new socket',
ttyd.sockets[1].input.includes('2') && !ttyd.sockets[0].input.includes('2'),
JSON.stringify(ttyd.sockets.map((s) => s.input)));
check('the terminal is reset, so the picker is not drawn over the session',
!(await page.evaluate(() => document.body.innerText)).includes('SESSION ' + RUN_ID));
await browser.close();
await ttyd.stop();
const failed = checks.filter((c) => !c.ok).length;
console.log(`\n${checks.length - failed}/${checks.length} checks passed`);
process.exit(failed === 0 ? 0 : 1);
+81
View File
@@ -44,3 +44,84 @@ test("the query string rides along, because ttyd's --url-arg reads it off the We
'wss://h/ws'
);
});
// A WebSocket and a fetch just real enough for createClient: every instance is
// recorded, so a test can see which url was opened and what was done to the
// one before it.
function stubTransport() {
const sockets = [];
globalThis.WebSocket = function (url, protocols) {
this.url = url;
this.protocols = protocols;
this.readyState = 0;
this.closed = false;
this.sent = [];
this.send = function (data) { this.sent.push(data); };
this.close = function () { this.closed = true; if (this.onclose) this.onclose(); };
sockets.push(this);
};
globalThis.WebSocket.OPEN = 1;
globalThis.fetch = () => Promise.resolve({ json: () => ({ token: 't' }) });
return sockets;
}
function client(search, statuses) {
return globalThis.DEJIMA.protocol.createClient({
location: { protocol: 'http:', host: 'h', pathname: '/', search },
size: () => ({ cols: 80, rows: 24 }),
onOutput() {}, onTitle() {}, onPreferences() {},
onStatus: (s) => statuses.push(s)
});
}
test('switchTo opens a new socket for a different arg, and keeps the page', async () => {
const sockets = stubTransport();
const statuses = [];
const c = client('?arg=0123456789abcdef', statuses);
c.connect();
await Promise.resolve(); await Promise.resolve();
assert.equal(sockets.length, 1);
assert.equal(sockets[0].url, 'ws://h/ws?arg=0123456789abcdef');
c.switchTo('');
await Promise.resolve(); await Promise.resolve();
// The old one was closed - ttyd kills the command when the socket goes, so
// this is what ends the session being left - and a second one opened on the
// url with no arg, which is the picker.
assert.equal(sockets[0].closed, true);
assert.equal(sockets.length, 2);
assert.equal(sockets[1].url, 'ws://h/ws');
assert.equal(c.urls().ws, 'ws://h/ws');
});
test('a switch is not reported as the session dropping', async () => {
const sockets = stubTransport();
const statuses = [];
const c = client('?arg=0123456789abcdef', statuses);
c.connect();
await Promise.resolve(); await Promise.resolve();
statuses.length = 0;
c.switchTo('');
await Promise.resolve(); await Promise.resolve();
// "closed" is what puts "Disconnected. Reconnecting..." on the screen and
// starts the backoff. A switch is neither.
assert.deepEqual(statuses, ['connecting']);
});
test('a switch while disconnected drops the pending reconnect', async () => {
const sockets = stubTransport();
const statuses = [];
const c = client('?arg=0123456789abcdef', statuses);
c.connect();
await Promise.resolve(); await Promise.resolve();
sockets[0].onclose(); // the session ended; a retry is now queued
const queued = sockets.length;
c.switchTo('');
await Promise.resolve(); await Promise.resolve();
assert.equal(sockets.length, queued + 1);
assert.equal(sockets[sockets.length - 1].url, 'ws://h/ws');
// And the queued retry must not arrive afterwards and reopen the session
// that was just left.
await new Promise((r) => setTimeout(r, 700));
assert.equal(sockets.length, queued + 1);
});