refactor(server): reuse client heartbeat for socket liveness

This commit is contained in:
Mohamed Boudra
2026-07-18 13:34:33 +00:00
parent 8cf190e516
commit abe91201f0
10 changed files with 8 additions and 56 deletions

View File

@@ -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.

View File

@@ -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,

View File

@@ -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 } : {}),
}),

View File

@@ -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];

View File

@@ -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();
});

View File

@@ -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(),

View File

@@ -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<ResumedPhysicalSocketSession> {
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<WebSocket> {
async function connectSocket(port: number, clientId: string): Promise<WebSocket> {
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;
}

View File

@@ -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;
}

View File

@@ -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<object>(() => 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<object>(() => 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<object>(() => now);
const rawSocket = {};

View File

@@ -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<TSocket extends object> {
this.deadlines.set(socket, this.clock() + APPLICATION_SOCKET_LEASE_MS);
}
enroll(socket: TSocket, capabilities: Record<string, unknown> | undefined): void {
if (capabilities?.[CLIENT_CAPS.applicationSocketLease] === true) {
this.claim(socket);
}
}
renew(socket: TSocket): void {
if (this.deadlines.has(socket)) {
this.claim(socket);