fix: harden console read evidence correlation

This commit is contained in:
lda
2026-08-04 21:10:37 +07:00 Verified
parent 6a5af51813
commit a458c6c375
5 changed files with 139 additions and 11 deletions
@@ -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,