authorgravatar for 128746408+eeinarsson@users.noreply.github.comeeinarsson <128746408+eeinarsson@users.noreply.github.com> 2026-07-20 01:33:01+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-20 01:33:01+02:00
log2640e6dcfcb6a5ba2f01f20295ce8bacd2fdb05e
tree210a8fa0ba371e6190a231c6826852289a801bbc
parente34250df469dcf50b444777f903b5f3482b4ce3c
signature Signed by PGP key B5690EEEBB952194

fix(client): use lightweight connection probe (#4137)


8 files changed, 105 insertions(+), 7 deletions(-)

apps/server/src/environment/ServerEnvironment.test.ts+1
......@@ -67,6 +67,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
6767
6868 expect(first.environmentId).toBe(second.environmentId);
6969 expect(second.capabilities.repositoryIdentity).toBe(true);
70 expect(second.capabilities.connectionProbe).toBe(true);
7071 }),
7172 );
7273
apps/server/src/environment/ServerEnvironment.ts+1
......@@ -135,6 +135,7 @@ export const make = Effect.gen(function* () {
135135 serverVersion: packageJson.version,
136136 capabilities: {
137137 repositoryIdentity: true,
138 connectionProbe: true,
138139 },
139140 };
140141
apps/server/src/ws.ts+5
......@@ -284,6 +284,7 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([
284284 [ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope],
285285 [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope],
286286 [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope],
287 [WS_METHODS.serverProbe, AuthOrchestrationReadScope],
287288 [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope],
288289 [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope],
289290 [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope],
......@@ -1238,6 +1239,10 @@ const makeWsRpcLayer = (
12381239 }),
12391240 { "rpc.aggregate": "orchestration" },
12401241 ),
1242 [WS_METHODS.serverProbe]: (_input) =>
1243 observeRpcEffect(WS_METHODS.serverProbe, Effect.succeed({}), {
1244 "rpc.aggregate": "server",
1245 }),
12411246 [WS_METHODS.serverGetConfig]: (_input) =>
12421247 observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, {
12431248 "rpc.aggregate": "server",
packages/client-runtime/src/connection/supervisor.test.ts+1-1
......@@ -652,7 +652,7 @@ describe("EnvironmentSupervisor", () => {
652652 }).pipe(Effect.provide(TestClock.layer())),
653653 );
654654
655 it.effect("keeps a healthy session when the application becomes active", () =>
655 it.effect("probes the active session without reconnecting on application activation", () =>
656656 Effect.gen(function* () {
657657 const probeCount = yield* Ref.make(0);
658658 const probeCalled = yield* Deferred.make<void>();
packages/client-runtime/src/rpc/session.test.ts+78-2
......@@ -109,6 +109,7 @@ const SERVER_CONFIG: ServerConfigType = {
109109 serverVersion: "0.0.0-test",
110110 capabilities: {
111111 repositoryIdentity: true,
112 connectionProbe: true,
112113 },
113114 },
114115 auth: {
......@@ -141,6 +142,16 @@ const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString);
141142const decodeRpcRequest = Schema.decodeUnknownSync(RpcRequest);
142143const encodeJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString);
143144const encodeServerConfig = Schema.encodeSync(ServerConfig);
145const ENCODED_SERVER_CONFIG = encodeServerConfig(SERVER_CONFIG);
146const LEGACY_SERVER_CONFIG = {
147 ...ENCODED_SERVER_CONFIG,
148 environment: {
149 ...ENCODED_SERVER_CONFIG.environment,
150 capabilities: {
151 repositoryIdentity: true,
152 },
153 },
154};
144155
145156const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* () {
146157 const sockets: TestWebSocket[] = [];
......@@ -169,9 +180,10 @@ const awaitSocket = Effect.fn("TestRpcSessionFactory.awaitSocket")(function* (
169180
170181const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* (
171182 socket: TestWebSocket,
183 index = 0,
172184) {
173185 for (let attempt = 0; attempt < 100; attempt += 1) {
174 const request = socket.sent[0];
186 const request = socket.sent[index];
175187 if (request) {
176188 return decodeRpcRequest(decodeJson(request));
177189 }
......@@ -182,6 +194,7 @@ const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* (
182194
183195const completeInitialConfig = Effect.fn("TestRpcSessionFactory.completeInitialConfig")(function* (
184196 socket: TestWebSocket,
197 config: unknown = ENCODED_SERVER_CONFIG,
185198) {
186199 const request = yield* awaitRequest(socket);
187200 expect(request).toMatchObject({
......@@ -195,7 +208,7 @@ const completeInitialConfig = Effect.fn("TestRpcSessionFactory.completeInitialCo
195208 requestId: request.id,
196209 exit: {
197210 _tag: "Success",
198 value: encodeServerConfig(SERVER_CONFIG),
211 value: config,
199212 },
200213 }),
201214 );
......@@ -218,6 +231,30 @@ describe("RpcSessionFactory", () => {
218231 expect(config).toEqual(SERVER_CONFIG);
219232 expect(socket.sent).toHaveLength(1);
220233
234 const probeFiber = yield* Effect.forkChild(session.probe);
235 const probeRequest = yield* awaitRequest(socket, 1);
236 expect(probeRequest).toMatchObject({
237 _tag: "Request",
238 tag: WS_METHODS.serverProbe,
239 payload: {},
240 });
241 socket.serverMessage(
242 encodeJson({
243 _tag: "Exit",
244 requestId: probeRequest.id,
245 exit: {
246 _tag: "Success",
247 value: {},
248 },
249 }),
250 );
251 yield* Fiber.join(probeFiber);
252
253 expect(socket.sent.map((request) => decodeRpcRequest(decodeJson(request)).tag)).toEqual([
254 WS_METHODS.serverGetConfig,
255 WS_METHODS.serverProbe,
256 ]);
257
221258 socket.close(1012, "service restart");
222259 const error = yield* Effect.flip(session.closed);
223260
......@@ -250,6 +287,45 @@ describe("RpcSessionFactory", () => {
250287 }),
251288 );
252289
290 it.effect("uses the legacy config RPC for probes when the server lacks the capability", () =>
291 Effect.scoped(
292 Effect.gen(function* () {
293 const { factory, sockets } = yield* makeFactory();
294 const session = yield* factory.connect(PREPARED);
295 const readyFiber = yield* Effect.forkChild(session.ready);
296 const socket = yield* awaitSocket(sockets);
297
298 socket.open();
299 yield* completeInitialConfig(socket, LEGACY_SERVER_CONFIG);
300 yield* Fiber.join(readyFiber);
301
302 const probeFiber = yield* Effect.forkChild(session.probe);
303 const probeRequest = yield* awaitRequest(socket, 1);
304 expect(probeRequest).toMatchObject({
305 _tag: "Request",
306 tag: WS_METHODS.serverGetConfig,
307 payload: {},
308 });
309 socket.serverMessage(
310 encodeJson({
311 _tag: "Exit",
312 requestId: probeRequest.id,
313 exit: {
314 _tag: "Success",
315 value: LEGACY_SERVER_CONFIG,
316 },
317 }),
318 );
319 yield* Fiber.join(probeFiber);
320
321 expect(socket.sent.map((request) => decodeRpcRequest(decodeJson(request)).tag)).toEqual([
322 WS_METHODS.serverGetConfig,
323 WS_METHODS.serverGetConfig,
324 ]);
325 }),
326 ),
327 );
328
253329 it.effect("fails readiness when the websocket never opens", () =>
254330 Effect.gen(function* () {
255331 const { factory, sockets } = yield* makeFactory();
packages/client-runtime/src/rpc/session.ts+10-4
......@@ -42,8 +42,9 @@ export class RpcSessionFactory extends Context.Service<
4242type InitialConfigError = Effect.Error<
4343 ReturnType<WsRpcProtocolClient[typeof WS_METHODS.serverGetConfig]>
4444>;
45type ProbeError = Effect.Error<ReturnType<WsRpcProtocolClient[typeof WS_METHODS.serverProbe]>>;
4546
46function mapInitialConfigError(error: InitialConfigError): ConnectionAttemptError {
47function mapSessionRpcError(error: InitialConfigError | ProbeError): ConnectionAttemptError {
4748 switch (error._tag) {
4849 case "EnvironmentAuthorizationError":
4950 return new ConnectionBlockedError({
......@@ -115,12 +116,17 @@ export const make = Effect.gen(function* () {
115116 const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext));
116117 const initialConfig = yield* Effect.cached(
117118 client[WS_METHODS.serverGetConfig]({}).pipe(
118 Effect.mapError(mapInitialConfigError),
119 Effect.mapError(mapSessionRpcError),
119120 Effect.withSpan("environment.initialSync"),
120121 ),
121122 );
122 const probe = client[WS_METHODS.serverGetConfig]({}).pipe(
123 Effect.mapError(mapInitialConfigError),
123 const probe = initialConfig.pipe(
124 Effect.flatMap((config) =>
125 (config.environment.capabilities.connectionProbe === true
126 ? client[WS_METHODS.serverProbe]({})
127 : client[WS_METHODS.serverGetConfig]({})
128 ).pipe(Effect.mapError(mapSessionRpcError)),
129 ),
124130 Effect.asVoid,
125131 Effect.withSpan("clientRuntime.connection.rpcSession.probe"),
126132 );
packages/contracts/src/environment.ts+1
......@@ -22,6 +22,7 @@ export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.T
2222
2323export const ExecutionEnvironmentCapabilities = Schema.Struct({
2424 repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
25 connectionProbe: Schema.optionalKey(Schema.Boolean),
2526});
2627export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type;
2728
packages/contracts/src/rpc.ts+8
......@@ -201,6 +201,7 @@ export const WS_METHODS = {
201201 previewAutomationFocusHost: "previewAutomation.focusHost",
202202
203203 // Server meta
204 serverProbe: "server.probe",
204205 serverGetConfig: "server.getConfig",
205206 serverRefreshProviders: "server.refreshProviders",
206207 serverUpdateProvider: "server.updateProvider",
......@@ -246,6 +247,12 @@ export const WsServerRemoveKeybindingRpc = Rpc.make(WS_METHODS.serverRemoveKeybi
246247 error: Schema.Union([KeybindingsConfigError, EnvironmentAuthorizationError]),
247248});
248249
250export const WsServerProbeRpc = Rpc.make(WS_METHODS.serverProbe, {
251 payload: Schema.Struct({}),
252 success: Schema.Struct({}),
253 error: EnvironmentAuthorizationError,
254});
255
249256export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, {
250257 payload: Schema.Struct({}),
251258 success: ServerConfig,
......@@ -682,6 +689,7 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess,
682689});
683690
684691export const WsRpcGroup = RpcGroup.make(
692 WsServerProbeRpc,
685693 WsServerGetConfigRpc,
686694 WsServerRefreshProvidersRpc,
687695 WsServerUpdateProviderRpc,