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) => {...@@ -67,6 +67,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
6767
68 expect(first.environmentId).toBe(second.environmentId);68 expect(first.environmentId).toBe(second.environmentId);
69 expect(second.capabilities.repositoryIdentity).toBe(true);69 expect(second.capabilities.repositoryIdentity).toBe(true);
70 expect(second.capabilities.connectionProbe).toBe(true);
70 }),71 }),
71 );72 );
7273
apps/server/src/environment/ServerEnvironment.ts+1
...@@ -135,6 +135,7 @@ export const make = Effect.gen(function* () {...@@ -135,6 +135,7 @@ export const make = Effect.gen(function* () {
135 serverVersion: packageJson.version,135 serverVersion: packageJson.version,
136 capabilities: {136 capabilities: {
137 repositoryIdentity: true,137 repositoryIdentity: true,
138 connectionProbe: true,
138 },139 },
139 };140 };
140141
apps/server/src/ws.ts+5
...@@ -284,6 +284,7 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([...@@ -284,6 +284,7 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([
284 [ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope],284 [ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope],
285 [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope],285 [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope],
286 [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope],286 [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope],
287 [WS_METHODS.serverProbe, AuthOrchestrationReadScope],
287 [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope],288 [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope],
288 [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope],289 [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope],
289 [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope],290 [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope],
...@@ -1238,6 +1239,10 @@ const makeWsRpcLayer = (...@@ -1238,6 +1239,10 @@ const makeWsRpcLayer = (
1238 }),1239 }),
1239 { "rpc.aggregate": "orchestration" },1240 { "rpc.aggregate": "orchestration" },
1240 ),1241 ),
1242 [WS_METHODS.serverProbe]: (_input) =>
1243 observeRpcEffect(WS_METHODS.serverProbe, Effect.succeed({}), {
1244 "rpc.aggregate": "server",
1245 }),
1241 [WS_METHODS.serverGetConfig]: (_input) =>1246 [WS_METHODS.serverGetConfig]: (_input) =>
1242 observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, {1247 observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, {
1243 "rpc.aggregate": "server",1248 "rpc.aggregate": "server",
packages/client-runtime/src/connection/supervisor.test.ts+1-1
...@@ -652,7 +652,7 @@ describe("EnvironmentSupervisor", () => {...@@ -652,7 +652,7 @@ describe("EnvironmentSupervisor", () => {
652 }).pipe(Effect.provide(TestClock.layer())),652 }).pipe(Effect.provide(TestClock.layer())),
653 );653 );
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", () =>
656 Effect.gen(function* () {656 Effect.gen(function* () {
657 const probeCount = yield* Ref.make(0);657 const probeCount = yield* Ref.make(0);
658 const probeCalled = yield* Deferred.make<void>();658 const probeCalled = yield* Deferred.make<void>();
packages/client-runtime/src/rpc/session.test.ts+78-2
...@@ -109,6 +109,7 @@ const SERVER_CONFIG: ServerConfigType = {...@@ -109,6 +109,7 @@ const SERVER_CONFIG: ServerConfigType = {
109 serverVersion: "0.0.0-test",109 serverVersion: "0.0.0-test",
110 capabilities: {110 capabilities: {
111 repositoryIdentity: true,111 repositoryIdentity: true,
112 connectionProbe: true,
112 },113 },
113 },114 },
114 auth: {115 auth: {
...@@ -141,6 +142,16 @@ const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString);...@@ -141,6 +142,16 @@ const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString);
141const decodeRpcRequest = Schema.decodeUnknownSync(RpcRequest);142const decodeRpcRequest = Schema.decodeUnknownSync(RpcRequest);
142const encodeJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString);143const encodeJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString);
143const encodeServerConfig = Schema.encodeSync(ServerConfig);144const 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
145const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* () {156const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* () {
146 const sockets: TestWebSocket[] = [];157 const sockets: TestWebSocket[] = [];
...@@ -169,9 +180,10 @@ const awaitSocket = Effect.fn("TestRpcSessionFactory.awaitSocket")(function* (...@@ -169,9 +180,10 @@ const awaitSocket = Effect.fn("TestRpcSessionFactory.awaitSocket")(function* (
169180
170const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* (181const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* (
171 socket: TestWebSocket,182 socket: TestWebSocket,
183 index = 0,
172) {184) {
173 for (let attempt = 0; attempt < 100; attempt += 1) {185 for (let attempt = 0; attempt < 100; attempt += 1) {
174 const request = socket.sent[0];186 const request = socket.sent[index];
175 if (request) {187 if (request) {
176 return decodeRpcRequest(decodeJson(request));188 return decodeRpcRequest(decodeJson(request));
177 }189 }
...@@ -182,6 +194,7 @@ const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* (...@@ -182,6 +194,7 @@ const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* (
182194
183const completeInitialConfig = Effect.fn("TestRpcSessionFactory.completeInitialConfig")(function* (195const completeInitialConfig = Effect.fn("TestRpcSessionFactory.completeInitialConfig")(function* (
184 socket: TestWebSocket,196 socket: TestWebSocket,
197 config: unknown = ENCODED_SERVER_CONFIG,
185) {198) {
186 const request = yield* awaitRequest(socket);199 const request = yield* awaitRequest(socket);
187 expect(request).toMatchObject({200 expect(request).toMatchObject({
...@@ -195,7 +208,7 @@ const completeInitialConfig = Effect.fn("TestRpcSessionFactory.completeInitialCo...@@ -195,7 +208,7 @@ const completeInitialConfig = Effect.fn("TestRpcSessionFactory.completeInitialCo
195 requestId: request.id,208 requestId: request.id,
196 exit: {209 exit: {
197 _tag: "Success",210 _tag: "Success",
198 value: encodeServerConfig(SERVER_CONFIG),211 value: config,
199 },212 },
200 }),213 }),
201 );214 );
...@@ -218,6 +231,30 @@ describe("RpcSessionFactory", () => {...@@ -218,6 +231,30 @@ describe("RpcSessionFactory", () => {
218 expect(config).toEqual(SERVER_CONFIG);231 expect(config).toEqual(SERVER_CONFIG);
219 expect(socket.sent).toHaveLength(1);232 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
221 socket.close(1012, "service restart");258 socket.close(1012, "service restart");
222 const error = yield* Effect.flip(session.closed);259 const error = yield* Effect.flip(session.closed);
223260
...@@ -250,6 +287,45 @@ describe("RpcSessionFactory", () => {...@@ -250,6 +287,45 @@ describe("RpcSessionFactory", () => {
250 }),287 }),
251 );288 );
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
253 it.effect("fails readiness when the websocket never opens", () =>329 it.effect("fails readiness when the websocket never opens", () =>
254 Effect.gen(function* () {330 Effect.gen(function* () {
255 const { factory, sockets } = yield* makeFactory();331 const { factory, sockets } = yield* makeFactory();
packages/client-runtime/src/rpc/session.ts+10-4
...@@ -42,8 +42,9 @@ export class RpcSessionFactory extends Context.Service<...@@ -42,8 +42,9 @@ export class RpcSessionFactory extends Context.Service<
42type InitialConfigError = Effect.Error<42type InitialConfigError = Effect.Error<
43 ReturnType<WsRpcProtocolClient[typeof WS_METHODS.serverGetConfig]>43 ReturnType<WsRpcProtocolClient[typeof WS_METHODS.serverGetConfig]>
44>;44>;
45type ProbeError = Effect.Error<ReturnType<WsRpcProtocolClient[typeof WS_METHODS.serverProbe]>>;
4546
46function mapInitialConfigError(error: InitialConfigError): ConnectionAttemptError {47function mapSessionRpcError(error: InitialConfigError | ProbeError): ConnectionAttemptError {
47 switch (error._tag) {48 switch (error._tag) {
48 case "EnvironmentAuthorizationError":49 case "EnvironmentAuthorizationError":
49 return new ConnectionBlockedError({50 return new ConnectionBlockedError({
...@@ -115,12 +116,17 @@ export const make = Effect.gen(function* () {...@@ -115,12 +116,17 @@ export const make = Effect.gen(function* () {
115 const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext));116 const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext));
116 const initialConfig = yield* Effect.cached(117 const initialConfig = yield* Effect.cached(
117 client[WS_METHODS.serverGetConfig]({}).pipe(118 client[WS_METHODS.serverGetConfig]({}).pipe(
118 Effect.mapError(mapInitialConfigError),119 Effect.mapError(mapSessionRpcError),
119 Effect.withSpan("environment.initialSync"),120 Effect.withSpan("environment.initialSync"),
120 ),121 ),
121 );122 );
122 const probe = client[WS_METHODS.serverGetConfig]({}).pipe(123 const probe = initialConfig.pipe(
123 Effect.mapError(mapInitialConfigError),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 ),
124 Effect.asVoid,130 Effect.asVoid,
125 Effect.withSpan("clientRuntime.connection.rpcSession.probe"),131 Effect.withSpan("clientRuntime.connection.rpcSession.probe"),
126 );132 );
packages/contracts/src/environment.ts+1
...@@ -22,6 +22,7 @@ export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.T...@@ -22,6 +22,7 @@ export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.T
2222
23export const ExecutionEnvironmentCapabilities = Schema.Struct({23export const ExecutionEnvironmentCapabilities = Schema.Struct({
24 repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),24 repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
25 connectionProbe: Schema.optionalKey(Schema.Boolean),
25});26});
26export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type;27export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type;
2728
packages/contracts/src/rpc.ts+8
...@@ -201,6 +201,7 @@ export const WS_METHODS = {...@@ -201,6 +201,7 @@ export const WS_METHODS = {
201 previewAutomationFocusHost: "previewAutomation.focusHost",201 previewAutomationFocusHost: "previewAutomation.focusHost",
202202
203 // Server meta203 // Server meta
204 serverProbe: "server.probe",
204 serverGetConfig: "server.getConfig",205 serverGetConfig: "server.getConfig",
205 serverRefreshProviders: "server.refreshProviders",206 serverRefreshProviders: "server.refreshProviders",
206 serverUpdateProvider: "server.updateProvider",207 serverUpdateProvider: "server.updateProvider",
...@@ -246,6 +247,12 @@ export const WsServerRemoveKeybindingRpc = Rpc.make(WS_METHODS.serverRemoveKeybi...@@ -246,6 +247,12 @@ export const WsServerRemoveKeybindingRpc = Rpc.make(WS_METHODS.serverRemoveKeybi
246 error: Schema.Union([KeybindingsConfigError, EnvironmentAuthorizationError]),247 error: Schema.Union([KeybindingsConfigError, EnvironmentAuthorizationError]),
247});248});
248249
250export const WsServerProbeRpc = Rpc.make(WS_METHODS.serverProbe, {
251 payload: Schema.Struct({}),
252 success: Schema.Struct({}),
253 error: EnvironmentAuthorizationError,
254});
255
249export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, {256export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, {
250 payload: Schema.Struct({}),257 payload: Schema.Struct({}),
251 success: ServerConfig,258 success: ServerConfig,
...@@ -682,6 +689,7 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess,...@@ -682,6 +689,7 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess,
682});689});
683690
684export const WsRpcGroup = RpcGroup.make(691export const WsRpcGroup = RpcGroup.make(
692 WsServerProbeRpc,
685 WsServerGetConfigRpc,693 WsServerGetConfigRpc,
686 WsServerRefreshProvidersRpc,694 WsServerRefreshProvidersRpc,
687 WsServerUpdateProviderRpc,695 WsServerUpdateProviderRpc,