Skip to content

Commit 8046ba9

Browse files
authored
Stream effective PTY geometry to clients (#135)
1 parent c23812e commit 8046ba9

13 files changed

Lines changed: 494 additions & 19 deletions

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

33
## Unreleased
44

5+
### Stream-ordered effective geometry for embedded clients
6+
7+
- Writable clients still select the shared PTY grid by independent row/column
8+
minima, and zero viewers still preserve the last size. The daemon now sends
9+
a framed `GEOMETRY` packet to writable and read-only output streams before
10+
every affected `SCREEN`/`DATA`, including peer attach, resize, and
11+
disconnect changes.
12+
- `SessionConnection` exposes `effectiveRows`/`effectiveCols` and a `geometry`
13+
event. `attachPty()` and server-mode testing sessions resize their local
14+
xterm grid from the same stream event before parsing later output. Older
15+
clients safely ignore the bounded unknown packet and retain raw-byte
16+
behavior.
17+
518
### Read-only session listing
619

720
- `listSessions()` and `pty list` are now strictly observational: they no

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,11 +406,15 @@ const stats = await queryStats("myserver");
406406

407407
```typescript
408408
const conn = new SessionConnection({ name: "myserver", rows: 24, cols: 80 });
409-
const initialScreen = await conn.connect();
410409

410+
conn.on("geometry", ({ rows, cols }) => myTerminalView.resize(cols, rows));
411411
conn.on("data", (data) => myTerminalView.write(data));
412412
conn.on("exit", (code) => console.log(`Exited: ${code}`));
413413

414+
const initialScreen = await conn.connect();
415+
myTerminalView.resize(conn.effectiveCols, conn.effectiveRows);
416+
myTerminalView.write(initialScreen);
417+
414418
conn.write("hello\r");
415419
conn.press("ctrl+c");
416420
conn.resize(30, 100);

docs/client.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,25 +186,38 @@ Bidirectional, event-driven connection to a session.
186186

187187
```typescript
188188
const conn = new SessionConnection({ name: "myserver", rows: 24, cols: 80 });
189-
const initialScreen = await conn.connect();
190189

190+
conn.on("geometry", ({ rows, cols }) => {
191+
// Resize your emulator before the following screen/data bytes are parsed.
192+
terminal.resize(cols, rows);
193+
});
191194
conn.on("data", (data: string) => { /* terminal output */ });
192195
conn.on("exit", (code: number) => { /* process exited */ });
193196
conn.on("close", () => { /* connection closed */ });
194197
conn.on("error", (err: Error) => { /* connection error */ });
195198

199+
const initialScreen = await conn.connect();
200+
// Initial GEOMETRY is stream-ordered before SCREEN. The effective getters are
201+
// therefore authoritative before applying the returned replay.
202+
terminal.resize(conn.effectiveCols, conn.effectiveRows);
203+
terminal.write(initialScreen);
204+
196205
conn.write("hello\r"); // send raw data
197206
conn.press("ctrl+c"); // send named key
198-
conn.resize(30, 100); // resize terminal
207+
conn.resize(30, 100); // request a shared-grid size
199208
conn.disconnect(); // close connection
200209
```
201210

202211
**Properties:**
203212
- `connected: boolean` — whether the connection is active
213+
- `effectiveRows: number` / `effectiveCols: number` — current authoritative
214+
shared-grid dimensions. These can differ from the client's requested size
215+
when another writable client is smaller.
204216

205217
**Events:**
206218
| Event | Payload | Description |
207219
|---|---|---|
220+
| `geometry` | `{ rows, cols }` | Effective shared geometry, ordered before affected `screen`/`data` |
208221
| `data` | `string` | Terminal output from the session |
209222
| `screen` | `string` | Initial screen replay on connect |
210223
| `exit` | `number` | Session process exited with code |
@@ -384,9 +397,15 @@ const MessageType = {
384397
SCREEN: 5, // Screen replay
385398
PEEK: 6, // Read-only peek request
386399
STATUS: 7, // Stats query/response
400+
GEOMETRY: 10, // Effective shared rows/cols (server → client)
387401
};
388402
```
389403

404+
Packet types are length-delimited. Clients predating `GEOMETRY` ignore the
405+
unknown bounded packet and continue with following `SCREEN`/`DATA`, preserving
406+
their historical raw-byte behavior. Embedders that reconstruct a terminal grid
407+
must handle `GEOMETRY`.
408+
390409
### `TERMINAL_SANITIZE: string`
391410

392411
ANSI sequence that resets all terminal modes (mouse tracking, cursor visibility, alternate screen, etc.). Useful after disconnecting from a session.

docs/testing.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,11 @@ Resize the terminal dimensions:
335335
session.resize(40, 120);
336336
```
337337
338+
In server mode this requests a size from the daemon. `session.rows` and
339+
`session.cols` update when the daemon reports the effective min-wins geometry;
340+
another smaller writable client can keep the effective grid below the requested
341+
size. Geometry is applied before affected screen/output bytes are parsed.
342+
338343
### connectToExisting(session)
339344
340345
Create a second client attached to the same server process:
@@ -356,7 +361,7 @@ await session2.waitForText("shared");
356361
- `session.hasExited` — whether the process has exited (always `false` for spawn-mode)
357362
- `session.name` — the session name (server-mode only)
358363
- `session.server` — the underlying `PtyServer` instance (server-mode only)
359-
- `session.rows` / `session.cols` — current terminal dimensions
364+
- `session.rows` / `session.cols` — current effective terminal dimensions
360365
361366
## Running Tests
362367

src/connection.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
encodeDetach,
99
encodePeek,
1010
encodeResize,
11+
decodeGeometry,
1112
decodeExit,
1213
} from "./protocol.ts";
1314
import { getSocketPath } from "./sessions.ts";
@@ -47,6 +48,7 @@ export interface PeekScreenOptions {
4748
*
4849
* Events:
4950
* - 'data' (data: string) — terminal output from the session
51+
* - 'geometry' ({ rows, cols }) — effective shared grid before affected output
5052
* - 'screen' (screen: string) — initial screen replay on connect
5153
* - 'exit' (code: number) — session process exited
5254
* - 'close' () — connection closed
@@ -57,16 +59,28 @@ export class SessionConnection extends EventEmitter {
5759
private reader = new PacketReader();
5860
private _connected = false;
5961
private options: SessionConnectionOptions;
62+
private _effectiveRows: number;
63+
private _effectiveCols: number;
6064

6165
constructor(options: SessionConnectionOptions) {
6266
super();
6367
this.options = options;
68+
this._effectiveRows = options.rows;
69+
this._effectiveCols = options.cols;
6470
}
6571

6672
get connected(): boolean {
6773
return this._connected;
6874
}
6975

76+
get effectiveRows(): number {
77+
return this._effectiveRows;
78+
}
79+
80+
get effectiveCols(): number {
81+
return this._effectiveCols;
82+
}
83+
7084
connect(): Promise<string> {
7185
return new Promise((resolve, reject) => {
7286
const socketPath = getSocketPath(this.options.name);
@@ -88,6 +102,13 @@ export class SessionConnection extends EventEmitter {
88102
}
89103
for (const packet of packets) {
90104
switch (packet.type) {
105+
case MessageType.GEOMETRY: {
106+
const geometry = decodeGeometry(packet.payload);
107+
this._effectiveRows = geometry.rows;
108+
this._effectiveCols = geometry.cols;
109+
this.emit("geometry", geometry);
110+
break;
111+
}
91112
case MessageType.SCREEN: {
92113
const screen = packet.payload.toString();
93114
if (!initialScreenResolved) {
@@ -147,6 +168,8 @@ export class SessionConnection extends EventEmitter {
147168

148169
resize(rows: number, cols: number): void {
149170
if (!this.socket || !this._connected) return;
171+
this.options.rows = rows;
172+
this.options.cols = cols;
150173
this.socket.write(encodeResize(rows, cols));
151174
}
152175

src/protocol.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export const MessageType = {
99
SCREEN: 5, // Server → Client: screen buffer replay on attach
1010
PEEK: 6, // Client → Server: read-only attach (no input, no resize)
1111
STATUS: 7, // Client → Server: request stats; Server → Client: JSON stats response
12+
// Values 8 and 9 are reserved for independent protocol extensions.
13+
GEOMETRY: 10, // Server → Client: effective shared rows/cols
1214
} as const;
1315

1416
export type MessageType = (typeof MessageType)[keyof typeof MessageType];
@@ -69,6 +71,13 @@ export function encodeResize(rows: number, cols: number): Buffer {
6971
return encodePacket(MessageType.RESIZE, payload);
7072
}
7173

74+
export function encodeGeometry(rows: number, cols: number): Buffer {
75+
const payload = Buffer.alloc(4);
76+
payload.writeUInt16BE(rows, 0);
77+
payload.writeUInt16BE(cols, 2);
78+
return encodePacket(MessageType.GEOMETRY, payload);
79+
}
80+
7281
export function encodeExit(code: number): Buffer {
7382
const payload = Buffer.alloc(4);
7483
payload.writeInt32BE(code, 0);
@@ -104,6 +113,10 @@ export function decodeSize(payload: Buffer): { rows: number; cols: number } {
104113
};
105114
}
106115

116+
export function decodeGeometry(payload: Buffer): { rows: number; cols: number } {
117+
return decodeSize(payload);
118+
}
119+
107120
export function decodeExit(payload: Buffer): number {
108121
if (payload.length < 4) {
109122
return -1;

src/server.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
encodeExit,
1717
encodeScreen,
1818
encodeStatusResponse,
19+
encodeGeometry,
1920
decodeSize,
2021
} from "./protocol.ts";
2122
import {
@@ -628,6 +629,9 @@ export class PtyServer {
628629
client.cols = size.cols;
629630
client.attachSeq = ++this.attachCounter;
630631
const resized = this.negotiateSize();
632+
if (!resized) {
633+
socket.write(encodeGeometry(this.terminal.rows, this.terminal.cols));
634+
}
631635
// Stamp the last-attach timestamp so `pty gc --idle-days N`
632636
// (and per-session `strategy.idle-days=N` tags) can detect
633637
// abandonment. Best-effort — if the metadata file was
@@ -686,6 +690,7 @@ export class PtyServer {
686690

687691
case MessageType.PEEK: {
688692
client.readonly = true;
693+
socket.write(encodeGeometry(this.terminal.rows, this.terminal.cols));
689694
const flags = packet.payload.length > 0 ? packet.payload.readUInt8(0) : 0;
690695
const plain = (flags & 1) !== 0;
691696
const full = (flags & 2) !== 0;
@@ -837,15 +842,25 @@ export class PtyServer {
837842

838843
if (rows > 0 && cols > 0) {
839844
if (rows !== this.terminal.rows || cols !== this.terminal.cols) {
840-
this.ptyProcess.resize(cols, rows);
841845
this.terminal.resize(cols, rows);
846+
this.broadcastGeometry(rows, cols);
847+
this.ptyProcess.resize(cols, rows);
842848
this.lastResizeTime = Date.now();
843849
return true;
844850
}
845851
}
846852
return false;
847853
}
848854

855+
private broadcastGeometry(rows: number, cols: number): void {
856+
const packet = encodeGeometry(rows, cols);
857+
for (const client of this.clients.values()) {
858+
if (client.attachSeq > 0 || client.readonly) {
859+
client.socket.write(packet);
860+
}
861+
}
862+
}
863+
849864
/** Briefly resize the PTY by 1 column and back to trigger SIGWINCH,
850865
* forcing the child to do a complete redraw. The xterm-headless terminal
851866
* is resized in sync so its buffer stays correct. */

src/testing/session.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
encodeAttach,
1212
encodeData,
1313
encodeResize,
14+
decodeGeometry,
1415
} from "../protocol.ts";
1516
import { getSocketPath } from "../sessions.ts";
1617
import { resolveKey } from "../keys.ts";
@@ -64,6 +65,8 @@ export class Session {
6465
private backend: Backend;
6566
private _rows: number;
6667
private _cols: number;
68+
private _requestedRows: number;
69+
private _requestedCols: number;
6770

6871
private constructor(
6972
terminal: Terminal,
@@ -77,6 +80,8 @@ export class Session {
7780
this.backend = backend;
7881
this._rows = rows;
7982
this._cols = cols;
83+
this._requestedRows = rows;
84+
this._requestedCols = cols;
8085
}
8186

8287
// ── Factories ──
@@ -190,8 +195,8 @@ export class Session {
190195
throw new Error("connectToExisting() requires a server-mode session");
191196
}
192197

193-
const rows = opts.rows ?? existing._rows;
194-
const cols = opts.cols ?? existing._cols;
198+
const rows = opts.rows ?? existing._requestedRows;
199+
const cols = opts.cols ?? existing._requestedCols;
195200

196201
const terminal = new xterm.Terminal({
197202
rows,
@@ -357,7 +362,7 @@ export class Session {
357362
resolve();
358363
});
359364
});
360-
backend.socket.write(encodeAttach(this._rows, this._cols));
365+
backend.socket.write(encodeAttach(this._requestedRows, this._requestedCols));
361366
await screenPromise;
362367
}
363368

@@ -378,10 +383,9 @@ export class Session {
378383
if (this.backend.kind !== "server") {
379384
throw new Error("resize() is only available in server mode");
380385
}
381-
this._rows = rows;
382-
this._cols = cols;
386+
this._requestedRows = rows;
387+
this._requestedCols = cols;
383388
this.backend.socket.write(encodeResize(rows, cols));
384-
this.terminal.resize(cols, rows);
385389
}
386390

387391
// ── Lifecycle ──
@@ -426,6 +430,13 @@ export class Session {
426430
}
427431
for (const packet of packets) {
428432
switch (packet.type) {
433+
case MessageType.GEOMETRY: {
434+
const geometry = decodeGeometry(packet.payload);
435+
this._rows = geometry.rows;
436+
this._cols = geometry.cols;
437+
this.terminal.resize(geometry.cols, geometry.rows);
438+
break;
439+
}
429440
case MessageType.SCREEN:
430441
this.terminal.reset();
431442
this.terminal.write(packet.payload.toString(), () => {

0 commit comments

Comments
 (0)