Skip to content

Commit 198cf9f

Browse files
authored
Merge pull request #245 from philipp-winterle/fix-bridge-client-execute-timeout
Let the bridge client outlast the bridge's own request timeout
2 parents dee8ac0 + 81f88c0 commit 198cf9f

3 files changed

Lines changed: 137 additions & 4 deletions

File tree

‎src/api/bridge-client.ts‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,23 @@ import { promisify } from 'util';
55

66
import { getDeferred } from '@httptoolkit/util';
77

8-
import { getSocketPath } from './ui-operation-bridge';
8+
import { getSocketPath, REQUEST_TIMEOUT_MS } from './ui-operation-bridge';
9+
10+
// Discovery requests are answered from memory, so a dead socket path has to fail
11+
// fast to leave time for the paths behind it:
12+
const DISCOVERY_TIMEOUT_MS = 2000;
13+
14+
// Executing an operation means waiting for the UI, which the bridge itself allows
15+
// REQUEST_TIMEOUT_MS to answer. Staying just above that keeps that budget usable
16+
// and lets the bridge's own error - which names the operation - reach the caller,
17+
// instead of failing here first with a bare ETIMEDOUT.
18+
const EXECUTE_TIMEOUT_MS = REQUEST_TIMEOUT_MS + 5000;
19+
20+
export function requestTimeout(urlPath: string): number {
21+
return urlPath === '/api/execute'
22+
? EXECUTE_TIMEOUT_MS
23+
: DISCOVERY_TIMEOUT_MS;
24+
}
925

1026
const execFileAsync = promisify(execFile);
1127
let darwinTempDirPromise: Promise<string | undefined> | undefined;
@@ -70,7 +86,7 @@ export async function apiRequest(
7086
throw createBridgeConnectionError(socketAttempts);
7187
}
7288

73-
function socketRequest(
89+
export function socketRequest(
7490
socketPath: string,
7591
method: 'GET' | 'POST',
7692
urlPath: string,
@@ -84,7 +100,7 @@ function socketRequest(
84100
headers: {
85101
'Content-Type': 'application/json'
86102
},
87-
timeout: 2000
103+
timeout: requestTimeout(urlPath)
88104
}, (res) => {
89105
const chunks: Buffer[] = [];
90106
res.on('error', (err: any) => {

‎src/api/ui-operation-bridge.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ interface PendingRequest {
4343
timer: ReturnType<typeof setTimeout>;
4444
}
4545

46-
const REQUEST_TIMEOUT_MS = 30_000;
46+
export const REQUEST_TIMEOUT_MS = 30_000;
4747

4848
export async function getSocketPath(): Promise<string> {
4949
if (process.platform === 'win32') {
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import * as os from 'os';
2+
import * as path from 'path';
3+
import * as fs from 'fs';
4+
5+
import { expect } from 'chai';
6+
import WebSocket, { WebSocketServer } from 'ws';
7+
8+
import {
9+
UiOperationBridge,
10+
HtkOperation,
11+
REQUEST_TIMEOUT_MS
12+
} from '../../src/api/ui-operation-bridge';
13+
import { socketRequest, requestTimeout } from '../../src/api/bridge-client';
14+
15+
const TEST_OPERATIONS: HtkOperation[] = [{
16+
name: 'proxy.get-config',
17+
description: 'Get the proxy configuration',
18+
category: 'proxy',
19+
tiers: ['free'],
20+
inputSchema: { type: 'object', properties: {} }
21+
}];
22+
23+
// Connects a mock UI to the bridge, authenticates it and waits for readiness,
24+
// exactly as the real UI does on startup.
25+
async function connectMockUi(bridge: UiOperationBridge) {
26+
const wss = new WebSocketServer({ port: 0 });
27+
28+
const pair = await new Promise<{ clientWs: WebSocket, wss: WebSocketServer }>((resolve) => {
29+
wss.on('connection', (serverSideWs) => bridge.setWebSocket(serverSideWs as any));
30+
wss.on('listening', () => {
31+
const { port } = wss.address() as { port: number };
32+
const clientWs = new WebSocket(`ws://127.0.0.1:${port}`);
33+
clientWs.on('open', () => resolve({ clientWs, wss }));
34+
});
35+
});
36+
37+
const authResult = new Promise<void>((resolve) => {
38+
pair.clientWs.on('message', function handler(data) {
39+
if (JSON.parse(data.toString()).type !== 'auth-result') return;
40+
pair.clientWs.removeListener('message', handler);
41+
resolve();
42+
});
43+
});
44+
pair.clientWs.send(JSON.stringify({ type: 'auth', jwt: false }));
45+
await authResult;
46+
47+
pair.clientWs.send(JSON.stringify({ type: 'operations', operations: TEST_OPERATIONS }));
48+
await new Promise<void>((resolve) => bridge.once('ready', resolve));
49+
50+
return pair;
51+
}
52+
53+
describe("Bridge client", function () {
54+
55+
// The slow test below deliberately waits out the previous 2 second client timeout:
56+
this.timeout(10_000);
57+
58+
let bridge: UiOperationBridge;
59+
let pair: { clientWs: WebSocket, wss: WebSocketServer };
60+
let socketDir: string;
61+
let socketPath: string;
62+
63+
beforeEach(async () => {
64+
socketDir = fs.mkdtempSync(path.join(os.tmpdir(), 'htk-bridge-client-test-'));
65+
socketPath = path.join(socketDir, 'test.sock');
66+
67+
bridge = new UiOperationBridge();
68+
await bridge.startApiServer(socketPath);
69+
pair = await connectMockUi(bridge);
70+
});
71+
72+
afterEach(() => {
73+
if (pair.clientWs.readyState === WebSocket.OPEN) pair.clientWs.close();
74+
pair.wss.close();
75+
bridge.destroy();
76+
77+
try { fs.rmSync(socketDir, { recursive: true, force: true }); } catch {}
78+
});
79+
80+
it("should wait for slow operations, up to the bridge's own request timeout", async () => {
81+
const slowResult = { port: 8000, ip: '127.0.0.1' };
82+
83+
// Serializing a large body in the UI can easily take longer than a moment:
84+
pair.clientWs.on('message', (data) => {
85+
const msg = JSON.parse(data.toString());
86+
if (msg.type !== 'request') return;
87+
setTimeout(() => {
88+
pair.clientWs.send(JSON.stringify({
89+
type: 'response',
90+
id: msg.id,
91+
result: slowResult
92+
}));
93+
}, 3000);
94+
});
95+
96+
const result = await socketRequest(socketPath, 'POST', '/api/execute', {
97+
name: 'proxy.get-config',
98+
args: {}
99+
});
100+
101+
expect(result).to.deep.equal(slowResult);
102+
});
103+
104+
it("should not give up before the bridge itself would", () => {
105+
// The bridge allows each operation REQUEST_TIMEOUT_MS to complete. If the
106+
// client gives up first, that budget is unreachable and the bridge's own
107+
// error message never gets to the user:
108+
expect(requestTimeout('/api/execute')).to.be.greaterThan(REQUEST_TIMEOUT_MS);
109+
});
110+
111+
it("should still give up quickly while looking for a server", () => {
112+
// Discovery tries each candidate socket path in turn, so a dead path has
113+
// to fail fast rather than hold up the ones behind it:
114+
expect(requestTimeout('/api/status')).to.be.lessThan(REQUEST_TIMEOUT_MS);
115+
expect(requestTimeout('/api/operations')).to.be.lessThan(REQUEST_TIMEOUT_MS);
116+
});
117+
});

0 commit comments

Comments
 (0)