Skip to content
Merged
2 changes: 2 additions & 0 deletions apps/mobile/src/state/threadOutboxDelivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -911,6 +911,7 @@ export function createThreadOutboxDelivery(): {
interactionMode: sendSettings.interactionMode,
createdAt: queuedMessage.createdAt,
dispatchMode: "queue",
resendAfterSocketLoss: false,
},
});
if (AsyncResult.isFailure(compactionResult) && !hasAccess()) return true;
Expand Down Expand Up @@ -951,6 +952,7 @@ export function createThreadOutboxDelivery(): {
createdAt: queuedMessage.createdAt,
dispatchMode: queuedMessage.dispatchMode ?? "start",
...(queuedMessage.freshContext ? { freshContext: true } : {}),
resendAfterSocketLoss: false,
},
});
if (AsyncResult.isFailure(deliveryResult) && !hasAccess()) return true;
Expand Down
191 changes: 190 additions & 1 deletion packages/client-runtime/src/operations/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,23 @@ import {
type ProjectMutation,
} from "@t3tools/contracts";
import { describe, expect, it } from "@effect/vitest";
import * as Cause from "effect/Cause";
import * as SecureRandom from "effect/crypto/SecureRandom";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as SubscriptionRef from "effect/SubscriptionRef";
import * as TestClock from "effect/testing/TestClock";
import { RpcClientError } from "effect/rpc";

import {
AVAILABLE_CONNECTION_STATE,
PrimaryConnectionTarget,
type PreparedConnection,
type SupervisorConnectionState,
} from "../connection/model.ts";
import * as EnvironmentSupervisor from "../connection/supervisor.ts";
import * as RpcSession from "../rpc/session.ts";
Expand All @@ -55,6 +62,12 @@ const TARGET = new PrimaryConnectionTarget({
wsBaseUrl: "wss://environment.example.test",
});

const CONNECTED_STATE: SupervisorConnectionState = {
...AVAILABLE_CONNECTION_STATE,
desired: true,
phase: "connected",
};

const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(function* (input: {
readonly commands: OrchestrationV2Command[];
readonly projects: ProjectMutation[];
Expand Down Expand Up @@ -121,7 +134,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct
};
return EnvironmentSupervisor.EnvironmentSupervisor.of({
target: TARGET,
state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE),
state: yield* SubscriptionRef.make(CONNECTED_STATE),
session: yield* SubscriptionRef.make(Option.some(session)),
prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
connect: Effect.void,
Expand Down Expand Up @@ -293,6 +306,182 @@ describe("V2 environment commands", () => {
}).pipe(Effect.provide(layerTestRandom)),
);

// A dead socket reaches the caller as a protocol error, or as an interrupt when
// the supervisor closes the session before the protocol reports the error.
it.effect.each([
[
"protocol error",
new RpcClientError.RpcClientError({
reason: new RpcClientError.RpcClientDefect({
message: "socket closed",
cause: new Error("socket closed"),
}),
}),
],
["session close", Effect.failCause(Cause.interrupt())],
] as const)("retries a message dispatch lost to a %s with the same command id", ([, loss]) =>
Effect.gen(function* () {
const commands: OrchestrationV2Command[] = [];
const supervisor = yield* makeSupervisor({ commands, projects: [] });
const healthySession = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session));
const firstAttemptLost = yield* Deferred.make<void>();
const lostClient = {
...healthySession.client,
[ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) =>
Effect.gen(function* () {
commands.push(command);
yield* SubscriptionRef.set(supervisor.session, Option.none());
yield* Deferred.succeed(firstAttemptLost, undefined);
return yield* loss;
}),
} as unknown as WsRpcProtocolClient;
yield* SubscriptionRef.set(
supervisor.session,
Option.some({ ...healthySession, client: lostClient }),
);

const sendFiber = yield* startThreadTurn({
commandId: CommandId.make("command-lost-ack"),
threadId: v2ThreadId,
message: {
messageId: MessageId.make("message-lost-ack"),
role: "user",
text: "hello",
attachments: [],
},
runtimeMode: "full-access",
interactionMode: "default",
}).pipe(
Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
Effect.forkChild,
);
yield* Deferred.await(firstAttemptLost);
yield* SubscriptionRef.set(supervisor.session, Option.some(healthySession));
const exit = yield* Fiber.await(sendFiber);

expect(commands.map((command) => [command.type, command.commandId])).toEqual([
["message.dispatch", "command-lost-ack"],
["message.dispatch", "command-lost-ack"],
]);
expect(Exit.isSuccess(exit)).toBe(true);
}).pipe(Effect.provide(layerTestRandom)),
);

it.effect("stops resending a message whose dispatch keeps losing the socket", () =>
Effect.gen(function* () {
const commands: OrchestrationV2Command[] = [];
const supervisor = yield* makeSupervisor({ commands, projects: [] });
const healthySession = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session));
const dyingClient: WsRpcProtocolClient = {
...healthySession.client,
[ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) =>
Effect.gen(function* () {
commands.push(command);
yield* SubscriptionRef.set(
supervisor.session,
Option.some({ ...healthySession, client: dyingClient }),
);
return yield* new RpcClientError.RpcClientError({
reason: new RpcClientError.RpcClientDefect({
message: "socket closed",
cause: new Error("socket closed"),
}),
});
}),
} as unknown as WsRpcProtocolClient;
yield* SubscriptionRef.set(
supervisor.session,
Option.some({ ...healthySession, client: dyingClient }),
);

const error = yield* startThreadTurn({
commandId: CommandId.make("command-socket-killer"),
threadId: v2ThreadId,
message: {
messageId: MessageId.make("message-socket-killer"),
role: "user",
text: "hello",
attachments: [],
},
runtimeMode: "full-access",
interactionMode: "default",
}).pipe(
Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
Effect.flip,
);

expect(commands.map((command) => command.commandId)).toEqual([
"command-socket-killer",
"command-socket-killer",
"command-socket-killer",
]);
expect(error._tag).toBe("RpcClientError");
}).pipe(Effect.provide(layerTestRandom)),
);

it.effect("holds a lost message through reconnect backoff until disconnect", () =>
Effect.gen(function* () {
const commands: OrchestrationV2Command[] = [];
const supervisor = yield* makeSupervisor({ commands, projects: [] });
const healthySession = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session));
const lostClient = {
...healthySession.client,
[ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) =>
Effect.gen(function* () {
commands.push(command);
yield* SubscriptionRef.set(supervisor.session, Option.none());
return yield* new RpcClientError.RpcClientError({
reason: new RpcClientError.RpcClientDefect({
message: "socket closed",
cause: new Error("socket closed"),
}),
});
}),
} as unknown as WsRpcProtocolClient;
const send = (resendAfterSocketLoss: boolean) =>
Effect.gen(function* () {
yield* SubscriptionRef.set(
supervisor.session,
Option.some({ ...healthySession, client: lostClient }),
);
return yield* startThreadTurn({
commandId: CommandId.make(`command-host-gone-${resendAfterSocketLoss}`),
threadId: v2ThreadId,
message: {
messageId: MessageId.make(`message-host-gone-${resendAfterSocketLoss}`),
role: "user",
text: "hello",
attachments: [],
},
runtimeMode: "full-access",
interactionMode: "default",
resendAfterSocketLoss,
}).pipe(
Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
Effect.flip,
Effect.forkChild,
);
});

const optedOut = yield* send(false);
expect((yield* Fiber.join(optedOut))._tag).toBe("RpcClientError");

const waiting = yield* send(true);
yield* SubscriptionRef.set(supervisor.state, {
...CONNECTED_STATE,
phase: "backoff" as const,
});
yield* TestClock.adjust("2 minutes");
expect(waiting.pollUnsafe()).toBeUndefined();
yield* SubscriptionRef.set(supervisor.state, AVAILABLE_CONNECTION_STATE);
expect((yield* Fiber.join(waiting))._tag).toBe("RpcClientError");
expect(commands.map((command) => command.commandId)).toEqual([
"command-host-gone-false",
"command-host-gone-true",
]);
}).pipe(Effect.provide(layerTestRandom)),
);

it.effect("Stop with no run left ends the thread's pull request watches", () =>
Effect.gen(function* () {
const link = {
Expand Down
Loading
Loading