fix: harden console read evidence correlation
This commit is contained in:
@@ -39,6 +39,14 @@ const successfulRead = (): RpcResponse => ({
|
||||
durationMs: 4,
|
||||
});
|
||||
|
||||
const deferred = <T,>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
const OutletProbe = () => {
|
||||
const workspace = useConsoleWorkspace();
|
||||
const executorIdentity = useRef<NonNullable<typeof workspace.readExecutor> | null>(null);
|
||||
@@ -151,6 +159,37 @@ describe("ConsoleWorkspace", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("drops a late read receipt from the old connection after reconnect", async () => {
|
||||
const oldRead = deferred<RpcResponse>();
|
||||
mockedConnectToServer
|
||||
.mockResolvedValueOnce(successfulConnection("http://one.example/rpc"))
|
||||
.mockResolvedValueOnce(successfulConnection("http://two.example/rpc"));
|
||||
mockedCallOperation.mockReturnValueOnce(oldRead.promise);
|
||||
renderWorkspace();
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "Connect" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Read capabilities" }));
|
||||
expect(mockedCallOperation).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Reconnect" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connected-target")).toHaveTextContent(
|
||||
"http://two.example/rpc",
|
||||
);
|
||||
expect(screen.getByTestId("evidence-ids")).toHaveTextContent(
|
||||
"workflow.health-0|workflow.health-1",
|
||||
);
|
||||
});
|
||||
|
||||
oldRead.resolve(successfulRead());
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("evidence-ids")).toHaveTextContent(
|
||||
"workflow.health-0|workflow.health-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a stale health response after a newer target connects", async () => {
|
||||
let resolveFirst!: (response: ConnectResponse) => void;
|
||||
const first = new Promise<ConnectResponse>((resolve) => {
|
||||
|
||||
@@ -27,14 +27,17 @@ export const ConsoleWorkspace = () => {
|
||||
);
|
||||
|
||||
const readExecutor = useMemo(
|
||||
() =>
|
||||
connectedTarget
|
||||
? createConsoleReadExecutor({
|
||||
target: connectedTarget,
|
||||
recordEvidence,
|
||||
allocateEvidenceId,
|
||||
})
|
||||
: null,
|
||||
() => {
|
||||
if (!connectedTarget) return null;
|
||||
const executorGeneration = connectGeneration.current;
|
||||
return createConsoleReadExecutor({
|
||||
target: connectedTarget,
|
||||
recordEvidence,
|
||||
allocateEvidenceId,
|
||||
shouldRecordEvidence: () =>
|
||||
connectGeneration.current === executorGeneration,
|
||||
});
|
||||
},
|
||||
[allocateEvidenceId, connectedTarget, recordEvidence],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ConsoleApiError } from "../../connection/api.js";
|
||||
import type { RpcResponse } from "../../connection/contracts.js";
|
||||
import type { OperationName, RpcResponse } from "../../connection/contracts.js";
|
||||
import { createConsoleReadExecutor } from "./read-executor.js";
|
||||
|
||||
const success = (interpreted: unknown): RpcResponse => ({
|
||||
const success = (
|
||||
interpreted: unknown,
|
||||
operation: OperationName = "workflow.capabilities.list",
|
||||
): RpcResponse => ({
|
||||
ok: true,
|
||||
operation: "workflow.capabilities.list",
|
||||
operation,
|
||||
label: "List capabilities",
|
||||
interpreted,
|
||||
exchange: { request: { sent: true }, response: { status: 200 } },
|
||||
@@ -72,6 +75,33 @@ describe("ConsoleReadExecutor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a successful response for a different requested operation", async () => {
|
||||
const recordEvidence = vi.fn();
|
||||
const decode = vi.fn((value: unknown) => value);
|
||||
const executor = createConsoleReadExecutor({
|
||||
target: "http://console.test/rpc",
|
||||
recordEvidence,
|
||||
invoke: vi.fn(async () =>
|
||||
success(null, "workflow.draft_workspaces.list"),
|
||||
),
|
||||
});
|
||||
|
||||
await expect(
|
||||
executor.run("workflow.capabilities.list", {}, decode),
|
||||
).rejects.toMatchObject({
|
||||
kind: "operation",
|
||||
operation: "workflow.capabilities.list",
|
||||
message: "operation mismatch: requested workflow.capabilities.list, received workflow.draft_workspaces.list",
|
||||
});
|
||||
expect(decode).not.toHaveBeenCalled();
|
||||
expect(recordEvidence).toHaveBeenCalledTimes(1);
|
||||
expect(recordEvidence.mock.calls[0]?.[0]).toMatchObject({
|
||||
operation: "workflow.capabilities.list",
|
||||
label: "workflow.capabilities.list failed",
|
||||
equivalentCli: "unavailable: response operation mismatch",
|
||||
});
|
||||
});
|
||||
|
||||
it("turns decoder failures into decode errors", async () => {
|
||||
const recordEvidence = vi.fn();
|
||||
const executor = createConsoleReadExecutor({
|
||||
@@ -158,4 +188,18 @@ describe("ConsoleReadExecutor", () => {
|
||||
"workspace-workflow.capabilities.list",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not record evidence when its connection generation is stale", async () => {
|
||||
const recordEvidence = vi.fn();
|
||||
const executor = createConsoleReadExecutor({
|
||||
target: "http://console.test/rpc",
|
||||
recordEvidence,
|
||||
shouldRecordEvidence: () => false,
|
||||
invoke: vi.fn(async () => success(null)),
|
||||
});
|
||||
|
||||
await executor.run("workflow.capabilities.list", {}, (value) => value);
|
||||
|
||||
expect(recordEvidence).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ export const createConsoleReadExecutor = (options: {
|
||||
readonly target: string;
|
||||
readonly recordEvidence: (record: EvidenceRecord) => void;
|
||||
readonly allocateEvidenceId?: EvidenceIdAllocator;
|
||||
readonly shouldRecordEvidence?: () => boolean;
|
||||
readonly invoke?: InvokeOperation;
|
||||
}): ConsoleReadExecutor => {
|
||||
let evidenceSequence = 0;
|
||||
@@ -66,6 +67,7 @@ export const createConsoleReadExecutor = (options: {
|
||||
response: unknown,
|
||||
durationMs: number,
|
||||
): void => {
|
||||
if (options.shouldRecordEvidence?.() === false) return;
|
||||
options.recordEvidence({
|
||||
id: allocateEvidenceId(operation),
|
||||
operation,
|
||||
@@ -118,6 +120,22 @@ export const createConsoleReadExecutor = (options: {
|
||||
);
|
||||
}
|
||||
|
||||
if (response.operation !== operation) {
|
||||
record(
|
||||
operation,
|
||||
`${operation} failed`,
|
||||
"unavailable: response operation mismatch",
|
||||
response.exchange.request,
|
||||
response.exchange.response,
|
||||
response.durationMs,
|
||||
);
|
||||
throw new ConsoleClientError(
|
||||
"operation",
|
||||
operation,
|
||||
`operation mismatch: requested ${operation}, received ${response.operation}`,
|
||||
);
|
||||
}
|
||||
|
||||
record(
|
||||
response.operation,
|
||||
response.label,
|
||||
|
||||
Reference in New Issue
Block a user