Skip to content

Commit ebc7c5d

Browse files
committed
harden browser contract-test adapter and entity against client leaks/hangs
When the test service (adapter + browser entity) is reused across many harness runs, two issues could wedge it permanently: - The adapter's send() waited forever for the browser to answer a command. A single unanswered command hung that REST request -- and therefore the harness -- indefinitely (status queries returned 000), with no recovery. Add a timeout (ADAPTER_REQUEST_TIMEOUT_MS, default 30s) so a stalled command fails that one request with HTTP 500 instead of wedging everything; reject in-flight commands on WS close; and log REST listen errors so a silent bind failure is visible. - The final test's client is never deleted by the harness before it exits, so it lingered and kept reconnecting (404 -> retry) across runs when the browser is reused. On getCapabilities (sent at the start of each run) the entity now destroys any leftover clients, so nothing accumulates across runs. Verified: interrupting a run leaves a retrying client that the next run's getCapabilities destroys (retries stop); 5 consecutive suites against one reused browser/adapter stay green with zero leaked clients.
1 parent ccd6249 commit ebc7c5d

2 files changed

Lines changed: 141 additions & 51 deletions

File tree

packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ export default class TestHarnessWebSocket {
3333
const resData: any = { reqId: data.reqId };
3434
switch (data.command) {
3535
case 'getCapabilities':
36+
// A capabilities query marks the start of a test-harness run. Destroy
37+
// any entities left over from a previous run -- e.g. a run that was
38+
// interrupted before its final deleteClient, or the final test's
39+
// client which the harness never deletes -- so SDK clients do not
40+
// accumulate and keep reconnecting across runs when the browser is
41+
// reused.
42+
this._destroyAllEntities();
3643
resData.capabilities = [
3744
'client-side',
3845
'service-endpoints',
@@ -90,6 +97,22 @@ export default class TestHarnessWebSocket {
9097
this._ws?.close();
9198
}
9299

100+
private _destroyAllEntities() {
101+
const ids = Object.keys(this._entities);
102+
if (ids.length === 0) {
103+
return;
104+
}
105+
this._logger.info(`Destroying ${ids.length} leftover client(s) before new run.`);
106+
ids.forEach((id) => {
107+
try {
108+
this._entities[id].close();
109+
} catch (err) {
110+
this._logger.warn(`Error closing leftover client ${id}: ${err}`);
111+
}
112+
delete this._entities[id];
113+
});
114+
}
115+
93116
send(data: unknown) {
94117
this._ws?.send(JSON.stringify(data));
95118
}

packages/tooling/contract-test-utils/src/adapter/startAdapter.ts

Lines changed: 118 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -10,42 +10,92 @@ import { WebSocketServer } from 'ws';
1010
export interface AdapterOptions {
1111
restPort?: number;
1212
wsPort?: number;
13+
/**
14+
* How long (ms) the adapter waits for the browser entity to answer a command
15+
* before failing the REST request. Prevents a single unanswered command from
16+
* wedging the adapter (and therefore the test harness) indefinitely.
17+
* Override with ADAPTER_REQUEST_TIMEOUT_MS. Defaults to 30s.
18+
*/
19+
requestTimeoutMs?: number;
20+
}
21+
22+
/**
23+
* Tracks an outstanding command sent to the browser entity, awaiting its
24+
* response over the WebSocket.
25+
*/
26+
interface Waiter {
27+
resolve: (data: any) => void;
28+
reject: (err: Error) => void;
29+
timer: ReturnType<typeof setTimeout>;
1330
}
1431

1532
let server: http.Server | undefined;
1633

1734
export function startAdapter(options?: AdapterOptions) {
1835
const restPort = options?.restPort ?? 8000;
1936
const wsPort = options?.wsPort ?? 8001;
37+
const requestTimeoutMs =
38+
options?.requestTimeoutMs ?? Number(process.env.ADAPTER_REQUEST_TIMEOUT_MS ?? 30000);
2039

2140
const wss = new WebSocketServer({ port: wsPort });
22-
const waiters: Record<string, (data: unknown) => void> = {};
41+
const waiters: Record<string, Waiter> = {};
2342

2443
console.log('Running contract test harness adapter.');
2544
wss.on('connection', async (ws) => {
2645
ws.on('error', console.error);
2746

2847
ws.on('message', (stringData: string) => {
2948
const data = JSON.parse(stringData);
30-
if (Object.prototype.hasOwnProperty.call(waiters, data.reqId)) {
31-
waiters[data.reqId](data);
49+
const waiter = waiters[data.reqId];
50+
if (waiter) {
51+
clearTimeout(waiter.timer);
3252
delete waiters[data.reqId];
53+
waiter.resolve(data);
3354
} else {
3455
console.error('Did not find outstanding request', data.reqId);
3556
}
3657
});
3758

38-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
39-
const send = (data: { [key: string]: unknown; reqId: string }): Promise<any> => {
40-
let resolver: (data: unknown) => void;
41-
const waiter = new Promise((resolve) => {
42-
resolver = resolve;
59+
// When the entity's socket drops, fail every outstanding command instead
60+
// of leaving REST requests (and the harness) hanging forever.
61+
ws.on('close', () => {
62+
Object.keys(waiters).forEach((reqId) => {
63+
const waiter = waiters[reqId];
64+
clearTimeout(waiter.timer);
65+
delete waiters[reqId];
66+
waiter.reject(new Error('WebSocket connection to entity closed before a response was received.'));
4367
});
44-
// @ts-expect-error The body of the above assignment runs sequentially.
45-
waiters[data.reqId] = resolver;
46-
ws.send(JSON.stringify(data));
47-
return waiter;
48-
};
68+
});
69+
70+
const send = (data: { [key: string]: unknown; reqId: string }): Promise<any> =>
71+
new Promise((resolve, reject) => {
72+
const timer = setTimeout(() => {
73+
delete waiters[data.reqId];
74+
reject(
75+
new Error(
76+
`Adapter timed out after ${requestTimeoutMs}ms awaiting entity response to '${data.command}' (reqId ${data.reqId}).`,
77+
),
78+
);
79+
}, requestTimeoutMs);
80+
waiters[data.reqId] = { resolve, reject, timer };
81+
ws.send(JSON.stringify(data));
82+
});
83+
84+
// Wrap async route handlers so an unanswered/timed-out command produces a
85+
// 500 for that single request rather than an unhandled rejection that
86+
// leaves the request — and the harness — hanging.
87+
const guard =
88+
(
89+
handler: (req: express.Request, res: express.Response) => Promise<void>,
90+
): express.RequestHandler =>
91+
(req, res) => {
92+
handler(req, res).catch((err) => {
93+
console.error('Adapter request failed:', err);
94+
if (!res.headersSent) {
95+
res.status(500).send(String(err));
96+
}
97+
});
98+
};
4999

50100
if (server) {
51101
await util.promisify(server.close).call(server);
@@ -63,54 +113,71 @@ export function startAdapter(options?: AdapterOptions) {
63113
);
64114
app.use(bodyParser.json());
65115

66-
app.get('/', async (_req, res) => {
67-
const commandResult = await send({ command: 'getCapabilities', reqId: randomUUID() });
68-
res.header('Content-Type', 'application/json');
69-
res.json(commandResult);
70-
});
116+
app.get(
117+
'/',
118+
guard(async (_req, res) => {
119+
const commandResult = await send({ command: 'getCapabilities', reqId: randomUUID() });
120+
res.header('Content-Type', 'application/json');
121+
res.json(commandResult);
122+
}),
123+
);
71124

72125
app.delete('/', () => {
73126
process.exit();
74127
});
75128

76-
app.post('/', async (req, res) => {
77-
const commandResult = await send({
78-
command: 'createClient',
79-
body: req.body,
80-
reqId: randomUUID(),
81-
});
82-
if (commandResult.resourceUrl) {
83-
res.set('Location', commandResult.resourceUrl);
84-
}
85-
if (commandResult.status) {
86-
res.status(commandResult.status);
87-
}
88-
res.send();
89-
});
129+
app.post(
130+
'/',
131+
guard(async (req, res) => {
132+
const commandResult = await send({
133+
command: 'createClient',
134+
body: req.body,
135+
reqId: randomUUID(),
136+
});
137+
if (commandResult.resourceUrl) {
138+
res.set('Location', commandResult.resourceUrl);
139+
}
140+
if (commandResult.status) {
141+
res.status(commandResult.status);
142+
}
143+
res.send();
144+
}),
145+
);
90146

91-
app.post('/clients/:id', async (req, res) => {
92-
const commandResult = await send({
93-
command: 'runCommand',
94-
id: req.params.id,
95-
body: req.body,
96-
reqId: randomUUID(),
97-
});
98-
if (commandResult.status) {
99-
res.status(commandResult.status);
100-
}
101-
if (commandResult.body) {
102-
res.write(JSON.stringify(commandResult.body));
103-
}
104-
res.send();
105-
});
147+
app.post(
148+
'/clients/:id',
149+
guard(async (req, res) => {
150+
const commandResult = await send({
151+
command: 'runCommand',
152+
id: req.params.id,
153+
body: req.body,
154+
reqId: randomUUID(),
155+
});
156+
if (commandResult.status) {
157+
res.status(commandResult.status);
158+
}
159+
if (commandResult.body) {
160+
res.write(JSON.stringify(commandResult.body));
161+
}
162+
res.send();
163+
}),
164+
);
106165

107-
app.delete('/clients/:id', async (req, res) => {
108-
await send({ command: 'deleteClient', id: req.params.id, reqId: randomUUID() });
109-
res.send();
110-
});
166+
app.delete(
167+
'/clients/:id',
168+
guard(async (req, res) => {
169+
await send({ command: 'deleteClient', id: req.params.id, reqId: randomUUID() });
170+
res.send();
171+
}),
172+
);
111173

112174
server = app.listen(restPort, () => {
113175
console.log('Listening on port %d', restPort);
114176
});
177+
// Surface bind failures (e.g. EADDRINUSE) instead of silently never
178+
// logging "Listening on port".
179+
server.on('error', (err) => {
180+
console.error(`REST server error on port ${restPort}:`, err);
181+
});
115182
});
116183
}

0 commit comments

Comments
 (0)