Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 : (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing the button is right for the flip-flop problem, but it leaves update-app with no in-session recovery path at all. hurryAlong already declines on this remedy (use-host-client.ts:57), so with the button gone nothing can re-dial: connectionFor(host) hands back the same wedged controller for the same host.id, and pruneConnections never disposes the selected host. A skew resolved host-side — someone rolls their daemon back to a compatible build — is then invisible until the app is force-quit.

That's mostly fine, because the intended remedy (an App Store update) restarts the process anyway. The mismatch is the copy: updateAppBody still ends "and try again" (en.ts:1434, zh-cn.ts:1389) on a screen that now offers nothing to try.

Technical details

The three mechanisms that together close every escape:

// apps/mobile/src/runtime/use-host-client.ts:55-58
const { status, error } = controller.getSnapshot();
if (status === 'ready') return;
if (error instanceof WireIncompatibleError && error.remedy === 'update-app') return;
controller.retry();
// apps/mobile/src/runtime/host-connection-pool.ts:20-28 — same id, same controller
// apps/mobile/src/runtime/host-connection-pool.ts:31-37 — prune only disposes what's not in `keep`
// apps/mobile/src/components/shell/host-connection-scope.tsx:16-25
const keep = new Set(
  keepHostsConnected ? hosts.map((entry) => entry.id) : host ? [host.id] : [],
);

keep always contains the selected host, so the wedged controller survives every re-render. The only escapes are force-quit, removing and re-adding the host (which mints a new host.id), or toggling keepHostsConnected for some other host.

Two ways out, either is fine:

  1. Drop "and try again" from updateAppBody in both locales — accepts the wedge and makes the copy honest.
  2. Let the foreground trigger redial once on update-app. The flip-flop the guard avoids is a repeated tap; a single re-dial on app foreground costs one "connecting" flash and catches the host-side rollback.

<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 @@ -325,8 +325,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
Loading