Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@
"vite-plugin-electron": "^0.29.0",
"vitest": "^3.0.0"
},
"overrides": {
"@bsv/wallet-toolbox-client": {
"@bsv/sdk": "2.1.3"
}
},
"build": {
"appId": "com.bsvblockchain.bsvdesktop",
"productName": "BSV-Desktop",
Expand Down
84 changes: 81 additions & 3 deletions src/onWalletReady.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,83 @@ interface HttpResponseEvent {
body: string;
}

const WALLET_HTTP_REQUEST_TIMEOUT_MS = 60000;
const WALLET_TIMEOUT_PREFIX = 'WALLET_REQUEST_TIMEOUT';

class WalletRequestTimeoutError extends Error {
constructor(
public readonly method: string,
public readonly timeoutMs: number
) {
super(`${WALLET_TIMEOUT_PREFIX}: ${method} did not complete within ${timeoutMs}ms`);
this.name = 'WalletRequestTimeoutError';
}
}

function withTimeout<T>(method: string, operation: Promise<T>): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | undefined;

const timeoutPromise = new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new WalletRequestTimeoutError(method, WALLET_HTTP_REQUEST_TIMEOUT_MS));
}, WALLET_HTTP_REQUEST_TIMEOUT_MS);
});

return Promise.race([operation, timeoutPromise]).finally(() => {
if (timeout) clearTimeout(timeout);
});
}

function withWalletRequestTimeout(wallet: WalletInterface): WalletInterface {
return new Proxy(wallet, {
get(target, property, receiver) {
const value = Reflect.get(target, property, receiver);
if (typeof value !== 'function') {
return value;
}

return (...args: unknown[]) => withTimeout(String(property), value.apply(target, args));
}
});
}

function timeoutPayloadFromBody(body: string): { message: string; method: string; timeoutMs: number } | undefined {
let parsed: unknown = body;
try {
parsed = JSON.parse(body);
} catch {
parsed = { message: body };
}

const message = typeof parsed === 'object' && parsed !== null && 'message' in parsed
? String((parsed as { message?: unknown }).message)
: typeof parsed === 'string'
? parsed
: '';
const match = message.match(/^WALLET_REQUEST_TIMEOUT: (.+) did not complete within (\d+)ms$/);
if (!match) return undefined;

return {
message,
method: match[1],
timeoutMs: Number(match[2])
};
}

function normalizeWalletTimeoutResponse(response: HttpResponseEvent): HttpResponseEvent {
const timeoutPayload = timeoutPayloadFromBody(response.body);
if (!timeoutPayload) return response;

return {
...response,
status: 504,
body: JSON.stringify({
code: WALLET_TIMEOUT_PREFIX,
...timeoutPayload
})
};
}

/**
* Duck-type check for WERR_REVIEW_ACTIONS-shaped errors. We don't use
* `error?.constructor.name === 'WERR_REVIEW_ACTIONS'` because Vite's
Expand Down Expand Up @@ -163,8 +240,8 @@ export const onWalletReady = async (wallet: WalletInterface): Promise<(() => voi
window.electronAPI.onHttpRequest(async (req: HttpRequestEvent) => {
let response: HttpResponseEvent;

const wallet = _currentWallet;
if (!wallet) {
const rawWallet = _currentWallet;
if (!rawWallet) {
response = {
request_id: req.request_id,
status: 503,
Expand All @@ -173,6 +250,7 @@ export const onWalletReady = async (wallet: WalletInterface): Promise<(() => voi
window.electronAPI.sendHttpResponse(response);
return;
}
const wallet = withWalletRequestTimeout(rawWallet);

try {
const origin = parseOrigin(req.headers);
Expand Down Expand Up @@ -868,7 +946,7 @@ export const onWalletReady = async (wallet: WalletInterface): Promise<(() => voi
}

// Send response back to main process
window.electronAPI.sendHttpResponse(response);
window.electronAPI.sendHttpResponse(normalizeWalletTimeoutResponse(response));
} catch (e) {
console.error("Error handling http-request event:", e);
response = {
Expand Down
39 changes: 39 additions & 0 deletions test/onWalletReady.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,45 @@ describe('onWalletReady', () => {
)
})

it('returns 504 when a wallet call does not complete', async () => {
vi.useFakeTimers()
try {
const wallet = makeMockWallet({
getPublicKey: vi.fn(() => new Promise(() => {})),
})
await onWalletReady(wallet)
const handler = mockOnHttpRequest.mock.calls[0][0]

const pending = handler({
request_id: 8,
path: '/getPublicKey',
headers: { origin: 'https://example.com' },
body: JSON.stringify({ identityKey: true, seekPermission: false }),
method: 'POST',
})

await vi.advanceTimersByTimeAsync(60000)
await pending

const response = mockSendHttpResponse.mock.calls[0][0]
expect(response).toEqual(
expect.objectContaining({
request_id: 8,
status: 504,
})
)
expect(JSON.parse(response.body)).toEqual(
expect.objectContaining({
code: 'WALLET_REQUEST_TIMEOUT',
method: 'getPublicKey',
timeoutMs: 60000,
})
)
} finally {
vi.useRealTimers()
}
})

it('preserves WERR_REVIEW_ACTIONS fields when the class name is minified', async () => {
const reviewActionResults = [{ txid: '00'.repeat(32), status: 'serviceError' }]
const sendWithResults = [{ txid: '00'.repeat(32), status: 'failed' }]
Expand Down
Loading