mirror of
https://github.com/morluto/rea.git
synced 2026-10-19 04:19:54 -05:00
Refs #4467. Retain failed socket owners across translated failures and resolve aggregate cleanup only after every required transport closes. Preserve concurrently active peer sessions.
279 lines
9.5 KiB
TypeScript
279 lines
9.5 KiB
TypeScript
import { createServer } from "node:http";
|
|
|
|
import { WebSocket, WebSocketServer } from "ws";
|
|
import { expect, it, onTestFinished, vi } from "vitest";
|
|
|
|
import { CdpTargetSessionOwner } from "../../../src/browser/CdpTargetSessionOwner.js";
|
|
import { CdpConnection } from "../../../src/browser/CdpConnection.js";
|
|
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
|
|
import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js";
|
|
import { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
|
|
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
|
|
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
|
|
|
|
it("waits for a late owned handshake and does not close its active peer on retry", async () => {
|
|
const http = createServer();
|
|
const webSockets = new WebSocketServer({ noServer: true });
|
|
let port = 0;
|
|
let releaseUpgrade: (() => void) | undefined;
|
|
let upgradeReceived: (() => void) | undefined;
|
|
const received = new Promise<void>((resolve) => {
|
|
upgradeReceived = resolve;
|
|
});
|
|
const allow = new Promise<void>((resolve) => {
|
|
releaseUpgrade = resolve;
|
|
});
|
|
http.on("upgrade", (request, socket, head) => {
|
|
upgradeReceived?.();
|
|
void allow.then(() =>
|
|
webSockets.handleUpgrade(request, socket, head, (webSocket) =>
|
|
webSockets.emit("connection", webSocket, request),
|
|
),
|
|
);
|
|
});
|
|
await new Promise<void>((resolve) => http.listen(0, "127.0.0.1", resolve));
|
|
const address = http.address();
|
|
if (address === null || typeof address === "string")
|
|
throw new Error("Fixture did not bind a TCP port");
|
|
port = address.port;
|
|
const owner = new CdpTargetSessionOwner();
|
|
let connecting: Promise<CdpConnection> | undefined;
|
|
onTestFinished(async () => {
|
|
releaseUpgrade?.();
|
|
if (connecting !== undefined) {
|
|
const pendingConnection = await connecting;
|
|
await owner.finish(pendingConnection);
|
|
}
|
|
await owner.close();
|
|
await new Promise<void>((resolve, reject) =>
|
|
webSockets.close((cause) => (cause ? reject(cause) : resolve())),
|
|
);
|
|
await new Promise<void>((resolve, reject) =>
|
|
http.close((cause) => (cause ? reject(cause) : resolve())),
|
|
);
|
|
});
|
|
|
|
connecting = owner.connect(
|
|
`ws://127.0.0.1:${String(port)}/devtools/browser/late`,
|
|
"inspect_web_page",
|
|
{},
|
|
"late-handshake",
|
|
);
|
|
await received;
|
|
const closing = owner.close();
|
|
releaseUpgrade?.();
|
|
const connection = await connecting;
|
|
await owner.finish(connection);
|
|
await closing;
|
|
expect(connection).toBeDefined();
|
|
});
|
|
|
|
it("retries only the failed transport and does not repeat successful child cleanup", async () => {
|
|
const browser = await startFakeCdpBrowser();
|
|
const owner = new CdpTargetSessionOwner();
|
|
let active: CdpConnection | undefined;
|
|
onTestFinished(async () => {
|
|
if (active !== undefined) await owner.finish(active);
|
|
await owner.close();
|
|
await browser.close();
|
|
});
|
|
const failed = await owner.connect(
|
|
browser.browserWebSocketUrl,
|
|
"inspect_web_page",
|
|
{},
|
|
"failed-session",
|
|
);
|
|
active = await owner.connect(
|
|
browser.browserWebSocketUrl,
|
|
"inspect_web_page",
|
|
{},
|
|
"active-peer",
|
|
);
|
|
const close = vi
|
|
.spyOn(WebSocket.prototype, "close")
|
|
.mockImplementationOnce(() => {
|
|
throw new Error("controlled close failure");
|
|
});
|
|
const terminate = vi
|
|
.spyOn(WebSocket.prototype, "terminate")
|
|
.mockImplementation(() => undefined);
|
|
try {
|
|
const failure = await owner.finish(failed).then(
|
|
() => undefined,
|
|
(cause: unknown) => cause,
|
|
);
|
|
expect(failure).toBeInstanceOf(ProviderCleanupError);
|
|
expect(failure).toMatchObject({
|
|
cleanupIncomplete: true,
|
|
cleanupResources: ["failed-session"],
|
|
});
|
|
} finally {
|
|
close.mockRestore();
|
|
terminate.mockRestore();
|
|
}
|
|
|
|
await owner.retryPending();
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(1));
|
|
const cleanup = vi.fn(async () => undefined);
|
|
await owner.finish(active, cleanup);
|
|
await owner.retryPending();
|
|
await owner.close();
|
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
expect(browser.commands.map(({ method }) => method)).not.toContain(
|
|
"Target.closeTarget",
|
|
);
|
|
expect(browser.commands.map(({ method }) => method)).not.toContain(
|
|
"Browser.close",
|
|
);
|
|
});
|
|
|
|
it("accepts asynchronous terminate success only after the close event", async () => {
|
|
const browser = await startFakeCdpBrowser();
|
|
const owner = new CdpTargetSessionOwner();
|
|
onTestFinished(async () => {
|
|
await owner.close();
|
|
await browser.close();
|
|
});
|
|
const connection = await owner.connect(
|
|
browser.browserWebSocketUrl,
|
|
"inspect_web_page",
|
|
{},
|
|
"forced-close",
|
|
);
|
|
const originalTerminate = WebSocket.prototype.terminate;
|
|
const close = vi
|
|
.spyOn(WebSocket.prototype, "close")
|
|
.mockImplementation(() => undefined);
|
|
const terminate = vi
|
|
.spyOn(WebSocket.prototype, "terminate")
|
|
.mockImplementation(function (this: WebSocket) {
|
|
return originalTerminate.call(this);
|
|
});
|
|
try {
|
|
await owner.finish(connection);
|
|
} finally {
|
|
close.mockRestore();
|
|
terminate.mockRestore();
|
|
}
|
|
await owner.close();
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
});
|
|
|
|
it("clears returned inspection and shutdown cleanup status only after socket release", async () => {
|
|
const browser = await startFakeCdpBrowser();
|
|
const provider = new CdpBrowserProvider();
|
|
const originalClose = CdpConnection.prototype.close;
|
|
let denied = true;
|
|
const closing = vi
|
|
.spyOn(CdpConnection.prototype, "close")
|
|
.mockImplementation(function (this: CdpConnection) {
|
|
return denied
|
|
? Promise.reject(new Error("controlled transport close failure"))
|
|
: originalClose.call(this);
|
|
});
|
|
onTestFinished(async () => {
|
|
closing.mockRestore();
|
|
await provider.close();
|
|
await browser.close();
|
|
});
|
|
const result = await provider.inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
if (result.ok) throw new Error("Expected retained socket cleanup failure");
|
|
expect(result.error.cleanupIncomplete).toBe(true);
|
|
expect(result.error.partialObservation).toBeDefined();
|
|
const observation = result.error.partialObservation;
|
|
const retry = await provider.close().catch((cause: unknown) => cause);
|
|
expect(retry).toBeInstanceOf(AnalysisError);
|
|
if (!(retry instanceof AnalysisError))
|
|
throw new Error("Missing retry failure");
|
|
expect(retry.cleanupIncomplete).toBe(true);
|
|
expect(browser.activeConnectionCount()).toBe(1);
|
|
denied = false;
|
|
await provider.close();
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
expect(result.error.cleanupIncomplete).toBe(false);
|
|
expect(retry.cleanupIncomplete).toBe(false);
|
|
expect(result.error.partialObservation).toBe(observation);
|
|
expect(browser.commands.map(({ method }) => method)).not.toContain(
|
|
"Browser.close",
|
|
);
|
|
expect(browser.commands.map(({ method }) => method)).not.toContain(
|
|
"Target.closeTarget",
|
|
);
|
|
});
|
|
|
|
it("keeps retry aggregates incomplete until every reported socket is released", async () => {
|
|
const browser = await startFakeCdpBrowser();
|
|
const owner = new CdpTargetSessionOwner();
|
|
const first = await owner.connect(
|
|
browser.browserWebSocketUrl,
|
|
"inspect_web_page",
|
|
{},
|
|
"first-socket",
|
|
);
|
|
const second = await owner.connect(
|
|
browser.browserWebSocketUrl,
|
|
"inspect_web_page",
|
|
{},
|
|
"second-socket",
|
|
);
|
|
const active = await owner.connect(
|
|
browser.browserWebSocketUrl,
|
|
"inspect_web_page",
|
|
{},
|
|
"active-peer",
|
|
);
|
|
const originalClose = CdpConnection.prototype.close;
|
|
const denied = new Set([first, second]);
|
|
const closing = vi
|
|
.spyOn(CdpConnection.prototype, "close")
|
|
.mockImplementation(function (this: CdpConnection) {
|
|
return denied.has(this)
|
|
? Promise.reject(new Error("controlled mixed close failure"))
|
|
: originalClose.call(this);
|
|
});
|
|
onTestFinished(async () => {
|
|
closing.mockRestore();
|
|
await owner.finish(active);
|
|
await owner.close();
|
|
await browser.close();
|
|
});
|
|
const errors = await Promise.all(
|
|
[first, second].map((connection) =>
|
|
owner.finish(connection).catch((cause: unknown) => cause),
|
|
),
|
|
);
|
|
const aggregate = await owner.retryPending().catch((cause: unknown) => cause);
|
|
if (
|
|
!(aggregate instanceof AnalysisError) ||
|
|
!errors.every((error) => error instanceof AnalysisError)
|
|
)
|
|
throw new Error("Missing typed cleanup errors");
|
|
expect(aggregate.cleanupResources).toEqual(["first-socket", "second-socket"]);
|
|
denied.delete(first);
|
|
const mixed = await owner.retryPending().catch((cause: unknown) => cause);
|
|
if (!(mixed instanceof AnalysisError))
|
|
throw new Error("Missing mixed retry error");
|
|
expect(errors.map((error) => error.cleanupIncomplete)).toEqual([false, true]);
|
|
expect(aggregate.cleanupIncomplete).toBe(true);
|
|
expect(mixed.cleanupResources).toEqual(["second-socket"]);
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(2));
|
|
denied.clear();
|
|
await owner.retryPending();
|
|
expect(errors.map((error) => error.cleanupIncomplete)).toEqual([
|
|
false,
|
|
false,
|
|
]);
|
|
expect(aggregate.cleanupIncomplete).toBe(false);
|
|
expect(mixed.cleanupIncomplete).toBe(false);
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(1));
|
|
await owner.finish(active);
|
|
});
|