Skip to content
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/components/host/host-client-gate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
);
Expand Down
47 changes: 35 additions & 12 deletions apps/mobile/src/components/host/host-connection-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

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

Expand All @@ -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 (
<Host style={{ flex: 1 }} useViewportSizeMeasurement>
<VStack spacing={16}>
Expand All @@ -38,20 +52,29 @@ export function HostConnectionState({
</>
) : (
<>
<Image systemName="wifi.exclamationmark" size={44} modifiers={[SECONDARY]} />
<Image
systemName={wireRemedy ? 'arrow.down.circle' : 'wifi.exclamationmark'}
Comment thread
Zerlight marked this conversation as resolved.
size={44}
modifiers={[SECONDARY]}
/>
<VStack spacing={6}>
<Text modifiers={[TITLE, CENTERED]}>{t('unavailableTitle')}</Text>
<Text modifiers={[SECONDARY, CENTERED, textSelection(true)]}>
{t('error', { url })}
</Text>
<Text modifiers={[TITLE, CENTERED]}>{title}</Text>
<Text modifiers={[SECONDARY, CENTERED, textSelection(true)]}>{body}</Text>
</VStack>
<Button
label={t('retry')}
systemImage="arrow.clockwise"
modifiers={[buttonStyle('borderedProminent')]}
onPress={onRetry}
/>
{failure ? (
{/* An app below the host's floor has nothing to retry: redialing only flashes
"connecting" and lands back here. Updating the host is a real action, so that
skew keeps the button. */}
{wireRemedy === 'update-app' ? null : (
Comment thread
Zerlight marked this conversation as resolved.
<Button
label={t('retry')}
systemImage="arrow.clockwise"
modifiers={[buttonStyle('borderedProminent')]}
onPress={onRetry}
/>
)}
{/* The technical line distinguishes causes on an ordinary failure; under a named skew
it only repeats the copy above in triage voice. */}
{failure && wireRemedy === undefined ? (
<Text modifiers={[FOOTNOTE, SECONDARY, CENTERED, textSelection(true)]}>
{failure}
</Text>
Expand Down
16 changes: 14 additions & 2 deletions apps/mobile/src/runtime/use-host-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { LinkCodeClient } from '@linkcode/client-core';
import type { LinkCodeClient, WireIncompatibilityRemedy } from '@linkcode/client-core';
import { WireIncompatibleError } from '@linkcode/client-core';
import type { HostProfile } from '@mobile/stores/host-store';
import NetInfo from '@react-native-community/netinfo';
import { extractErrorMessage } from 'foxts/extract-error-message';
Expand All @@ -17,6 +18,8 @@ interface HostClientBase {
* tells neither the user nor a triager whether the host is down, unreachable, or speaking a
* different wire version — the causes need entirely different responses. */
readonly failure?: string;
/** Set when the failure is a wire skew the controller stopped retrying: which side must update. */
readonly wireRemedy?: WireIncompatibilityRemedy;
}

interface HostClientReady extends HostClientBase {
Expand Down Expand Up @@ -44,9 +47,15 @@ export function useHostClient(host: HostProfile): HostClientState {
const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot);

// Both triggers only ever hurry a stalled connection along; neither tears down a healthy one.
// An app too old for the host stays put: no foreground or network change can fix that, and
// redialing would only flip the update screen back to "connecting". A too-old host may have
// been updated meanwhile, so that skew still redials.
useEffect(() => {
const hurryAlong = (): void => {
if (controller.getSnapshot().status !== 'ready') controller.retry();
const { status, error } = controller.getSnapshot();
if (status === 'ready') return;
if (error instanceof WireIncompatibleError && error.remedy === 'update-app') return;
controller.retry();
};
const appState = AppState.addEventListener('change', (next) => {
if (next === 'active') hurryAlong();
Expand All @@ -70,6 +79,9 @@ export function useHostClient(host: HostProfile): HostClientState {
attempt: snapshot.attempt,
client: null,
failure: extractErrorMessage(snapshot.error, false) ?? undefined,
...(snapshot.error instanceof WireIncompatibleError && {
wireRemedy: snapshot.error.remedy,
}),
retry,
status: snapshot.status === 'error' ? 'error' : 'connecting',
};
Expand Down
15 changes: 13 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,8 +328,19 @@ at or above the floor whose `kind` this build has never heard of is dropped on i
once per connection) while its neighbours are delivered, and only a frame below the floor is
refused for its version. The handshake `pong` carries the answering build's `version` and
`minCompatible`, which a client reads through `peerWireVersion` to skip frames an older host
would drop; it also names the one skew both sides can still read (the host's floor moved past
the client) instead of leaving it to the handshake timeout.
would drop. `ping` and `pong` are the one exchange accepted whatever `v` they carry, so both
skews are named at handshake instead of ending in the timeout: a client below the host's floor
still gets the pong and reports that the app must update; a client whose floor has passed the
host reads the older pong and reports that the host must update. The tunnel needs no
wire-version surface of its own (it versions only its subprotocol and peer frames) — the relay
carries `WireMessage` frames opaquely and the pong returns on the same peer connection. Moving
`MIN_COMPATIBLE_WIRE_VERSION` waits until the clients it will refuse can render that advisory:
at least two shipped mobile releases carrying the update-required screen, and a desktop release
carrying it too — desktop's bundled daemon is lockstep with its renderer, but that renderer also
dials a daemon it did not ship (a `runtime.json` advertisement, or the Developer-tab override).
The handshake exchange itself has been in clients since wire v64 (2026-07-31, first released in
v0.13.0), but those builds only surface the technical message after their retry budget — the
procedure is in `docs/RELEASE.md`.

Who receives a host frame is declared in `wire/delivery.ts`, not decided in the transport: a
correlated reply follows its `replyTo` to the connection that asked, and everything else fans out
Expand Down
9 changes: 9 additions & 0 deletions docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ How to cut, sign, notarize, and publish the Electron desktop app, plus the packa
- **Package-manager bumps** close the release job, on stable `v*.*.*` tags only (no `-` prerelease suffix), each with a short-lived token minted from the org GitHub App (`BOT_APP_ID` / `BOT_APP_PRIVATE_KEY`) — absent secrets make them self-skip. **Homebrew**: token scoped to `arcboxlabs/homebrew-tap`, then that repo's `bump-cask` action for `linkcode` with the two DMG sha256s. **WinGet**: token scoped to the org fork `arcboxlabs/winget-pkgs`, then `vedantmgoyal9/winget-releaser` (`komac update --submit` underneath) opens a version PR on `microsoft/winget-pkgs` for `ArcBox.LinkCode` from the release's `.exe` assets. Both WinGet steps are `continue-on-error` so packaging never fails a release — check the step status in the release job, not the release itself. Two constraints: the package must **already exist** upstream (the action only updates; a from-scratch submission is `komac new`, no manifests are kept in this repo), and an App **installation** token only covers installed repos, so if the upstream PR comes back `resource not accessible by integration` the fallback is a classic PAT with `public_repo` for a bot user that can push to the fork.
- electron-builder derives artifact names and the updater feed from **package.json, not the tag**, so `build-desktop.yml` independently fails unless `v${version}` equals `GITHUB_REF_NAME`. The **GitHub Release** is for human downloads only — the updater reads the R2 feed. Tags containing `-` (e.g. `v1.2.3-beta.1`) publish as prerelease. `release-desktop.yml` concurrency is `cancel-in-progress: false` — never cancel a release mid-flight.

## Moving the compatibility floor

A release that bumps `MIN_COMPATIBLE_WIRE_VERSION` (root `AGENTS.md`, Invariant 1) refuses every client below it, so it ships only once those clients can say so themselves:

- The below-floor handshake advisory (`ping`/`pong` accepted at any version; the client renders an update-required state from the pong's `minCompatible`) must be in at least **two shipped mobile releases** before the floor moves — count from the first store build that contains the update screen, not from the handshake exchange itself (in clients since wire v64, 2026-07-31, first released in v0.13.0: those builds only show the technical message after their retry budget).
- **Desktop is not exempt, only unbounded.** Its bundled daemon is lockstep with its own renderer, but the renderer does not always dial that daemon: `resolveDaemonUrl()` prefers the Developer-tab `daemonUrl` override, then any daemon already advertising itself in `runtime.json` — and the supervisor deliberately stands down when another install's daemon already serves the machine. So a pre-advisory desktop build does meet a newer daemon, and the advisory must have shipped in a desktop release before the floor moves. Auto-update adoption bounds that wait; there is no store-review count to hold it to, so the trigger is a judgement call rather than a number. The webview has no store lag, but a cached bundle or a long-lived tab can be just as old and is covered by the same rule.
- Bump `WIRE_PROTOCOL_VERSION` and `MIN_COMPATIBLE_WIRE_VERSION` in the same commit, retire the frames and fields the floor makes unreachable, and delete the client's `peerWireVersion` gates that can no longer be false.
- Before tagging, check both rendered states by hand: an older client build against the new daemon shows the update-app state, and the new client against an older daemon the update-host state. The transport and client-core tests cover the mechanics; the release check is the screen.

## Mobile production builds

`build-mobile.yml` runs `eas build --local` once per platform: Android on
Expand Down
70 changes: 70 additions & 0 deletions packages/client/core/src/__tests__/connection-controller.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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();
});
});
57 changes: 52 additions & 5 deletions packages/client/core/src/__tests__/connection.test.ts
Original file line number Diff line number Diff line change
@@ -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[] = [];
Expand Down Expand Up @@ -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({
Expand All @@ -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();
});

Expand Down
Loading