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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
synchronization, so a re-attach or writable-to-readonly mode switch cannot
emit the previous mode's stale screen or queued output. Reconnects establish
the same fresh `GEOMETRY` → `SCREEN` → `DATA`/`EXIT` baseline.
- Valid `ATTACH` and `PEEK` messages now explicitly replace the socket's client
role. In particular, attaching after a read-only peek restores input, resize,
and shared-grid geometry participation; malformed attaches preserve the
existing role and synchronization generation.

### Atomic exact-id metadata patching

Expand Down
7 changes: 7 additions & 0 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,13 @@ including writable-to-readonly mode changes, so stale screen or queued output
from the previous mode is not emitted. A reconnect starts the same ordering
contract again with a fresh `GEOMETRY` and `SCREEN`.

Each valid `ATTACH` or `PEEK` also replaces the socket's current role rather
than accumulating state. `ATTACH` makes the socket writable, installs its
requested geometry, and restores `DATA`/`RESIZE` handling and shared-grid
participation. `PEEK` makes it read-only and removes its geometry constraint.
A malformed `ATTACH` payload leaves the prior role and synchronization
generation unchanged.

### `TERMINAL_SANITIZE: string`

ANSI sequence that resets all terminal modes (mouse tracking, cursor visibility, alternate screen, etc.). Useful after disconnecting from a session.
1 change: 1 addition & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,7 @@ export class PtyServer {
// to its own size, which would then look like it had matched.
const sizeMatched =
size.rows === this.terminal.rows && size.cols === this.terminal.cols;
client.readonly = false;
client.rows = size.rows;
client.cols = size.cols;
client.attachSeq = ++this.attachCounter;
Expand Down
168 changes: 168 additions & 0 deletions tests/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
encodeData,
encodeDetach,
encodeExit,
encodePacket,
encodePeek,
encodeResize,
encodeStatus,
Expand Down Expand Up @@ -850,6 +851,173 @@ describe("integration", () => {
liveClient.destroy();
});

it("replaces a same-socket PEEK role with ATTACH", async () => {
const name = uniqueName();
await startServer(name, "cat");

const client = await connect(name);
const packets = recordPackets(client);
client.write(encodePeek());
await packets.waitFor((received) =>
received.some((packet) => packet.type === MessageType.SCREEN)
);

client.write(encodeAttach(20, 70));
await packets.waitFor(
(received) =>
received.filter((packet) => packet.type === MessageType.SCREEN).length === 2
);
client.write(encodeData("writable-again\n"));
await packets.waitFor((received) =>
received.some(
(packet) =>
packet.type === MessageType.DATA &&
packet.payload.toString().includes("writable-again")
)
);

const statsClient = await connect(name);
const statsReader = new PacketReader();
statsClient.write(encodeStatus());
const status = await waitForType(statsClient, statsReader, MessageType.STATUS);
const stats = JSON.parse(status.payload.toString());
expect(stats.terminal).toMatchObject({ rows: 20, cols: 70 });
expect(stats.clients).toMatchObject({ attached: 1, readOnly: 0 });

client.write(encodeResize(18, 60));
await packets.waitFor((received) =>
received.some(
(packet) =>
packet.type === MessageType.GEOMETRY &&
packet.payload.readUInt16BE(0) === 18 &&
packet.payload.readUInt16BE(2) === 60
)
);

client.destroy();
statsClient.destroy();
});

it("replaces a same-socket ATTACH role with PEEK", async () => {
const name = uniqueName();
await startServer(name, "cat");

const client = await connect(name);
const clientPackets = recordPackets(client);
client.write(encodeAttach(20, 70));
await clientPackets.waitFor((received) =>
received.some((packet) => packet.type === MessageType.SCREEN)
);
client.write(encodePeek());
await clientPackets.waitFor(
(received) =>
received.filter((packet) => packet.type === MessageType.SCREEN).length === 2
);

client.write(
Buffer.concat([
encodeResize(18, 60),
encodeData("must-not-reach-cat\n"),
encodeStatus(),
])
);
await clientPackets.waitFor((received) =>
received.some((packet) => packet.type === MessageType.STATUS)
);
const status = clientPackets.packets
.filter((packet) => packet.type === MessageType.STATUS)
.at(-1)!;
const stats = JSON.parse(status.payload.toString());
expect(stats.clients).toMatchObject({ attached: 0, readOnly: 1 });
expect(stats.terminal).toMatchObject({ rows: 20, cols: 70 });

const observer = await connect(name);
const observerPackets = recordPackets(observer);
observer.write(encodeAttach(20, 70));
await observerPackets.waitFor((received) =>
received.some((packet) => packet.type === MessageType.SCREEN)
);
observer.write(encodeData("accepted-by-cat\n"));
await observerPackets.waitFor((received) =>
received.some(
(packet) =>
packet.type === MessageType.DATA &&
packet.payload.toString().includes("accepted-by-cat")
)
);
const observedOutput = observerPackets.packets
.filter(
(packet) =>
packet.type === MessageType.SCREEN || packet.type === MessageType.DATA
)
.map((packet) => packet.payload.toString())
.join("");
expect(observedOutput).not.toContain("must-not-reach-cat");

client.destroy();
observer.destroy();
});

it("does not change either role for a malformed ATTACH payload", async () => {
const name = uniqueName();
const server = await startServer(name, "cat");
const terminalWrites = holdTerminalWrites(server);

const peeker = await connect(name);
const peekPackets = recordPackets(peeker);
peeker.write(encodePeek());
await peekPackets.waitFor((received) =>
received.some((packet) => packet.type === MessageType.GEOMETRY)
);
expect(terminalWrites.pendingWrites).toHaveLength(1);
peeker.write(
Buffer.concat([
encodePacket(MessageType.ATTACH, Buffer.alloc(2)),
encodeStatus(),
])
);
await peekPackets.waitFor((received) =>
received.some((packet) => packet.type === MessageType.STATUS)
);
const peekStatus = peekPackets.packets
.filter((packet) => packet.type === MessageType.STATUS)
.at(-1)!;
expect(JSON.parse(peekStatus.payload.toString()).clients).toMatchObject({
attached: 0,
readOnly: 1,
});
await terminalWrites.releaseWrites();
await peekPackets.waitFor((received) =>
received.some((packet) => packet.type === MessageType.SCREEN)
);
terminalWrites.restore();

const attached = await connect(name);
const attachedPackets = recordPackets(attached);
attached.write(encodeAttach(20, 70));
await attachedPackets.waitFor((received) =>
received.some((packet) => packet.type === MessageType.SCREEN)
);
attached.write(
Buffer.concat([
encodePacket(MessageType.ATTACH, Buffer.alloc(2)),
encodeStatus(),
])
);
await attachedPackets.waitFor((received) =>
received.some((packet) => packet.type === MessageType.STATUS)
);
const status = attachedPackets.packets
.filter((packet) => packet.type === MessageType.STATUS)
.at(-1)!;
const stats = JSON.parse(status.payload.toString());
expect(stats.clients).toMatchObject({ attached: 1, readOnly: 1 });
expect(stats.terminal).toMatchObject({ rows: 20, cols: 70 });

peeker.destroy();
attached.destroy();
});

it("skips the redraw SIGWINCH nudge at the session's current size", async () => {
const name = uniqueName();
const marker = path.join(testCwd, `${name}-winch`);
Expand Down
Loading