diff --git a/docs/vrs/requirements.md b/docs/vrs/requirements.md index 783b45b..73b0988 100644 --- a/docs/vrs/requirements.md +++ b/docs/vrs/requirements.md @@ -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. @@ -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 diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index 3354ec9..491eb6b 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -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) @@ -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 diff --git a/src/server.ts b/src/server.ts index 1daf96f..9449ae3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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; diff --git a/tests/integration.test.ts b/tests/integration.test.ts index 1c23432..cd731a9 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -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");