diff --git a/AGENTS.md b/AGENTS.md index e9f69d6b4..9a02e9fb1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Each of these breaks the product, a release, or the build with **no loud error**. -1. **Two wire versions, and only one of them is lockstep.** Both live in `packages/foundation/schema/src/wire/message.ts` (read the values there — they are deliberately not repeated here). `WIRE_PROTOCOL_VERSION` is what a build stamps and **every** wire change bumps it. `MIN_COMPATIBLE_WIRE_VERSION` is the oldest a build still accepts, and it moves **only for a breaking change** — a variant or field removed, renamed, or given a new meaning. Get that call wrong in the additive direction and nothing breaks; get it wrong in the breaking direction and peers silently misread each other. An unrecognized `kind` from a newer peer is dropped by itself (logged once per connection) and the connection lives on, so adding a frame no longer forces every peer to upgrade together. A peer *below* the floor is still the hard case: its frames are refused, it never answers a `ping`, and the handshake ends in the 5s timeout — the drop is logged, but only an out-of-band probe can name it (CODE-447). +1. **Two wire versions, and only one of them is lockstep.** Both live in `packages/foundation/schema/src/wire/message.ts` (read the values there — they are deliberately not repeated here). `WIRE_PROTOCOL_VERSION` is what a build stamps and **every** wire change bumps it. `MIN_COMPATIBLE_WIRE_VERSION` is the oldest a build still accepts, and it moves **only for a breaking change** — a variant or field removed, renamed, or given a new meaning. Get that call wrong in the additive direction and nothing breaks; get it wrong in the breaking direction and peers silently misread each other. An unrecognized `kind` from a newer peer is dropped by itself (logged once per connection) and the connection lives on, so adding a frame no longer forces every peer to upgrade together. A peer *below* the floor is refused frame by frame — except the handshake: `ping`/`pong` are accepted whatever version they carry, so the skew is named on both sides ("update this app" / "update the host") instead of ending in the 5s timeout. Moving the floor is gated on that advisory having shipped in the clients it will refuse — see `docs/RELEASE.md`. 2. **`foxts/once` prewarms by default.** `once(fn)` runs `fn` immediately at construction and caches the result; call-at-most-once semantics need `once(fn, false)`. The default has already shipped a daemon that ran its shutdown at boot and transports whose close-callback fired at construction. Read any foxts helper's `.d.ts`/source before adopting it — the lodash-alike name lies. 3. **Native deps must be allow-listed.** pnpm blocks install scripts by default; a native dep (e.g. `better-sqlite3`) missing from `allowBuilds:` in `pnpm-workspace.yaml` installs fine but fails at `require()` time with missing bindings. 4. **`check:ci` does not run `pnpm test`.** CI runs vitest as a separate TypeScript-job step, while `check:ci` remains format/lint/typecheck only. Run both commands before every commit; passing either one alone is not the complete JavaScript gate. 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..506a41afa 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,20 +52,29 @@ 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/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(); + } + }); +}); diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index cbbfbbc94..5e9e522c6 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: { @@ -1427,6 +1429,11 @@ export const en = { connecting: 'Connecting to the host…', unavailableTitle: 'Host unavailable', error: 'Unable to reach the host at {url}.', + updateAppTitle: 'Update LinkCode', + updateAppBody: 'This version of the app is too old to talk to that host. Update the app.', + updateHostTitle: 'Update the host', + updateHostBody: + 'That host runs a LinkCode version too old for this app. Update LinkCode on the computer and try again.', retry: 'Retry', }, settings: { diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index b6a7e0ddb..593864aeb 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: { @@ -1383,6 +1385,11 @@ export const zhCN = { connecting: '正在连接 host…', unavailableTitle: '无法连接 host', error: '无法连接 host({url})。', + updateAppTitle: '请更新 LinkCode', + updateAppBody: '此版本的应用过旧,无法连接到该 host。请更新应用。', + updateHostTitle: '请更新 host', + updateHostBody: + '该 host 的 LinkCode 版本过旧,无法与此版本的应用通信。请在电脑上更新 LinkCode。', retry: '重试', }, settings: {