Patch renderer: mitigate Usersnap init failure#2460
Conversation
831690e to
b554810
Compare
Review summary (condensed)Ran Findings: 0 critical, 0 important open, 0 minor open. All resolved in-review:
Quality gate: typecheck ✓, lint ✓, 3/3 tests ✓. Reviewer focus:
|
|
Due to limited resources and aggressive work schedule, we're not able to review this right now, but we will when we're able. If this delay creates difficulties for you in your development schedule, please let me know. Thank you for your contribution to improve Paratext. |
Usersnap is a non-critical feedback widget; failure is expected in offline or firewalled environments. Logging it as an error overstates the severity and may trigger noise in error monitoring. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract the timeout-wrapping logic into a loadSpaceWithTimeout helper and, when the timeout fires, destroy the space if loadSpace later resolves so the loaded SDK is not left orphaned. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the hand-rolled Promise + setTimeout wrapper with the project's sanctioned AsyncVariable (per the Code-Style-Guide async-coordination rule), inlined into initializeUsersnapApi. A space that loads after the timeout fires is still destroyed so the loaded SDK is not left orphaned. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Drive the AsyncVariable from an async IIFE (per the async/await default) instead of .then()/.catch(). - Log post-timeout settle/cleanup failures at debug instead of swallowing. - Extract the timeout into a named LOAD_SPACE_TIMEOUT_MS constant. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add usersnap.service.test.ts covering the three behavioral branches of the loadSpace timeout (resolve before timeout, resolve after timeout → orphan destroyed, reject before timeout), using the real AsyncVariable with fake timers. - Export LOAD_SPACE_TIMEOUT_MS so the test references it instead of duplicating the literal. - Reword the post-timeout debug log and add a fire-and-forget comment on the IIFE. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mock SpaceApi was never wired into the reject path, so the init/destroy assertions were vacuously true. Assert the falsifiable behavior instead: initialization resolves without throwing when loadSpace rejects. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
bfbaf1e to
9375e08
Compare
katherinejensen00
left a comment
There was a problem hiding this comment.
Nice fix! Thanks for making it! Just one blocking thing to look at, the rest are nitpicky suggestions that you can decide what to do with. Thanks again!
@katherinejensen00 reviewed 2 files and all commit messages, and made 7 comments.
Reviewable status: all files reviewed, 6 unresolved discussions (waiting on imnasnainaec).
src/renderer/services/usersnap.service.ts line 21 at r3 (raw file):
/** Milliseconds to wait for `loadSpace` to resolve before giving up. */ export const LOAD_SPACE_TIMEOUT_MS = 5 * 1000;
NIT Super nitpicky and up to you if it is actually worth fixing.
Trivial: LOAD_SPACE_TIMEOUT_MS is exported only so the test can advance fake timers by exactly this amount — which is a good reason to avoid a drifting magic number. Consider a half-sentence "Exported for tests." on the TSDoc so the widened surface reads as intentional. Optional.
src/renderer/services/usersnap.service.ts line 179 at r3 (raw file):
const spaceApi = await loadSpace(USERSNAP_SPACE_API_KEY); // If a space loads after the timeout fires, destroy it to avoid an orphaned instance. if (loadVar.hasSettled) await spaceApi.destroy();
NIT
Minor/optional: AsyncVariable also exposes hasTimedOut, which states the intent here directly ("the timeout already fired, so clean up the late arrival"). It's functionally equivalent in this code since the timeout is the only thing that can settle loadVar before these checks — so this is purely a readability nit and matches the closest precedents (shutdown-tasks.ts, network.service.ts). Entirely fine to leave as hasSettled if you prefer.
src/renderer/services/usersnap.service.ts line 184 at r3 (raw file):
if (loadVar.hasSettled) logger.debug('Usersnap loadSpace rejected (or cleanup failed) after timeout:', error); else loadVar.rejectWithReason(getErrorMessage(error));
NIT
Minor: rejecting with getErrorMessage(error) means loadVar.promise rejects with a plain string, so the outer catch (error) at the bottom logs a string rather than the original Error — the stack/name is gone for a genuine loadSpace throw. rejectWithReason requires a string, so this is a reasonable adaptation, but if init-time diagnostics matter you could logger.warn(..., error) (with the real error) inside the IIFE before rejecting. Low priority.
src/renderer/services/usersnap.service.ts line 188 at r3 (raw file):
})(); const api = await loadVar.promise; await api.init(defaultInitParams);
The timeout guards loadSpace, but api.init() on the next line is still unbounded — so the startup hang this PR targets is only half-closed.
initializeUsersnapApi() is awaited sequentially in the renderer startup chain (src/renderer/index.tsx:96) and blocks the runPromisesAndThrowIfRejected(...) that starts the web-view, dialog, and theme services. This change correctly bounds loadSpace, but await api.init(defaultInitParams) runs right after await loadVar.promise and also talks to Usersnap infrastructure. If init hangs, initializeUsersnapApi() hangs and the same downstream services never start — which is the exact failure the summary says is fixed ("Startup no longer risks hanging on Usersnap"). Could you either bring api.init() inside the same timeout budget (e.g. race the init too, or gate it), or narrow the PR/comment wording to say only loadSpace is bounded? The init hang is less likely than a loadSpace hang (the SDK is already loaded by then), but it's on the same awaited path, so it's worth closing or explicitly scoping out.
src/renderer/services/usersnap.service.ts line 256 at r3 (raw file):
if (!globalUsersnapApi) { logger.warn( 'Cannot open Usersnap form: UserSnap API is not initialized. This may be due to network connectivity issues or blocked external requests.',
NIT
Consistency nit: trimming the init-failure log to "feedback forms will be unavailable" is a good noise reduction (the error argument still carries the real cause). Just note openUsersnapForm a few lines down still carries the verbose "This may be due to network connectivity issues or blocked external requests" tail — worth trimming that one too (or keeping both) so the two messages follow one convention.
src/renderer/services/usersnap.service.test.ts line 88 at r3 (raw file):
}); it('resolves after the timeout: init not called, late space destroyed', async () => {
NIT
Two branches of the new race logic aren't exercised — both in the orphan-cleanup safety net this PR adds. The suite covers resolve-before, resolve-after, and reject-before. Not covered: (a) loadSpace rejecting after the timeout, whose only behavior is the logger.debug('Usersnap loadSpace rejected (or cleanup failed) after timeout:', error) branch, and (b) spaceApi.destroy() itself rejecting in the resolve-after path (it lands in the same catch). These are exactly the "swallow and don't crash startup" contracts this change introduces, and they'd regress silently. The Deferred + fake-timer harness already makes both cheap to add (advance timers, then reject(...) / make destroy reject; assert startup still resolves and doesn't throw).
…nches The 5 s timeout now spans loadSpace + init (both are on the awaited renderer startup path), uses hasTimedOut for the late-completion checks, and logs the real Error before rejecting so its stack survives. Renames the timeout constant to reflect the widened scope, trims the openUsersnapForm log for consistency, and adds tests for the reject-after-timeout and destroy-failure-after-timeout swallow branches. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
imnasnainaec
left a comment
There was a problem hiding this comment.
@imnasnainaec made 1 comment and resolved 5 discussions.
Reviewable status: 0 of 2 files reviewed, 1 unresolved discussion (waiting on katherinejensen00).
src/renderer/services/usersnap.service.ts line 188 at r3 (raw file):
Previously, katherinejensen00 wrote…
The timeout guards
loadSpace, butapi.init()on the next line is still unbounded — so the startup hang this PR targets is only half-closed.
initializeUsersnapApi()isawaited sequentially in the renderer startup chain (src/renderer/index.tsx:96) and blocks therunPromisesAndThrowIfRejected(...)that starts the web-view, dialog, and theme services. This change correctly boundsloadSpace, butawait api.init(defaultInitParams)runs right afterawait loadVar.promiseand also talks to Usersnap infrastructure. Ifinithangs,initializeUsersnapApi()hangs and the same downstream services never start — which is the exact failure the summary says is fixed ("Startup no longer risks hanging on Usersnap"). Could you either bringapi.init()inside the same timeout budget (e.g. race the init too, or gate it), or narrow the PR/comment wording to say onlyloadSpaceis bounded? Theinithang is less likely than aloadSpacehang (the SDK is already loaded by then), but it's on the same awaited path, so it's worth closing or explicitly scoping out.
Done.
katherinejensen00
left a comment
There was a problem hiding this comment.
Thanks for making those adjustments! This PR is ready to ship ⛵
@katherinejensen00 reviewed 2 files and all commit messages, made 1 comment, and resolved 1 discussion.
Reviewable status:complete! all files reviewed, all discussions resolved.
Summary
Hardens renderer startup against a non-critical Usersnap initialization failure. Usersnap is an optional feedback widget loaded from an external service; in offline or firewalled environments its load can hang or fail, and that shouldn't surface as an error or stall startup.
Changes in
src/renderer/services/usersnap.service.ts(+ a new test file):loadSpace+initwith a 5 s timeout. Both are awaited on the startup path and both talk to Usersnap infrastructure, so a hang in either (blocked egress, dead connection) would otherwise stall the renderer. Load and init now run inside anAsyncVariable(the project's promise-with-timeout utility) that rejects after 5 s, letting the existingcatchhandle the failure. Since a promise can't be cancelled, a load/init that finishes after the timeout has its space destroyed so the SDK isn't left orphaned.errortowarn. Usersnap being unavailable is expected and recoverable, not an app error, so logging it aterrorjust adds noise.globalUsersnapApiis still cleared toundefinedas before.usersnap.service.test.tscovering the timeout/race branches against the realAsyncVariablewith fake timers: resolve before timeout, resolve after timeout → orphan destroyed, reject before timeout, reject after timeout → late failure swallowed, and destroy-failure after timeout → swallowed.Behavior
Startup no longer risks hanging on Usersnap, and a blocked load logs a single warning instead of an error. No functional change — feedback forms degrade gracefully as they did before.
🤖 Generated with Claude Code
Devin review: https://app.devin.ai/review/paranext/paranext-core/pull/2460
This change is