From 6916a3102ebe2de9eaebbfaea1b320a521df9ad9 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Wed, 9 Sep 2026 04:17:56 +0800 Subject: [PATCH 1/7] feat(schema,transport): accept the handshake below the wire floor so a skew is named instead of timing out --- .../foundation/schema/src/wire/message.ts | 27 +++++++++--- .../tests/contract/wire/envelope.test.ts | 23 +++++++++- .../transport/src/__tests__/tunnel.test.ts | 17 +++++++ .../tests/integration/socket-io.test.ts | 44 ++++++++++++++++++- .../tests/integration/ws-server.test.ts | 42 ++++++++++++++++++ 5 files changed, 146 insertions(+), 7 deletions(-) create mode 100644 packages/foundation/transport/tests/integration/ws-server.test.ts diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index 53693a930..ef0a784dc 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -38,12 +38,14 @@ declare const wireMessageValidated: unique symbol; * A WireMessage a transport accepts for send. Minted in exactly two places: here by * {@link parseWireMessage} (zod at the receive trust boundary) and by the transport package's * `createWireMessage` (typed local construction). The brand keeps raw, unvalidated objects out - * of the send path without paying a per-frame parse there. + * of the send path without paying a per-frame parse there. One receive-side exception: a + * below-floor `ping`/`pong` carries the brand too, so a version skew can be answered and named. */ export type ValidatedWireMessage = WireMessage & { readonly [wireMessageValidated]: true }; -/** Why a frame was refused. Only `unsupported-version` is fatal to a connection; the rest describe - * one frame, and `unknown-kind` is the routine cost of talking to a newer peer. */ +/** Why a frame was refused. Every reason describes one frame and leaves the connection open; + * `unsupported-version` names the peer's version for the log, and `unknown-kind` is the routine + * cost of talking to a newer peer. */ export type WireParseFailure = | { reason: 'malformed-envelope' } | { reason: 'unsupported-version'; version: number } @@ -54,15 +56,30 @@ export type WireParseResult = | { ok: true; message: ValidatedWireMessage } | ({ ok: false } & WireParseFailure); +/** The handshake is the one exchange both ends of a version skew can still read: a `ping` or a + * `pong` is accepted whatever `v` it carries, so a peer below this build's floor learns the range + * it must update into instead of timing out against silence. */ +const VERSION_AGNOSTIC_KINDS: ReadonlySet = new Set(['ping', 'pong']); + /** Parse + validate an inbound message; success mints the {@link ValidatedWireMessage} brand. */ export function parseWireMessage(input: unknown): WireParseResult { const envelope = WireEnvelopeSchema.safeParse(input); if (!envelope.success) return { ok: false, reason: 'malformed-envelope' }; + const kind = payloadKind(envelope.data.payload); if (envelope.data.v < MIN_COMPATIBLE_WIRE_VERSION) { - return { ok: false, reason: 'unsupported-version', version: envelope.data.v }; + const handshake = + kind !== undefined && VERSION_AGNOSTIC_KINDS.has(kind) + ? WirePayloadSchema.safeParse(envelope.data.payload) + : undefined; + if (!handshake?.success) { + return { ok: false, reason: 'unsupported-version', version: envelope.data.v }; + } + return { + ok: true, + message: { ...envelope.data, payload: handshake.data } as ValidatedWireMessage, + }; } - const kind = payloadKind(envelope.data.payload); if (kind === undefined) return { ok: false, reason: 'malformed-envelope' }; if (!WIRE_PAYLOAD_KINDS.has(kind)) return { ok: false, reason: 'unknown-kind', kind }; diff --git a/packages/foundation/schema/tests/contract/wire/envelope.test.ts b/packages/foundation/schema/tests/contract/wire/envelope.test.ts index 40322833a..ba2d6fe2b 100644 --- a/packages/foundation/schema/tests/contract/wire/envelope.test.ts +++ b/packages/foundation/schema/tests/contract/wire/envelope.test.ts @@ -19,7 +19,28 @@ describe('wire envelope compatibility', () => { }); it('refuses a peer below the compatible floor, naming the version it spoke', () => { - const parsed = parseWireMessage(envelope(ping, MIN_COMPATIBLE_WIRE_VERSION - 1)); + const parsed = parseWireMessage( + envelope({ kind: 'session.list', clientReqId: 'r1' }, MIN_COMPATIBLE_WIRE_VERSION - 1), + ); + + expect(parsed).toMatchObject({ + ok: false, + reason: 'unsupported-version', + version: MIN_COMPATIBLE_WIRE_VERSION - 1, + }); + }); + + it('accepts the handshake whatever version it carries, so a skew can be named', () => { + const belowFloorPing = parseWireMessage(envelope(ping, MIN_COMPATIBLE_WIRE_VERSION - 1)); + expect(belowFloorPing).toMatchObject({ ok: true, message: { payload: ping } }); + + const olderHostPong = { kind: 'pong', version: 1, minCompatible: 1 }; + const belowFloorPong = parseWireMessage(envelope(olderHostPong, 1)); + expect(belowFloorPong).toMatchObject({ ok: true, message: { v: 1, payload: olderHostPong } }); + }); + + it('refuses a below-floor frame that only claims to be the handshake', () => { + const parsed = parseWireMessage(envelope({ kind: 'pong' }, MIN_COMPATIBLE_WIRE_VERSION - 1)); expect(parsed).toMatchObject({ ok: false, diff --git a/packages/foundation/transport/src/__tests__/tunnel.test.ts b/packages/foundation/transport/src/__tests__/tunnel.test.ts index 2f04a685f..b1beaef0b 100644 --- a/packages/foundation/transport/src/__tests__/tunnel.test.ts +++ b/packages/foundation/transport/src/__tests__/tunnel.test.ts @@ -1,3 +1,4 @@ +import { MIN_COMPATIBLE_WIRE_VERSION } from '@linkcode/schema'; import { TUNNEL_MAX_CONNECTION_AGE_MS, TunnelChunkAssembler, @@ -118,6 +119,22 @@ describe('TunnelTransportServer', () => { } expect(inbound).toEqual([request]); + // A remote peer below this build's floor still gets its handshake through — the relay carries + // the frame opaquely, so this is the tunnel's whole version surface. + const belowFloorPing = { + v: MIN_COMPATIBLE_WIRE_VERSION - 1, + id: 'message-2', + ts: 1, + payload: { kind: 'ping' }, + }; + const pingChunks = new TunnelChunkEncoder(1).encode(JSON.stringify(belowFloorPing)); + for (let i = 0, len = pingChunks.length; i < len; i++) { + socket.emit('message', { + data: encodeTunnelPeerFrame({ kind: 'peer.data', peerId: 'peer-1', data: pingChunks[i] }), + }); + } + expect(inbound).toEqual([request, belowFloorPing]); + connection.send(createWireMessage({ kind: 'request.succeeded', replyTo: 'r1' })); let outbound: TunnelPeerFrame | null = null; for (let i = 0, len = socket.sent.length; i < len; i++) { diff --git a/packages/foundation/transport/tests/integration/socket-io.test.ts b/packages/foundation/transport/tests/integration/socket-io.test.ts index 8b261496e..c72e47abe 100644 --- a/packages/foundation/transport/tests/integration/socket-io.test.ts +++ b/packages/foundation/transport/tests/integration/socket-io.test.ts @@ -1,12 +1,54 @@ import { createServer } from 'node:http'; import type { AddressInfo } from 'node:net'; +import { MIN_COMPATIBLE_WIRE_VERSION, WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; import { SocketIoTransport } from '@linkcode/transport'; import type { SocketIoServer } from '@linkcode/transport/server'; -import { createSocketIoServer } from '@linkcode/transport/server'; +import { createSocketIoServer, Hub } from '@linkcode/transport/server'; import { noop } from 'foxts/noop'; import { wait } from 'foxts/wait'; +import { io } from 'socket.io-client'; import { describe, expect, it, vi } from 'vitest'; +describe('SocketIoServer below-floor handshake', () => { + it('answers a ping stamped below the floor with the version range the peer must update into', async () => { + const server = await createSocketIoServer({ port: 0, host: '127.0.0.1' }); + const hub = new Hub(); + server.onConnection((connection) => hub.addConnection(connection)); + // A raw socket: the real transport always stamps this build's version, and the point is a + // frame an older build would send. + const socket = io(`http://127.0.0.1:${server.port}`, { + transports: ['websocket'], + reconnection: false, + }); + try { + const answer = new Promise((resolve) => { + socket.on('frame', resolve); + }); + await new Promise((resolve) => { + socket.on('connect', () => resolve()); + }); + socket.emit('frame', { + v: MIN_COMPATIBLE_WIRE_VERSION - 1, + id: 'message-1', + ts: Date.now(), + payload: { kind: 'ping' }, + }); + expect(await answer).toMatchObject({ + v: WIRE_PROTOCOL_VERSION, + payload: { + kind: 'pong', + version: WIRE_PROTOCOL_VERSION, + minCompatible: MIN_COMPATIBLE_WIRE_VERSION, + }, + }); + } finally { + socket.close(); + hub.close(); + await server.close(); + } + }); +}); + describe('SocketIoTransport connection lifetime', () => { it('does not reconnect after the initial connection attempt fails', async () => { const portHolder = await createSocketIoServer({ port: 0, host: '127.0.0.1' }); diff --git a/packages/foundation/transport/tests/integration/ws-server.test.ts b/packages/foundation/transport/tests/integration/ws-server.test.ts new file mode 100644 index 000000000..d8b96c35d --- /dev/null +++ b/packages/foundation/transport/tests/integration/ws-server.test.ts @@ -0,0 +1,42 @@ +import { MIN_COMPATIBLE_WIRE_VERSION, WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; +import { createWsServer, Hub } from '@linkcode/transport/server'; +import { describe, expect, it } from 'vitest'; + +describe('WsServer below-floor handshake', () => { + it('answers a ping stamped below the floor with the version range the peer must update into', async () => { + const server = await createWsServer({ port: 0, host: '127.0.0.1' }); + const hub = new Hub(); + server.onConnection((connection) => hub.addConnection(connection)); + // A raw socket: the real transport always stamps this build's version, and the point is a + // frame an older build would send. + const socket = new WebSocket(`ws://127.0.0.1:${server.port}`); + try { + const answer = new Promise((resolve) => { + socket.addEventListener('message', (event) => resolve(JSON.parse(String(event.data)))); + }); + await new Promise((resolve) => { + socket.addEventListener('open', () => resolve()); + }); + socket.send( + JSON.stringify({ + v: MIN_COMPATIBLE_WIRE_VERSION - 1, + id: 'message-1', + ts: Date.now(), + payload: { kind: 'ping' }, + }), + ); + expect(await answer).toMatchObject({ + v: WIRE_PROTOCOL_VERSION, + payload: { + kind: 'pong', + version: WIRE_PROTOCOL_VERSION, + minCompatible: MIN_COMPATIBLE_WIRE_VERSION, + }, + }); + } finally { + socket.close(); + hub.close(); + await server.close(); + } + }); +}); From c36723cbcc394749ac604bfe9939a4717e8f9ca9 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Wed, 9 Sep 2026 04:26:45 +0800 Subject: [PATCH 2/7] feat(client-core): type the wire incompatibility and stop retrying it --- .../__tests__/connection-controller.test.ts | 70 +++++++++++++++++++ .../core/src/__tests__/connection.test.ts | 57 +++++++++++++-- packages/client/core/src/client.ts | 17 +++-- .../client/core/src/connection-controller.ts | 13 +++- packages/client/core/src/index.ts | 1 + .../core/src/wire-incompatible-error.ts | 25 +++++++ .../core/tests/integration/wire-skew.test.ts | 49 +++++++++++++ 7 files changed, 219 insertions(+), 13 deletions(-) create mode 100644 packages/client/core/src/__tests__/connection-controller.test.ts create mode 100644 packages/client/core/src/wire-incompatible-error.ts create mode 100644 packages/client/core/tests/integration/wire-skew.test.ts diff --git a/packages/client/core/src/__tests__/connection-controller.test.ts b/packages/client/core/src/__tests__/connection-controller.test.ts new file mode 100644 index 000000000..7852618f5 --- /dev/null +++ b/packages/client/core/src/__tests__/connection-controller.test.ts @@ -0,0 +1,70 @@ +import type { Transport } from '@linkcode/transport'; +import { noop } from 'foxts/noop'; +import { describe, expect, it, vi } from 'vitest'; +import type { RecoverableClient } from '../connection-controller'; +import { ConnectionController } from '../connection-controller'; +import { WireIncompatibleError } from '../wire-incompatible-error'; + +/** The controller never drives the transport itself; the client it creates does. */ +const transport: Transport = { + connect: () => Promise.resolve(), + send: noop, + onMessage: () => noop, + onClose: () => noop, + close: noop, +}; + +class FailingClient implements RecoverableClient { + constructor(private readonly failure: Error) {} + + connect(): Promise { + return Promise.reject(this.failure); + } + + onClose(): () => void { + return noop; + } + + readonly dispose = noop; +} + +const FAST_RETRY = { retries: 2, minTimeout: 1, maxTimeout: 1 }; + +describe('ConnectionController recovery', () => { + it('stops at once when the handshake names a wire incompatibility', async () => { + const createClient = vi.fn( + () => new FailingClient(new WireIncompatibleError('update-app', 90, 85)), + ); + const controller = new ConnectionController( + { resolve: () => ({ transport }) }, + { createClient, retry: FAST_RETRY }, + ); + controller.start(); + + await vi.waitFor(() => expect(controller.getSnapshot().status).toBe('error')); + expect(controller.getSnapshot().error).toBeInstanceOf(WireIncompatibleError); + expect(createClient).toHaveBeenCalledTimes(1); + + // A deliberate retry (the host may have been updated) dials once more and stops again. + controller.retry(); + expect(controller.getSnapshot().status).toBe('connecting'); + await vi.waitFor(() => expect(controller.getSnapshot().status).toBe('error')); + expect(controller.getSnapshot().error).toBeInstanceOf(WireIncompatibleError); + expect(createClient).toHaveBeenCalledTimes(2); + controller.dispose(); + }); + + it('keeps retrying an ordinary connection failure until the budget runs out', async () => { + const createClient = vi.fn(() => new FailingClient(new Error('connection refused'))); + const controller = new ConnectionController( + { resolve: () => ({ transport }) }, + { createClient, retry: FAST_RETRY }, + ); + controller.start(); + + await vi.waitFor(() => expect(controller.getSnapshot().status).toBe('error')); + expect(controller.getSnapshot().error).toMatchObject({ message: 'connection refused' }); + expect(createClient).toHaveBeenCalledTimes(FAST_RETRY.retries + 1); + controller.dispose(); + }); +}); diff --git a/packages/client/core/src/__tests__/connection.test.ts b/packages/client/core/src/__tests__/connection.test.ts index 7f8d0121c..9ef260a5d 100644 --- a/packages/client/core/src/__tests__/connection.test.ts +++ b/packages/client/core/src/__tests__/connection.test.ts @@ -1,9 +1,15 @@ import type { ValidatedWireMessage, WirePayload } from '@linkcode/schema'; -import { SessionIdSchema, SessionResourceSchema, WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; +import { + MIN_COMPATIBLE_WIRE_VERSION, + SessionIdSchema, + SessionResourceSchema, + WIRE_PROTOCOL_VERSION, +} from '@linkcode/schema'; import type { Transport, Unsubscribe } from '@linkcode/transport'; import { createWireMessage, pong } from '@linkcode/transport'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { LinkCodeClient } from '../client'; +import { WireIncompatibleError } from '../wire-incompatible-error'; class ControlledTransport implements Transport { readonly sent: WirePayload[] = []; @@ -75,9 +81,12 @@ describe('LinkCodeClient connection lifetime', () => { it('names the skew when the host has moved its floor past this build', async () => { const transport = new ControlledTransport(); const client = new LinkCodeClient(transport); - const connecting = expect(client.connect()).rejects.toThrow( - `this build speaks wire v${WIRE_PROTOCOL_VERSION}, older than the v${WIRE_PROTOCOL_VERSION + 3} the host needs`, - ); + const connecting = client + .connect() + .then(() => { + throw new Error('handshake should have failed'); + }) + .catch((error: unknown) => error); await vi.waitFor(() => expect(transport.sent).toContainEqual({ kind: 'ping' })); transport.receive({ @@ -86,7 +95,45 @@ describe('LinkCodeClient connection lifetime', () => { minCompatible: WIRE_PROTOCOL_VERSION + 3, }); - await connecting; + const error = await connecting; + expect(error).toBeInstanceOf(WireIncompatibleError); + expect(error).toMatchObject({ + remedy: 'update-app', + peerVersion: WIRE_PROTOCOL_VERSION + 5, + peerMinCompatible: WIRE_PROTOCOL_VERSION + 3, + message: expect.stringContaining( + `this build speaks wire v${WIRE_PROTOCOL_VERSION}, older than the v${WIRE_PROTOCOL_VERSION + 3} the host needs`, + ), + }); + client.dispose(); + }); + + it('names the skew when the host is older than this build accepts', async () => { + const transport = new ControlledTransport(); + const client = new LinkCodeClient(transport); + const connecting = client + .connect() + .then(() => { + throw new Error('handshake should have failed'); + }) + .catch((error: unknown) => error); + + await vi.waitFor(() => expect(transport.sent).toContainEqual({ kind: 'ping' })); + transport.receive({ + kind: 'pong', + version: MIN_COMPATIBLE_WIRE_VERSION - 1, + minCompatible: MIN_COMPATIBLE_WIRE_VERSION - 4, + }); + + const error = await connecting; + expect(error).toBeInstanceOf(WireIncompatibleError); + expect(error).toMatchObject({ + remedy: 'update-host', + peerVersion: MIN_COMPATIBLE_WIRE_VERSION - 1, + message: expect.stringContaining( + `host speaks wire v${MIN_COMPATIBLE_WIRE_VERSION - 1}, older than the v${MIN_COMPATIBLE_WIRE_VERSION} this build needs`, + ), + }); client.dispose(); }); diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index 88875f063..1dd07df88 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -129,6 +129,7 @@ import type { } from './client/pending-registry'; import { PendingRegistry, resolveRandomUUID } from './client/pending-registry'; import { TerminalChannel } from './client/terminal-channel'; +import { WireIncompatibleError } from './wire-incompatible-error'; export type { AgentLoginHandlers, AgentLoginSettled } from './client/agent-login-channel'; export type { @@ -244,13 +245,16 @@ type ConnectionState = 'idle' | 'connecting' | 'ready' | 'closed' | 'disposed'; const HANDSHAKE_TIMEOUT_MS = 5000; -/** The message to fail the handshake with, or null when the two builds overlap. */ -function wireIncompatibility(peerVersion: number, peerMinCompatible: number): string | null { +/** The skew between this build and the peer that answered the handshake, or null when they overlap. */ +function wireIncompatibility( + peerVersion: number, + peerMinCompatible: number, +): WireIncompatibleError | null { if (peerVersion < MIN_COMPATIBLE_WIRE_VERSION) { - return `LinkCodeClient: host speaks wire v${peerVersion}, older than the v${MIN_COMPATIBLE_WIRE_VERSION} this build needs — update the host`; + return new WireIncompatibleError('update-host', peerVersion, peerMinCompatible); } if (WIRE_PROTOCOL_VERSION < peerMinCompatible) { - return `LinkCodeClient: this build speaks wire v${WIRE_PROTOCOL_VERSION}, older than the v${peerMinCompatible} the host needs — update this app`; + return new WireIncompatibleError('update-app', peerVersion, peerMinCompatible); } return null; } @@ -800,7 +804,7 @@ export class LinkCodeClient { case 'pong': { this.peerWire = { version: p.version, minCompatible: p.minCompatible }; const incompatible = wireIncompatibility(p.version, p.minCompatible); - if (incompatible) this.rejectHandshake?.(new Error(incompatible)); + if (incompatible) this.rejectHandshake?.(incompatible); else this.resolveHandshake?.(); break; } @@ -1729,6 +1733,9 @@ export class LinkCodeClient { } } +/** A wire incompatibility must reach callers as itself: the controller stops retrying on the type + * and a client renders an update state from it. */ function toError(error: unknown): Error { + if (error instanceof WireIncompatibleError) return error; return new Error(extractErrorMessage(error, false) ?? 'Unknown error', { cause: error }); } diff --git a/packages/client/core/src/connection-controller.ts b/packages/client/core/src/connection-controller.ts index 01888c2d9..ef304f02f 100644 --- a/packages/client/core/src/connection-controller.ts +++ b/packages/client/core/src/connection-controller.ts @@ -2,6 +2,7 @@ import type { Transport, Unsubscribe } from '@linkcode/transport'; import type { AsyncRetryOptions } from 'foxts/async-retry'; import { asyncRetry } from 'foxts/async-retry'; import { noop } from 'foxts/noop'; +import { WireIncompatibleError } from './wire-incompatible-error'; const DEFAULT_RETRY_POLICY = { factor: 2, @@ -70,8 +71,8 @@ export interface ConnectionControllerOptions { onPromote?: (client: TClient | null) => void; onOutcome?: (outcome: ConnectionOutcome) => void; /** `retries` defaults to infinity — right for a local daemon that will eventually come back, - * wrong for a battery-powered client, and wrong for a permanent failure such as a wire-protocol - * mismatch. Cap it to surface `error` and let the caller re-trigger deliberately. */ + * wrong for a battery-powered client. Cap it to surface `error` and let the caller re-trigger + * deliberately; a wire incompatibility stops the run on its own regardless of the cap. */ retry?: Partial>; } @@ -251,7 +252,13 @@ export class ConnectionController { return bail(error); } })(); - return this.connectGeneration(run, attempt, resolved, client); + try { + return await this.connectGeneration(run, attempt, resolved, client); + } catch (error) { + // A wire skew cannot heal by retrying: stop at once so the app can show an update state. + if (error instanceof WireIncompatibleError) return bail(error); + throw error; + } }, { ...this.retryPolicy, diff --git a/packages/client/core/src/index.ts b/packages/client/core/src/index.ts index 0e68045ee..6d3c74ce1 100644 --- a/packages/client/core/src/index.ts +++ b/packages/client/core/src/index.ts @@ -8,3 +8,4 @@ export * from './conversation'; export * from './conversation-read'; export * from './conversation-store'; export * from './react'; +export * from './wire-incompatible-error'; diff --git a/packages/client/core/src/wire-incompatible-error.ts b/packages/client/core/src/wire-incompatible-error.ts new file mode 100644 index 000000000..909195968 --- /dev/null +++ b/packages/client/core/src/wire-incompatible-error.ts @@ -0,0 +1,25 @@ +import { MIN_COMPATIBLE_WIRE_VERSION, WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; + +/** Which side of a wire skew is behind. */ +export type WireIncompatibilityRemedy = 'update-host' | 'update-app'; + +/** + * The two builds on a connection do not overlap on the wire. A handshake fails with this instead of + * a timeout, and it cannot heal by retrying: the connection controller stops on it at once so a + * client renders an update state rather than "host unavailable". + */ +export class WireIncompatibleError extends Error { + override readonly name = 'WireIncompatibleError'; + + constructor( + readonly remedy: WireIncompatibilityRemedy, + readonly peerVersion: number, + readonly peerMinCompatible: number, + ) { + super( + remedy === 'update-host' + ? `LinkCodeClient: host speaks wire v${peerVersion}, older than the v${MIN_COMPATIBLE_WIRE_VERSION} this build needs — update the host` + : `LinkCodeClient: this build speaks wire v${WIRE_PROTOCOL_VERSION}, older than the v${peerMinCompatible} the host needs — update this app`, + ); + } +} diff --git a/packages/client/core/tests/integration/wire-skew.test.ts b/packages/client/core/tests/integration/wire-skew.test.ts new file mode 100644 index 000000000..692a69a1e --- /dev/null +++ b/packages/client/core/tests/integration/wire-skew.test.ts @@ -0,0 +1,49 @@ +import { MIN_COMPATIBLE_WIRE_VERSION, parseWireMessage } from '@linkcode/schema'; +import { WsTransport } from '@linkcode/transport'; +import { createWsServer } from '@linkcode/transport/server'; +import { describe, expect, it } from 'vitest'; +import { LinkCodeClient } from '../../src/client'; +import { WireIncompatibleError } from '../../src/wire-incompatible-error'; + +describe('LinkCodeClient against an older host', () => { + it('reads the below-floor pong through the real transport and names the host as the side to update', async () => { + const server = await createWsServer({ port: 0, host: '127.0.0.1' }); + server.onConnection((connection) => { + connection.onMessage((message) => { + if (message.payload.kind !== 'ping') return; + // What an older host sends back: its own stamp and range, both below this build's floor. + const olderPong = parseWireMessage({ + v: MIN_COMPATIBLE_WIRE_VERSION - 1, + id: 'older-pong', + ts: Date.now(), + payload: { + kind: 'pong', + version: MIN_COMPATIBLE_WIRE_VERSION - 1, + minCompatible: MIN_COMPATIBLE_WIRE_VERSION - 4, + }, + }); + if (!olderPong.ok) throw new Error(`fixture pong refused: ${olderPong.reason}`); + connection.send(olderPong.message); + }); + }); + const client = new LinkCodeClient(new WsTransport({ url: `ws://127.0.0.1:${server.port}` })); + try { + const error = await client + .connect() + .then(() => { + throw new Error('handshake should have failed'); + }) + .catch((error_: unknown) => error_); + expect(error).toBeInstanceOf(WireIncompatibleError); + expect(error).toMatchObject({ + remedy: 'update-host', + peerVersion: MIN_COMPATIBLE_WIRE_VERSION - 1, + peerMinCompatible: MIN_COMPATIBLE_WIRE_VERSION - 4, + }); + expect(client.peerWireVersion).toBe(MIN_COMPATIBLE_WIRE_VERSION - 1); + } finally { + client.dispose(); + await server.close(); + } + }); +}); From 5be13fb4b413b5d5a66b6aebd5486a215a7da8b3 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Wed, 9 Sep 2026 04:32:32 +0800 Subject: [PATCH 3/7] feat(mobile): show an update-required state for a wire version skew --- .../src/components/host/host-client-gate.tsx | 1 + .../components/host/host-connection-state.tsx | 26 +++++++++++++++---- apps/mobile/src/runtime/use-host-client.ts | 16 ++++++++++-- packages/presentation/i18n/src/locales/en.ts | 6 +++++ .../presentation/i18n/src/locales/zh-cn.ts | 5 ++++ 5 files changed, 47 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/components/host/host-client-gate.tsx b/apps/mobile/src/components/host/host-client-gate.tsx index 6a110f39d..4ff4377b6 100644 --- a/apps/mobile/src/components/host/host-client-gate.tsx +++ b/apps/mobile/src/components/host/host-client-gate.tsx @@ -18,6 +18,7 @@ export function HostClientGate({ children }: React.PropsWithChildren): React.Rea status={connection.status} url={connection.endpointLabel} failure={connection.failure} + wireRemedy={connection.wireRemedy} onRetry={connection.retry} /> ); diff --git a/apps/mobile/src/components/host/host-connection-state.tsx b/apps/mobile/src/components/host/host-connection-state.tsx index be981c328..c7e5c85c1 100644 --- a/apps/mobile/src/components/host/host-connection-state.tsx +++ b/apps/mobile/src/components/host/host-connection-state.tsx @@ -5,6 +5,7 @@ import { multilineTextAlignment, textSelection, } from '@expo/ui/swift-ui/modifiers'; +import type { WireIncompatibilityRemedy } from '@linkcode/client-core'; import { FOOTNOTE, SECONDARY } from '@mobile/components/form/styles'; import { useTranslations } from 'use-intl'; @@ -16,6 +17,8 @@ export interface HostConnectionStateProps { url: string; /** The underlying failure, when the controller reported one. */ failure?: string; + /** A wire skew: retrying cannot help, one side has to update. */ + wireRemedy?: WireIncompatibilityRemedy; onRetry: () => void; } @@ -24,10 +27,21 @@ export function HostConnectionState({ status, url, failure, + wireRemedy, onRetry, }: HostConnectionStateProps): React.ReactNode { const t = useTranslations('mobile.connection'); + let title = t('unavailableTitle'); + let body = t('error', { url }); + if (wireRemedy === 'update-app') { + title = t('updateAppTitle'); + body = t('updateAppBody'); + } else if (wireRemedy === 'update-host') { + title = t('updateHostTitle'); + body = t('updateHostBody'); + } + return ( @@ -38,12 +52,14 @@ export function HostConnectionState({ ) : ( <> - + - {t('unavailableTitle')} - - {t('error', { url })} - + {title} + {body} diff --git a/packages/client/workbench/src/runtime/provider.tsx b/packages/client/workbench/src/runtime/provider.tsx index d539ba097..fda7ad0d1 100644 --- a/packages/client/workbench/src/runtime/provider.tsx +++ b/packages/client/workbench/src/runtime/provider.tsx @@ -81,6 +81,16 @@ export function useWorkbenchRuntimeEndpoint(): string | undefined { ); } +/** The last connection failure, for copy that depends on its type (a wire skew names an update). */ +export function useWorkbenchRuntimeError(): unknown { + const controller = useWorkbenchConnectionController(); + return useSyncExternalStore( + controller.subscribe, + () => controller.getSnapshot().error, + () => controller.getSnapshot().error, + ); +} + export function useWorkbenchRuntimeRetry(): () => void { return useWorkbenchConnectionController().retry; } diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 384b6ac09..64e496631 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -121,6 +121,8 @@ export const en = { error: 'Unable to connect to the daemon ({url}). Run {command} first.', errorManaged: 'Unable to connect to the daemon ({url}). It starts automatically with the app — retry, and restart the app if this keeps failing.', + updateApp: 'This LinkCode build is too old to talk to the daemon at {url}. Update the app.', + updateHost: 'The daemon at {url} is too old for this LinkCode build. Update the daemon.', retry: 'Retry', }, errors: { diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index a69a3354f..e09c0b615 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -117,6 +117,8 @@ export const zhCN = { error: '无法连接到 daemon({url})。请先运行 {command}。', errorManaged: '无法连接到 daemon({url})。它会随应用自动启动——请重试,若持续失败请重启应用。', + updateApp: '此版本的 LinkCode 过旧,无法与 daemon({url})通信。请更新应用。', + updateHost: 'daemon({url})的版本过旧,无法与此版本的 LinkCode 通信。请更新 daemon。', retry: '重试', }, errors: { From b992236da4d21ee981dc192685ea246df661fe63 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Fri, 11 Sep 2026 12:14:46 +0800 Subject: [PATCH 6/7] fix(mobile,docs): drop the pointless retry on a wire skew and stop exempting desktop from the advisory --- .../components/host/host-connection-state.tsx | 21 ++++++++++++------- docs/ARCHITECTURE.md | 10 +++++---- docs/RELEASE.md | 3 ++- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/components/host/host-connection-state.tsx b/apps/mobile/src/components/host/host-connection-state.tsx index c7e5c85c1..506a41afa 100644 --- a/apps/mobile/src/components/host/host-connection-state.tsx +++ b/apps/mobile/src/components/host/host-connection-state.tsx @@ -61,13 +61,20 @@ export function HostConnectionState({ {title} {body} -