app: fix brittle LNC reconnect flow — fail fast, clear stale sessions

- Reconnect timeout reduced from 30s to 12s
- On any reconnect failure, automatically clear the saved session flag
  so the user isn't stuck in a broken reconnect loop on next visit
- Error message simplified to "Session expired. Please connect with a
  new pairing phrase." instead of vague troubleshooting advice
- When reconnect fails, UI auto-switches to the new pairing phrase form
- "Use a new pairing phrase" now explicitly clears old LNC storage
  before attempting the fresh connection

Made-with: Cursor
This commit is contained in:
Brian Murray 2026-04-04 00:01:05 -07:00
parent f6f5df1155
commit 65747216b7
2 changed files with 136 additions and 52 deletions

View file

@ -18,6 +18,7 @@ import {
ChevronDown,
ChevronRight,
HelpCircle,
Server,
} from 'lucide-react';
const fadeUp = keyframes`
@ -25,6 +26,11 @@ const fadeUp = keyframes`
to { opacity: 1; transform: translateY(0); }
`;
const spin = keyframes`
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
`;
const S = {
Wrapper: styled.div`
display: flex;
@ -430,35 +436,42 @@ const S = {
}
`,
NewSessionBtn: styled.button`
display: flex;
align-items: center;
gap: 7px;
background: none;
border: none;
color: rgba(255, 255, 255, 0.25);
font-family: inherit;
font-size: 12px;
font-size: 13px;
cursor: pointer;
padding: 6px 0;
margin-top: 4px;
&:hover {
color: rgba(255, 255, 255, 0.5);
}
`,
Footer: styled.div`
margin-top: 12px;
text-align: center;
animation: ${fadeUp} 0.5s ease both;
animation-delay: 0.15s;
`,
FooterLink: styled.button`
background: none;
border: none;
color: rgba(255, 255, 255, 0.2);
font-size: 13px;
font-family: inherit;
cursor: pointer;
margin-top: 8px;
transition: color 0.15s ease;
&:hover {
color: rgba(255, 255, 255, 0.45);
}
`,
FooterLink: styled.button`
display: flex;
align-items: center;
gap: 7px;
background: none;
border: none;
color: rgba(255, 255, 255, 0.25);
font-family: inherit;
font-size: 13px;
cursor: pointer;
padding: 6px 0;
margin-top: 8px;
transition: color 0.15s ease;
&:hover {
color: rgba(255, 255, 255, 0.45);
}
`,
Spinner: styled(Loader2)`
animation: ${spin} 1s linear infinite;
`,
};
const CMD = 'litcli sessions add --label="Lightning Terminal Web" --type admin';
@ -535,7 +548,7 @@ const providerHelp: {
{
id: 'self',
name: 'Self-Hosted (litd)',
logo: '/icons/Lit.png',
logo: '/icons/Lit-dark.png',
steps: null,
},
];
@ -583,13 +596,21 @@ const ConnectNodePage: React.FC = () => {
setLoading(true);
setError('');
try {
if (hasSavedSession && !pairingPhrase.trim()) {
if (hasSavedSession && !showNewSession && !pairingPhrase.trim()) {
await store.authStore.reconnectLnc(lncPassword);
} else {
if (showNewSession) {
LncApi.clearPaired();
}
await store.authStore.loginWithLnc(pairingPhrase, lncPassword);
}
} catch (err: any) {
setError(err?.message || 'Connection failed');
const msg = err?.message || 'Connection failed';
setError(msg);
if (msg.includes('expired') || msg.includes('Session')) {
setShowNewSession(true);
setLncPassword('');
}
} finally {
setLoading(false);
}
@ -675,10 +696,7 @@ const ConnectNodePage: React.FC = () => {
<S.Btn type="submit" disabled={loading || !lncPassword.trim()}>
{loading ? (
<>
<Loader2
size={16}
style={{ animation: 'spin 1s linear infinite' }}
/>
<S.Spinner size={16} />
Connecting...
</>
) : (
@ -687,7 +705,9 @@ const ConnectNodePage: React.FC = () => {
</S.Btn>
</S.Form>
<S.NewSessionBtn onClick={() => setShowNewSession(true)}>
<Key size={13} />
Use a new pairing phrase instead
<ChevronRight size={13} style={{ marginLeft: 2 }} />
</S.NewSessionBtn>
</S.ReconnectPanel>
) : (
@ -723,10 +743,7 @@ const ConnectNodePage: React.FC = () => {
>
{loading ? (
<>
<Loader2
size={16}
style={{ animation: 'spin 1s linear infinite' }}
/>
<S.Spinner size={16} />
Connecting...
</>
) : (
@ -736,15 +753,8 @@ const ConnectNodePage: React.FC = () => {
</S.Form>
)}
<S.SecurityNote>
<Shield size={12} />
End-to-end encrypted. No keys or credentials leave your node.
</S.SecurityNote>
{showLncForm && (
<>
<S.Divider>need a pairing phrase?</S.Divider>
<S.HelpToggle onClick={() => setHelpOpen(!helpOpen)}>
<HelpCircle size={13} />
How to get your pairing phrase
@ -810,8 +820,8 @@ const ConnectNodePage: React.FC = () => {
)}
</S.SmallCopyBtn>
</S.CommandBlock>
Copy the <strong>10-word pairing phrase</strong> from the
output.
Copy the <strong>10-word pairing phrase</strong> from
the output.
</>
)}
</S.AccordionBody>
@ -848,7 +858,7 @@ const ConnectNodePage: React.FC = () => {
<S.Btn type="submit" disabled={loading || !password.trim()}>
{loading ? (
<>
<Loader2 size={16} style={{ animation: 'spin 1s linear infinite' }} />
<S.Spinner size={16} />
Connecting...
</>
) : (
@ -862,13 +872,12 @@ const ConnectNodePage: React.FC = () => {
</S.SecurityNote>
</>
)}
</S.Content>
<S.Footer>
<S.FooterLink onClick={() => store.appView.goTo('/get-node')}>
Don&apos;t have a node? Get one &rarr;
<Server size={13} />
Don&apos;t have a node? Get one
<ChevronRight size={13} style={{ marginLeft: 2 }} />
</S.FooterLink>
</S.Footer>
</S.Content>
</S.Wrapper>
);
};

View file

@ -143,6 +143,25 @@ export default class AuthStore {
});
}
private _withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`${label} timed out after ${ms / 1000}s`)),
ms,
);
promise.then(
v => {
clearTimeout(timer);
resolve(v);
},
e => {
clearTimeout(timer);
reject(e);
},
);
});
}
/**
* Connect via LNC pairing phrase (first time)
*/
@ -156,9 +175,35 @@ export default class AuthStore {
password,
});
await lnc.connect();
try {
await this._withTimeout(lnc.connect(), 30_000, 'Connection');
} catch (err: any) {
const msg = err?.message || '';
if (msg.includes('timed out')) {
try {
lnc.disconnect();
} catch {
/* best-effort cleanup */
}
throw new Error(
'Connection timed out. Make sure litd is running on your node and try a fresh pairing phrase.',
);
}
if (
msg.includes('WASM') ||
msg.includes('proxy') ||
msg.includes('stream not found')
) {
throw new Error(
'Could not reach your node. Make sure litd is running and the pairing phrase hasn\u2019t already been used. Generate a new one with: litcli sessions add --label="web" --type admin',
);
}
throw err;
}
if (!lnc.isConnected) {
throw new Error('Failed to establish LNC connection');
throw new Error(
'Connection failed. Verify litd is running and reachable, then try a fresh pairing phrase.',
);
}
const lncApi = new LncApi(lnc);
@ -168,13 +213,18 @@ export default class AuthStore {
});
try {
await this.validate();
await this._withTimeout(this.validate(), 15_000, 'Validation');
LncApi.markPaired();
} catch (error) {
} catch (error: any) {
lnc.disconnect();
runInAction(() => {
this.isLnc = false;
});
if (error?.message?.includes('timed out')) {
throw new Error(
'Connected to node but verification timed out. Your node may be slow to respond — try again.',
);
}
throw new Error(
'Connected but could not verify node access. Check your pairing phrase.',
);
@ -190,9 +240,34 @@ export default class AuthStore {
const lnc = new LNC({ password });
await lnc.connect();
try {
await this._withTimeout(lnc.connect(), 12_000, 'Reconnection');
} catch (err: any) {
try {
lnc.disconnect();
} catch {
/* best-effort cleanup */
}
LncApi.clearPaired();
const msg = err?.message || '';
if (
msg.includes('timed out') ||
msg.includes('stream not found') ||
msg.includes('WASM') ||
msg.includes('proxy') ||
msg.includes('closed network')
) {
throw new Error(
'Session expired. Please connect with a new pairing phrase.',
);
}
throw err;
}
if (!lnc.isConnected) {
throw new Error('Failed to reconnect via LNC');
LncApi.clearPaired();
throw new Error(
'Session expired. Please connect with a new pairing phrase.',
);
}
const lncApi = new LncApi(lnc);
@ -201,7 +276,7 @@ export default class AuthStore {
this.isLnc = true;
});
await this.validate();
await this._withTimeout(this.validate(), 15_000, 'Validation');
}
/** Disconnect LNC and clear pairing data */