Skip to content

Commit ef760e5

Browse files
yyq1025claude
andcommitted
protocol: symmetric compat check + name the outdated side on version mismatch
- isProtocolCompatible: replace npm caret (asymmetric — rejected a remote with a lower patch on the same line) with symmetric same-breaking-line comparison, the faithful implementation of the bump policy (0.x: same minor; >=1.0: same major; prerelease: exact match) - new outdatedSide(remote) helper: semver-lower side is the outdated one; null on equal/garbage input - error frame: optional protocolVersion field, set by the daemon on version-mismatch rejections (deliberately absent on the frame-before-hello misuse rejection where direction is meaningless) - app: IncompatibleProtocolError gains outdatedSide ("app" | "daemon" | "unknown") and direction-specific copy — "update the Mac app" vs "update this app" vs the neutral both-sides fallback - protocol 0.0.0 -> 0.0.1 (additive field) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 98bea53 commit ef760e5

6 files changed

Lines changed: 139 additions & 52 deletions

File tree

packages/app/src/lib/daemon-client-context.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -303,9 +303,10 @@ export function DaemonClientProvider({ children }: { children: ReactNode }) {
303303
if (!shouldReconnectRef.current) return;
304304
// Protocol mismatch is TERMINAL — retrying can't change a
305305
// version check. Stop the auto-reconnect loop and surface a
306-
// terminal `error` (red, "update both"); the user re-attempts
307-
// via reset() after updating. KEEP `paired` so the update
308-
// screen can still show host context, like the offline path.
306+
// terminal `error` (the message names which side is outdated
307+
// via `err.outdatedSide`); the user re-attempts via reset()
308+
// after updating. KEEP `paired` so the update screen can
309+
// still show host context, like the offline path.
309310
if (err instanceof IncompatibleProtocolError) {
310311
shouldReconnectRef.current = false;
311312
setState({ status: "error", error: err });

packages/app/src/lib/daemon-client.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type ImageAttachment,
1212
isChunkEnvelope,
1313
isProtocolCompatible,
14+
outdatedSide,
1415
PAIR_OFFER_VERSION,
1516
PROTOCOL_VERSION,
1617
type SessionState,
@@ -26,26 +27,40 @@ import { WebRTCPeer } from "./webrtc-peer";
2627

2728
/**
2829
* Thrown by the connect handshake when the daemon and app speak
29-
* wire-incompatible protocol versions (either side too old — the caret
30-
* compat gate is symmetric, so we can't tell which from here, and it
31-
* doesn't matter: "update both to latest" is always-correct guidance).
30+
* wire-incompatible protocol versions. The daemon's rejection frame (and
31+
* `server_info`) carry its PROTOCOL_VERSION, so `outdatedSide` names the
32+
* side that needs updating — the semver-lower side — and the message is
33+
* tailored to it. `"unknown"` (→ direction-neutral "update both" copy)
34+
* only happens when the version is missing or unparseable, e.g. the
35+
* frame-before-hello misuse rejection which deliberately omits it.
3236
*
3337
* Unlike a transient unreachable-daemon failure, this is TERMINAL: no
3438
* amount of retrying changes the version check. `DaemonClientProvider`
3539
* routes it to a terminal `error` state and stops the auto-reconnect loop
36-
* (the user re-attempts via `reset()` after updating). `daemonProtocolVersion`
37-
* is best-effort for diagnostics — null when the daemon rejected our `hello`
38-
* before we could read its version; the user-facing copy never names a side.
40+
* (the user re-attempts via `reset()` after updating).
3941
*/
4042
export class IncompatibleProtocolError extends Error {
4143
readonly appProtocolVersion = PROTOCOL_VERSION;
4244
readonly daemonProtocolVersion: string | null;
45+
/** Which side is too old. `"daemon"` → update the Mac app, `"app"` →
46+
* update this app, `"unknown"` → no usable daemon version, update both. */
47+
readonly outdatedSide: "app" | "daemon" | "unknown";
4348
constructor(daemonProtocolVersion: string | null = null) {
49+
const side =
50+
daemonProtocolVersion === null
51+
? null
52+
: outdatedSide(daemonProtocolVersion);
4453
super(
45-
"Sidecode is out of date. Update both the iPhone app and the Mac app to the latest version, then try again.",
54+
side === "remote"
55+
? "The Mac app is out of date. Update Sidecode on your Mac, then try again."
56+
: side === "local"
57+
? "This app is out of date. Update Sidecode from the App Store, then try again."
58+
: "Sidecode is out of date. Update both the iPhone app and the Mac app to the latest version, then try again.",
4659
);
4760
this.name = "IncompatibleProtocolError";
4861
this.daemonProtocolVersion = daemonProtocolVersion;
62+
this.outdatedSide =
63+
side === "remote" ? "daemon" : side === "local" ? "app" : "unknown";
4964
}
5065
}
5166

@@ -416,9 +431,10 @@ export class Transport {
416431
`daemon reported incompatible_protocol: ${frame.message}`,
417432
);
418433
}
419-
// Daemon's error frame carries no structured version (only the
420-
// freetext message above), so direction is unknown — fine, the
421-
// copy never names a side.
434+
// Version-mismatch rejections carry the daemon's structured
435+
// protocolVersion → the error names which side is outdated.
436+
// The frame-before-hello misuse rejection omits it → falls
437+
// back to the direction-neutral "update both" copy.
422438
fail(
423439
new IncompatibleProtocolError(frame.protocolVersion ?? null),
424440
);

packages/daemon/src/webrtc-peer.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,9 @@ export class WebRTCPeerServer {
522522
type: "error",
523523
code: "incompatible_protocol",
524524
message: `client protocol ${frame.protocolVersion} is not compatible with daemon ${PROTOCOL_VERSION}`,
525+
// Structured copy of our version so iOS can tell WHICH side is
526+
// outdated (`outdatedSide`) and name it in the error copy.
527+
protocolVersion: PROTOCOL_VERSION,
525528
});
526529
this.closePeer(slot, "incompatible wire protocol");
527530
return;

packages/protocol/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@sidecodeapp/protocol",
3-
"version": "0.0.0",
3+
"version": "0.0.1",
44
"private": true,
55
"description": "Wire protocol shared between sidecode daemon and clients",
66
"type": "module",

packages/protocol/src/index.test.ts

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
interruptCommand,
1717
interruptResponse,
1818
isProtocolCompatible,
19+
outdatedSide,
1920
PAIR_OFFER_VERSION,
2021
PROTOCOL_VERSION,
2122
pairOfferFrame,
@@ -48,28 +49,21 @@ describe("isProtocolCompatible", () => {
4849
expect(isProtocolCompatible(PROTOCOL_VERSION)).toBe(true);
4950
});
5051

51-
it("npm caret semantics for 0.0.x: exact-match only (every change is breaking)", () => {
52-
// npm convention: ^0.0.0 → >=0.0.0 <0.0.1. No upgrades allowed
53-
// because at 0.0.x every change is potentially breaking. We surface
54-
// this through `^PROTOCOL_VERSION`. If PROTOCOL_VERSION is in this
55-
// range, anything other than exact-match is rejected.
56-
if (PROTOCOL_VERSION.startsWith("0.0.")) {
57-
const [, , patch] = PROTOCOL_VERSION.split(".").map(Number);
58-
expect(isProtocolCompatible(`0.0.${patch + 1}`)).toBe(false);
59-
expect(isProtocolCompatible(`0.1.0`)).toBe(false);
60-
expect(isProtocolCompatible(`1.0.0`)).toBe(false);
61-
}
62-
});
63-
64-
it("npm caret semantics for 0.x.y (x≥1): same minor + patch ≥ local", () => {
65-
// ^0.5.3 → >=0.5.3 <0.6.0. Patch upgrades OK, minor bump breaking.
66-
if (
67-
PROTOCOL_VERSION.startsWith("0.") &&
68-
!PROTOCOL_VERSION.startsWith("0.0.")
69-
) {
52+
it("same breaking line during 0.x: patch differences compatible BOTH directions", () => {
53+
// Bump policy: patch = additive only, old peers harmlessly ignore.
54+
// The check is symmetric same-`0.minor`-line — deliberately NOT npm
55+
// caret (which rejects a remote with a LOWER patch than ours).
56+
if (PROTOCOL_VERSION.startsWith("0.")) {
7057
const [, minor, patch] = PROTOCOL_VERSION.split(".").map(Number);
7158
expect(isProtocolCompatible(`0.${minor}.${patch + 5}`)).toBe(true);
59+
if (patch > 0) {
60+
expect(isProtocolCompatible(`0.${minor}.${patch - 1}`)).toBe(true);
61+
}
7262
expect(isProtocolCompatible(`0.${minor + 1}.0`)).toBe(false);
63+
if (minor > 0) {
64+
expect(isProtocolCompatible(`0.${minor - 1}.0`)).toBe(false);
65+
}
66+
expect(isProtocolCompatible(`1.0.0`)).toBe(false);
7367
}
7468
});
7569

@@ -89,6 +83,33 @@ describe("isProtocolCompatible", () => {
8983
});
9084
});
9185

86+
describe("outdatedSide", () => {
87+
it("semver-lower remote → remote is the outdated side", () => {
88+
const [major, minor, patch] = PROTOCOL_VERSION.split(".").map(Number);
89+
expect(outdatedSide(`${major}.${minor}.${patch + 1}`)).toBe("local");
90+
expect(outdatedSide(`${major}.${minor + 1}.0`)).toBe("local");
91+
expect(outdatedSide(`${major + 1}.0.0`)).toBe("local");
92+
if (patch > 0) {
93+
expect(outdatedSide(`${major}.${minor}.${patch - 1}`)).toBe("remote");
94+
}
95+
if (minor > 0) {
96+
expect(outdatedSide(`${major}.${minor - 1}.0`)).toBe("remote");
97+
}
98+
if (major > 0) {
99+
expect(outdatedSide(`${major - 1}.0.0`)).toBe("remote");
100+
}
101+
});
102+
103+
it("equal version → null (a peer rejecting us at OUR version is misbehaving, not outdated)", () => {
104+
expect(outdatedSide(PROTOCOL_VERSION)).toBe(null);
105+
});
106+
107+
it("garbage input → null (caller falls back to direction-neutral copy)", () => {
108+
expect(outdatedSide("not-a-version")).toBe(null);
109+
expect(outdatedSide("")).toBe(null);
110+
});
111+
});
112+
92113
describe("pair offer", () => {
93114
it("PAIR_OFFER_VERSION is 2", () => {
94115
// Bumping forces an old iOS app to fail-decode an incompatible QR
@@ -443,6 +464,20 @@ describe("error frame", () => {
443464
}).code,
444465
).toBe("incompatible_protocol");
445466
});
467+
468+
it("preserves the sender's protocolVersion on incompatible_protocol", () => {
469+
// The field must be in the schema — z.object strips unknown keys, so
470+
// a zod-parsed path would silently drop it and iOS couldn't tell
471+
// which side is outdated.
472+
expect(
473+
errorFrame.parse({
474+
type: "error",
475+
code: "incompatible_protocol",
476+
message: "client protocol 0.1.0 is not compatible with daemon 0.2.0",
477+
protocolVersion: "0.2.0",
478+
}).protocolVersion,
479+
).toBe("0.2.0");
480+
});
446481
});
447482

448483
describe("clientFrame union (commands only — no application-layer handshake)", () => {

packages/protocol/src/index.ts

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -70,30 +70,54 @@ export const PROTOCOL_VERSION: string = pkg.version;
7070
/**
7171
* True iff a remote peer reporting `remote` speaks a wire-compatible
7272
* schema set with this build's PROTOCOL_VERSION. Compatible means
73-
* `remote` satisfies a caret range over PROTOCOL_VERSION:
73+
* "same breaking line", per the bump policy above:
7474
*
75-
* - `^0.5.7` → compatible with `0.5.x` (any patch), not `0.6.0`
76-
* - `^1.2.3` → compatible with `1.x.y` (any minor or patch ≥ 1.2.3)
75+
* - during 0.x the minor is the breaking axis → same `0.minor` line
76+
* (`0.5.1` ↔ `0.5.9` compatible in both directions, `0.6.0` not)
77+
* - ≥1.0 the major is the breaking axis → same major
78+
* - pre-release on either side → exact match only (mid-development
79+
* schemas churn; two builds are only known-compatible if identical)
7780
*
78-
* This is npm's standard caret semantics — same rule npm uses to decide
79-
* which versions satisfy a `^x.y.z` dependency. The semver package
80-
* handles pre-release tags / build metadata / edge cases correctly,
81-
* which a hand-rolled major-comparison would miss.
81+
* Deliberately NOT npm caret semantics. `satisfies(remote, ^local)` is
82+
* asymmetric — it rejects a remote with a LOWER patch on the same
83+
* breaking line, but the bump policy says patch bumps are additive and
84+
* old peers harmlessly ignore them. Symmetric same-line comparison is
85+
* the faithful implementation, and it guarantees the daemon (which
86+
* checks `hello` first) always rejects before iOS gets a chance to —
87+
* iOS's `server_info` re-check is pure drift defense.
8288
*
8389
* Returns `false` on unparseable input (defensive — we'd rather refuse
8490
* than blunder on with an unknown version that might or might not be
8591
* compatible).
8692
*/
8793
export function isProtocolCompatible(remote: string): boolean {
88-
if (!semver.valid(remote) || !semver.valid(PROTOCOL_VERSION)) return false;
89-
return semver.satisfies(remote, `^${PROTOCOL_VERSION}`, {
90-
// includePrerelease: false — pre-release versions don't satisfy
91-
// a caret range over a stable version. This is npm's default and
92-
// it's the right call: a 1.2.0-beta.1 daemon shouldn't be
93-
// considered compatible with a 1.0.0 client (the beta may have
94-
// incompatible schemas mid-development).
95-
includePrerelease: false,
96-
});
94+
const r = semver.parse(remote);
95+
const local = semver.parse(PROTOCOL_VERSION);
96+
if (!r || !local) return false;
97+
if (r.prerelease.length > 0 || local.prerelease.length > 0) {
98+
return semver.eq(r, local);
99+
}
100+
if (r.major !== local.major) return false;
101+
return local.major === 0 ? r.minor === local.minor : true;
102+
}
103+
104+
/**
105+
* Which side is outdated, given the version an INCOMPATIBLE remote
106+
* reported. Only meaningful after `isProtocolCompatible(remote)`
107+
* returned false — incompatible versions are never equal, so the
108+
* semver-lower side is the one that needs updating.
109+
*
110+
* Returns from the caller's perspective: `"remote"` → the peer is
111+
* older (iOS calling this → tell the user to update the Mac app),
112+
* `"local"` → this build is older. Returns `null` when the input is
113+
* unparseable or equal to ours (a peer that rejected us while
114+
* reporting our own version is misbehaving, not outdated) — callers
115+
* fall back to direction-neutral "update both" copy.
116+
*/
117+
export function outdatedSide(remote: string): "local" | "remote" | null {
118+
if (!semver.valid(remote) || !semver.valid(PROTOCOL_VERSION)) return null;
119+
if (semver.eq(remote, PROTOCOL_VERSION)) return null;
120+
return semver.lt(remote, PROTOCOL_VERSION) ? "remote" : "local";
97121
}
98122

99123
// ─── Pair offer ────────────────────────────────────────────────────────────
@@ -1320,15 +1344,23 @@ export const errorFrame = z.object({
13201344
"not_a_directory",
13211345
]),
13221346
message: z.string(),
1347+
/** Sender's PROTOCOL_VERSION. Set on `incompatible_protocol` VERSION-
1348+
* mismatch rejections so the receiver can run `outdatedSide()` and
1349+
* name the side that needs updating. Deliberately absent on the
1350+
* other `incompatible_protocol` use (frame-before-hello, a protocol-
1351+
* misuse rejection where versions may well be EQUAL and direction is
1352+
* meaningless) and on all other codes. */
1353+
protocolVersion: z.string().optional(),
13231354
});
13241355

13251356
// ─── Hello / server_info (wire-version handshake on DataChannel open) ─────
13261357
//
13271358
// iOS sends `hello` immediately after DC.open carrying its PROTOCOL_VERSION.
13281359
// Daemon checks compatibility via `isProtocolCompatible` and responds with
1329-
// its own `server_info` (or with `error{code:"incompatible_protocol"}` +
1330-
// DC close on mismatch). iOS treats `server_info` as the "ready" signal —
1331-
// application commands may only flow after it arrives.
1360+
// its own `server_info` (or with `error{code:"incompatible_protocol",
1361+
// protocolVersion}` + DC close on mismatch — the version lets iOS name
1362+
// which side is outdated via `outdatedSide`). iOS treats `server_info` as
1363+
// the "ready" signal — application commands may only flow after it arrives.
13321364
//
13331365
// Single field exchanged each way. There's no separate "app release
13341366
// version" / "daemon release version" — the protocol package owns the

0 commit comments

Comments
 (0)