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
18 changes: 11 additions & 7 deletions docs/vrs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ implementation contract and validation map live in [spec.md](./spec.md).

- **T01 Per-session daemon:** Each session pays for an independent daemon in
exchange for client-independent lifetime and failure isolation.
- **T02 Shared grid:** All writable clients share the minimum requested rows and
minimum requested columns so every writer can represent the complete grid.
- **T02 Shared grid:** All writable-attached clients share the minimum requested
rows and minimum requested columns so every attached writer can represent the
complete grid.
- **T03 Pre-1.0 compatibility:** Public storage and package APIs may evolve
before 1.0, but readers remain bounded and documented compatibility tiers are
preserved deliberately.
Expand Down Expand Up @@ -56,12 +57,15 @@ implementation contract and validation map live in [spec.md](./spec.md).
emits terminal state sends effective geometry, exactly one screen baseline,
then post-cut data and at most one process exit in source order. A later mode
request supersedes an unfinished generation; reconnect starts a new one.
- **R05 Replaceable client roles:** A complete `ATTACH` makes its socket
writable, installs requested geometry, and enables input and resize. A
recognized `PEEK` makes it readonly and removes its geometry constraint. A
malformed attach changes neither role nor synchronization generation.
- **R05 Replaceable client roles:** A fresh command socket accepts input and
status requests but starts no screen baseline, has no geometry membership,
and cannot resize. A complete `ATTACH` makes it writable-attached, retaining
input and status capabilities while installing requested geometry, enabling
resize, and starting ordered baseline synchronization. A recognized `PEEK`
makes it readonly and removes its geometry constraint. A malformed attach
changes neither role nor synchronization generation.
- **R06 Deterministic geometry:** Effective rows and columns are the independent
minima requested by writable clients. Attach, resize, and disconnect
minima requested by writable-attached clients. Attach, resize, and disconnect
recompute them; readonly observation never constrains them. Geometry changes
are visible before terminal bytes produced for the new size.
- **R07 Bounded stream protocol:** Packets are length-delimited, fragmented
Expand Down
24 changes: 20 additions & 4 deletions docs/vrs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,16 +96,32 @@ A local machine detach may end with `DETACH` before a baseline is emitted.

### Roles and geometry

Role frames replace, rather than accumulate, socket state (R05):
Each socket begins in the command role. Role frames replace, rather than
accumulate, socket state (R05):

| Frame | Resulting role | Geometry membership | Input/resize |
| --- | --- | --- | --- |
| complete `ATTACH(rows, cols)` | writable | requested rows/cols | enabled |
| complete `ATTACH(rows, cols)` | writable-attached | requested rows/cols | enabled |
| recognized `PEEK(flags)` | readonly | none | disabled |
| malformed `ATTACH` | unchanged | unchanged | unchanged |
| `STATUS` | unchanged | unchanged | unchanged |

For writable request set `W`, shared geometry is (R06):
Client-to-server data, status, and resize behavior is role-specific:

| Role | `DATA` | `STATUS` | `RESIZE` |
| --- | --- | --- | --- |
| command | accepted | accepted | ignored |
| writable-attached | accepted | accepted | accepted |
| readonly | ignored | accepted | ignored |

Command sockets do not receive a screen baseline and do not participate in
geometry. They may receive baseline-less live `DATA` or `EXIT` broadcasts;
those packets do not constitute reconstructable terminal state. A consumer that
needs reconstructable terminal state must first send `ATTACH` or `PEEK`. Public
stats omit command sockets and expose the writable-attached role with the
compatibility string `"writable"`.

For writable-attached request set `W`, shared geometry is (R06):

```text
rows = min(client.rows for client in W)
Expand All @@ -114,7 +130,7 @@ cols = min(client.cols for client in W)

The dimensions are minimized independently. A changed `GEOMETRY` notification
precedes terminal output produced after the corresponding PTY resize. Removing
the last writable client leaves the last effective geometry stable.
the last writable-attached client leaves the last effective geometry stable.

### Machine attach

Expand Down
2 changes: 1 addition & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1021,7 +1021,7 @@ export class PtyServer {
}

case MessageType.RESIZE: {
if (!client.readonly && packet.payload.length >= 4) {
if (!client.readonly && client.attachSeq > 0 && packet.payload.length >= 4) {
const size = decodeSize(packet.payload);
client.rows = size.rows;
client.cols = size.cols;
Expand Down
52 changes: 52 additions & 0 deletions tests/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2063,6 +2063,58 @@ describe("STATUS message", () => {
statsClient.destroy();
});

it("ignores RESIZE from a command socket until that socket attaches", async () => {
const name = uniqueName();
await startServer(name, "cat");

const attached = await connect(name);
const attachedPackets = recordPackets(attached);
attached.write(encodeAttach(24, 80));
await attachedPackets.waitFor((packets) =>
packets.some((packet) => packet.type === MessageType.SCREEN)
);

const command = await connect(name);
const commandPackets = recordPackets(command);
command.write(
Buffer.concat([
encodeData("command-input-remains-valid\n"),
encodeResize(13, 37),
encodeStatus(),
])
);
await commandPackets.waitFor((packets) =>
packets.some((packet) => packet.type === MessageType.STATUS)
);
await attachedPackets.waitFor((packets) =>
packets.some(
(packet) =>
packet.type === MessageType.DATA &&
packet.payload.toString().includes("command-input-remains-valid")
)
);

const status = commandPackets.packets.find(
(packet) => packet.type === MessageType.STATUS
)!;
const stats = JSON.parse(status.payload.toString());
expect(stats.clients).toMatchObject({
total: 1,
attached: 1,
readOnly: 0,
});
expect(stats.terminal).toMatchObject({ rows: 24, cols: 80 });
expect(
commandPackets.packets.some((packet) => packet.type === MessageType.GEOMETRY)
).toBe(false);
expect(
commandPackets.packets.some((packet) => packet.type === MessageType.SCREEN)
).toBe(false);

attached.destroy();
command.destroy();
});

it("reports anonymous client geometry and per-axis constraints", async () => {
const name = uniqueName();
await startServer(name, "cat");
Expand Down
Loading