diff --git a/docs/architecture.md b/docs/architecture.md index 55211a692..98f1e4a39 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -198,7 +198,7 @@ There is no dedicated welcome message; the server emits a `status` session messa **Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages). -Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients advertise `capabilities.application_socket_lease: true` in `hello`, which claims an application-ownership lease for that physical socket immediately; a first JSON `ping` also opts in clients that did not advertise the capability. All later inbound activity renews a claimed lease, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that advertises neither the capability nor an application `ping` never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead. +Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead. Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id. diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 048cd1c0e..37a766d23 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -664,7 +664,6 @@ test("advertises client capabilities in hello", async () => { clientType: "cli", protocolVersion: 1, capabilities: { - application_socket_lease: true, custom_mode_icons: true, project_updates: true, provider_subagents: true, diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 6b13e324a..6eaa0b155 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -5151,7 +5151,6 @@ export class DaemonClient { [CLIENT_CAPS.providerSubagents]: true, [CLIENT_CAPS.projectUpdates]: true, ...this.config.capabilities, - [CLIENT_CAPS.applicationSocketLease]: true, }, ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}), }), diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index af9ff389d..f9ccbeec9 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -21,7 +21,6 @@ export const CLIENT_CAPS = { // COMPAT(projectUpdates): added in v0.1.109, remove gate after 2027-01-15. projectUpdates: "project_updates", browserHost: "browser_host", - applicationSocketLease: "application_socket_lease", } as const; export type ClientCapability = (typeof CLIENT_CAPS)[keyof typeof CLIENT_CAPS]; diff --git a/packages/protocol/src/messages.application-socket-lease.test.ts b/packages/protocol/src/messages.application-socket-lease.test.ts deleted file mode 100644 index 4a73c3adc..000000000 --- a/packages/protocol/src/messages.application-socket-lease.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { expect, test } from "vitest"; -import { CLIENT_CAPS } from "./client-capabilities.js"; -import { WSHelloMessageSchema } from "./messages.js"; - -const legacyHello = { - type: "hello" as const, - clientId: "client-1", - clientType: "browser" as const, - protocolVersion: 1, -}; - -test("hello accepts the application socket lease capability", () => { - const hello = WSHelloMessageSchema.parse({ - ...legacyHello, - capabilities: { [CLIENT_CAPS.applicationSocketLease]: true }, - }); - - expect(hello.capabilities?.[CLIENT_CAPS.applicationSocketLease]).toBe(true); -}); - -test("hello remains compatible when the application socket lease capability is absent", () => { - const hello = WSHelloMessageSchema.parse(legacyHello); - - expect(hello.capabilities).toBeUndefined(); -}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index fecd93dd5..48e9f106d 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -5565,7 +5565,6 @@ export const WSHelloMessageSchema = z.object({ [CLIENT_CAPS.providerSubagents]: z.boolean().optional(), [CLIENT_CAPS.projectUpdates]: z.boolean().optional(), [CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(), - [CLIENT_CAPS.applicationSocketLease]: z.boolean().optional(), }) .passthrough() .optional(), diff --git a/packages/server/src/server/websocket-server.liveness.e2e.test.ts b/packages/server/src/server/websocket-server.liveness.e2e.test.ts index dd4de7c72..cbbc0f705 100644 --- a/packages/server/src/server/websocket-server.liveness.e2e.test.ts +++ b/packages/server/src/server/websocket-server.liveness.e2e.test.ts @@ -2,7 +2,6 @@ import { expect, test } from "vitest"; import { WebSocket, type RawData } from "ws"; import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/index.js"; import { WSOutboundMessageSchema, type WSOutboundMessage } from "./messages.js"; -import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; const LARGE_REQUEST_BYTES = 512 * 1024; const BURST_MESSAGE_COUNT = 32; @@ -23,7 +22,7 @@ class ResumedPhysicalSocketSession { static async launch(): Promise { const daemon = await createTestPaseoDaemon(); - const original = await connectSocket(daemon.port, "stale-physical-socket", true); + const original = await connectSocket(daemon.port, "stale-physical-socket"); return new ResumedPhysicalSocketSession(daemon, original); } @@ -112,11 +111,7 @@ test( TEST_TIMEOUT_MS, ); -async function connectSocket( - port: number, - clientId: string, - applicationSocketLease = false, -): Promise { +async function connectSocket(port: number, clientId: string): Promise { const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`); await waitForOpen(socket); await sendAndWait( @@ -126,15 +121,13 @@ async function connectSocket( clientId, clientType: "browser", protocolVersion: 1, - ...(applicationSocketLease - ? { capabilities: { [CLIENT_CAPS.applicationSocketLease]: true } } - : {}), }, (message) => message.type === "session" && message.message.type === "status" && message.message.payload.status === "server_info", ); + await sendAndWait(socket, { type: "ping" }, (message) => message.type === "pong"); return socket; } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index af945a744..cb251c2ce 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1379,7 +1379,6 @@ export class VoiceAssistantWebSocketServer { this.clearPendingConnection(ws); pending.identity.clientId = clientId; - this.applicationSocketLease.enroll(ws, message.capabilities); if (message.appVersion) { pending.identity.appVersion = message.appVersion; } diff --git a/packages/server/src/server/websocket/physical-socket.test.ts b/packages/server/src/server/websocket/physical-socket.test.ts index fa35326a0..9b334cc11 100644 --- a/packages/server/src/server/websocket/physical-socket.test.ts +++ b/packages/server/src/server/websocket/physical-socket.test.ts @@ -1,5 +1,4 @@ import { expect, test } from "vitest"; -import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import { APPLICATION_SOCKET_LEASE_MS, ApplicationSocketLease, @@ -7,12 +6,10 @@ import { sendBoundedPhysicalFrame, } from "./physical-socket.js"; -test("legacy sockets without the application lease capability remain exempt", () => { +test("sockets remain exempt until they send an application ping", () => { let now = 0; const lease = new ApplicationSocketLease(() => now); const legacySocket = {}; - lease.enroll(legacySocket, undefined); - now = APPLICATION_SOCKET_LEASE_MS * 10; expect(lease.listExpired()).toEqual([]); @@ -20,11 +17,11 @@ test("legacy sockets without the application lease capability remain exempt", () expect(lease.listExpired()).toEqual([]); }); -test("the hello capability enrolls immediately and inbound activity renews the lease", () => { +test("inbound activity renews a claimed lease", () => { let now = 0; const lease = new ApplicationSocketLease(() => now); const applicationSocket = {}; - lease.enroll(applicationSocket, { [CLIENT_CAPS.applicationSocketLease]: true }); + lease.claim(applicationSocket); now = APPLICATION_SOCKET_LEASE_MS - 1; lease.renew(applicationSocket); @@ -37,7 +34,7 @@ test("the hello capability enrolls immediately and inbound activity renews the l expect(lease.listExpired()).toEqual([]); }); -test("a socket that does not advertise the capability can opt in with an application ping", () => { +test("an application ping claims a socket lease", () => { let now = 0; const lease = new ApplicationSocketLease(() => now); const rawSocket = {}; diff --git a/packages/server/src/server/websocket/physical-socket.ts b/packages/server/src/server/websocket/physical-socket.ts index 9c815bcb2..f42b6fd13 100644 --- a/packages/server/src/server/websocket/physical-socket.ts +++ b/packages/server/src/server/websocket/physical-socket.ts @@ -1,5 +1,3 @@ -import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; - // Terminal streams begin snapshot catch-up at 4 MiB. The physical socket gets // another 4 MiB to recover before the daemon enforces the hard memory bound. export const MAX_PHYSICAL_SOCKET_BUFFERED_BYTES = 8 * 1024 * 1024; @@ -21,12 +19,6 @@ export class ApplicationSocketLease { this.deadlines.set(socket, this.clock() + APPLICATION_SOCKET_LEASE_MS); } - enroll(socket: TSocket, capabilities: Record | undefined): void { - if (capabilities?.[CLIENT_CAPS.applicationSocketLease] === true) { - this.claim(socket); - } - } - renew(socket: TSocket): void { if (this.deadlines.has(socket)) { this.claim(socket);