authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-31 01:11:46+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-31 01:11:46+02:00
logdf78cda8bf9c0971300e1bf35251774d9fbc833a
tree9d267cf4505f84e80406ee23d73dd89c1563af18
parent605c2bb734bbebed0b19fc1ff6b514f41bd3659b
signature Signed by PGP key B5690EEEBB952194

fix(contracts): decode growing config unions forward-compatibly (#5055)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

6 files changed, 165 insertions(+), 5 deletions(-)

packages/client-runtime/src/rpc/session.test.ts+34
......@@ -287,6 +287,40 @@ describe("RpcSessionFactory", () => {
287287 }),
288288 );
289289
290 it.effect("reaches ready when a newer server sends unknown config members", () =>
291 Effect.gen(function* () {
292 const { factory, sockets } = yield* makeFactory();
293 const session = yield* factory.connect(PREPARED);
294 const readyFiber = yield* Effect.forkChild(session.ready);
295 const socket = yield* awaitSocket(sockets);
296 socket.open();
297
298 const shortcut = {
299 key: "p",
300 metaKey: false,
301 ctrlKey: false,
302 shiftKey: false,
303 altKey: false,
304 modKey: true,
305 };
306 yield* completeInitialConfig(socket, {
307 ...ENCODED_SERVER_CONFIG,
308 keybindings: [
309 { command: "someFuture.toggle", shortcut },
310 { command: "terminal.toggle", shortcut },
311 ],
312 issues: [{ kind: "keybindings.future-issue", message: "From a newer server" }],
313 availableEditors: ["some-future-editor", "zed"],
314 });
315 yield* Fiber.join(readyFiber);
316
317 const config = yield* session.initialConfig;
318 expect(config.keybindings).toEqual([{ command: "terminal.toggle", shortcut }]);
319 expect(config.issues).toEqual([]);
320 expect(config.availableEditors).toEqual(["zed"]);
321 }),
322 );
323
290324 it.effect("uses the legacy config RPC for probes when the server lacks the capability", () =>
291325 Effect.scoped(
292326 Effect.gen(function* () {
packages/contracts/src/baseSchemas.ts+25
......@@ -1,4 +1,5 @@
11import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";
23import * as Schema from "effect/Schema";
34import * as SchemaTransformation from "effect/SchemaTransformation";
45
......@@ -20,6 +21,30 @@ export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximu
2021export const IsoDateTime = Schema.String;
2122export type IsoDateTime = typeof IsoDateTime.Type;
2223
24/**
25 * Wire codec for server→client arrays whose element unions grow over time
26 * (new literal members, new struct variants). Decoding drops elements the
27 * current build cannot decode instead of failing the whole payload — a client
28 * has to keep decoding configs sent by servers newer than itself, and
29 * rejecting the payload would take down the connection over data the client
30 * couldn't act on anyway. Encoding is the plain array encoding.
31 */
32export const ForwardCompatibleArray = <Element extends Schema.Top>(element: Element) => {
33 const decodeElement = Schema.decodeUnknownOption(element as never);
34 return Schema.Array(Schema.Unknown).pipe(
35 Schema.decodeTo(
36 Schema.Array(element),
37 SchemaTransformation.transform<ReadonlyArray<Element["Encoded"]>, ReadonlyArray<unknown>>({
38 decode: (values) =>
39 values.filter((value) => Option.isSome(decodeElement(value))) as ReadonlyArray<
40 Element["Encoded"]
41 >,
42 encode: (values) => values,
43 }),
44 ),
45 );
46};
47
2348/**
2449 * Construct a branded identifier. Enforces non-empty trimmed strings
2550 */
packages/contracts/src/keybindings.test.ts+65
......@@ -20,6 +20,7 @@ const decode = <S extends Schema.Top>(
2020 >;
2121
2222const decodeResolvedRule = Schema.decodeUnknownEffect(ResolvedKeybindingRule as never);
23const encodeResolvedKeybindings = Schema.encodeEffect(ResolvedKeybindingsConfig);
2324
2425it.effect("parses keybinding rules", () =>
2526 Effect.gen(function* () {
......@@ -185,6 +186,70 @@ it.effect("parses resolved keybindings arrays", () =>
185186 }),
186187);
187188
189const shortcut = {
190 key: "p",
191 metaKey: false,
192 ctrlKey: false,
193 shiftKey: false,
194 altKey: false,
195 modKey: true,
196};
197
198it.effect("drops resolved rules with commands this build does not know", () =>
199 Effect.gen(function* () {
200 const parsed = yield* decode(ResolvedKeybindingsConfig, [
201 { command: "terminal.toggle", shortcut },
202 { command: "someFuture.toggle", shortcut },
203 { command: "filePicker.toggle", shortcut },
204 ]);
205 assert.deepEqual(
206 parsed.map((rule) => rule.command),
207 ["terminal.toggle", "filePicker.toggle"],
208 );
209 }),
210);
211
212it.effect("drops resolved rules with unknown when-node types", () =>
213 Effect.gen(function* () {
214 const parsed = yield* decode(ResolvedKeybindingsConfig, [
215 {
216 command: "terminal.toggle",
217 shortcut,
218 whenAst: { type: "xor", left: 1, right: 2 },
219 },
220 { command: "terminal.split", shortcut },
221 ]);
222 assert.deepEqual(
223 parsed.map((rule) => rule.command),
224 ["terminal.split"],
225 );
226 }),
227);
228
229it.effect("drops malformed resolved rule entries", () =>
230 Effect.gen(function* () {
231 const parsed = yield* decode(ResolvedKeybindingsConfig, [
232 "garbage",
233 { command: "terminal.toggle", shortcut },
234 null,
235 ]);
236 assert.deepEqual(
237 parsed.map((rule) => rule.command),
238 ["terminal.toggle"],
239 );
240 }),
241);
242
243it.effect("encodes resolved keybindings to the plain wire shape", () =>
244 Effect.gen(function* () {
245 const rules = [{ command: "terminal.toggle" as const, shortcut }];
246 const encoded = yield* encodeResolvedKeybindings(rules);
247 assert.deepEqual(encoded, rules);
248 const roundTripped = yield* decode(ResolvedKeybindingsConfig, encoded);
249 assert.deepEqual(roundTripped, rules);
250 }),
251);
252
188253it.effect("drops unknown fields in resolved keybinding rules", () =>
189254 decodeResolvedRule({
190255 command: "terminal.toggle",
packages/contracts/src/keybindings.ts+9-2
......@@ -1,5 +1,5 @@
11import * as Schema from "effect/Schema";
2import { TrimmedString } from "./baseSchemas.ts";
2import { ForwardCompatibleArray, TrimmedString } from "./baseSchemas.ts";
33
44export const MAX_KEYBINDING_VALUE_LENGTH = 64;
55export const MAX_KEYBINDING_WHEN_LENGTH = 256;
......@@ -155,7 +155,14 @@ export const ResolvedKeybindingRule = Schema.Struct({
155155}).annotate({ parseOptions: { onExcessProperty: "ignore" } });
156156export type ResolvedKeybindingRule = typeof ResolvedKeybindingRule.Type;
157157
158export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check(
158/**
159 * The command set grows over time, so a client may receive rules it cannot
160 * represent (a command or `when` node added after that client shipped).
161 * Decoding drops those rules instead of failing the whole payload —
162 * rejecting the config would take down the connection over a shortcut the
163 * client couldn't dispatch anyway.
164 */
165export const ResolvedKeybindingsConfig = ForwardCompatibleArray(ResolvedKeybindingRule).check(
159166 Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),
160167);
161168export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type;
packages/contracts/src/server.test.ts+25-1
......@@ -1,9 +1,11 @@
11import * as Schema from "effect/Schema";
22import { describe, expect, it } from "vite-plus/test";
33
4import { ServerProvider } from "./server.ts";
4import { ServerConfig, ServerProvider, ServerUpsertKeybindingResult } from "./server.ts";
55
66const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider);
7const decodeUpsertKeybindingResult = Schema.decodeUnknownSync(ServerUpsertKeybindingResult);
8const decodeAvailableEditors = Schema.decodeUnknownSync(ServerConfig.fields.availableEditors);
79
810describe("ServerProvider", () => {
911 it("defaults capability arrays when decoding provider snapshots", () => {
......@@ -72,3 +74,25 @@ describe("ServerProvider", () => {
7274 expect(parsed.continuation?.groupKey).toBe("codex:home:/Users/julius/.codex");
7375 });
7476});
77
78describe("server config forward compatibility", () => {
79 it("drops config issues with kinds this build does not know", () => {
80 const parsed = decodeUpsertKeybindingResult({
81 keybindings: [],
82 issues: [
83 { kind: "keybindings.invalid-entry", message: "Bad entry", index: 2 },
84 { kind: "keybindings.future-issue", message: "From a newer server" },
85 ],
86 });
87
88 expect(parsed.issues).toEqual([
89 { kind: "keybindings.invalid-entry", message: "Bad entry", index: 2 },
90 ]);
91 });
92
93 it("drops editor ids this build does not know", () => {
94 const parsed = decodeAvailableEditors(["zed", "some-future-editor", "vscode"]);
95
96 expect(parsed).toEqual(["zed", "vscode"]);
97 });
98});
packages/contracts/src/server.ts+7-2
......@@ -3,6 +3,7 @@ import * as Schema from "effect/Schema";
33import { ExecutionEnvironmentDescriptor, ServerSelfUpdateMethod } from "./environment.ts";
44import { ServerAuthDescriptor } from "./auth.ts";
55import {
6 ForwardCompatibleArray,
67 IsoDateTime,
78 NonNegativeInt,
89 PositiveInt,
......@@ -38,7 +39,9 @@ export const ServerConfigIssue = Schema.Union([
3839]);
3940export type ServerConfigIssue = typeof ServerConfigIssue.Type;
4041
41const ServerConfigIssues = Schema.Array(ServerConfigIssue);
42// Issue kinds grow over time; older clients must not fail the whole config
43// decode over a kind they cannot render.
44const ServerConfigIssues = ForwardCompatibleArray(ServerConfigIssue);
4245
4346export const ServerProviderState = Schema.Literals(["ready", "warning", "error", "disabled"]);
4447export type ServerProviderState = typeof ServerProviderState.Type;
......@@ -417,7 +420,9 @@ export const ServerConfig = Schema.Struct({
417420 keybindings: ResolvedKeybindingsConfig,
418421 issues: ServerConfigIssues,
419422 providers: ServerProviders,
420 availableEditors: Schema.Array(EditorId),
423 // Editor ids grow over time; drop ones this build does not know rather than
424 // failing the whole config decode.
425 availableEditors: ForwardCompatibleArray(EditorId),
421426 observability: ServerObservability,
422427 settings: ServerSettings,
423428 /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */