Skip to content

Commit a453645

Browse files
committed
Preserve renamed session names through reconnect
* Fixes #3207
1 parent 5c170d8 commit a453645

8 files changed

Lines changed: 229 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
Changes to Calva.
44

55
## [Unreleased]
6-
- enable new clojure-lsp code actions: inline function, if<->cond, extract selection to function, move to :let
6+
7+
- [enable new clojure-lsp code actions: inline function, if<->cond, extract selection to function, move to :let](https://github.com/BetterThanTomorrow/calva/issues/3204)
8+
- Fix: [Calva forgets the renamed session name on disconnect->reconnect](https://github.com/BetterThanTomorrow/calva/issues/3207)
79

810
## [2.0.583] - 2026-05-04
911

src/connector.ts

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ async function connectViaWebSocket(
9898
isJackIn = false
9999
): Promise<ConnectResult> {
100100
let activeClient: nrepl.NReplClient | undefined;
101+
let preservedRenames: Partial<sessionRoleUtils.SessionRoleKeys> | undefined;
101102
const baseSessionNames = sessionRoleUtils.deriveSessionRoleKeys(connectSequence);
102103
const projectRootPath = state.getProjectRootUri().fsPath;
103104
const projectRoot = state.getProjectRootUri().toString();
@@ -193,6 +194,7 @@ async function connectViaWebSocket(
193194
connectSequence,
194195
baseSessionNames,
195196
suffix: resolution.suffix,
197+
renamedSessionNames: preservedRenames,
196198
},
197199
});
198200

@@ -211,19 +213,29 @@ async function connectViaWebSocket(
211213
});
212214
clientRegistry.setCljcTargetForConnection(client.clientKey, 'primary');
213215

216+
// Apply preserved rename from previous browser connection
217+
const renamedPrimary = preservedRenames?.primary;
218+
if (renamedPrimary && renamedPrimary !== mainKey) {
219+
sessionRegistry.renameSession(mainKey, renamedPrimary);
220+
}
221+
const effectiveMainKey = renamedPrimary ?? mainKey;
222+
preservedRenames = undefined;
223+
214224
status.update();
215-
output.appendLineOtherOut(`Connected session: ${mainKey}, ws://${wsHost}:${currentPort}`);
225+
output.appendLineOtherOut(
226+
`Connected session: ${effectiveMainKey}, ws://${wsHost}:${currentPort}`
227+
);
216228
replSession.updateReplSessionType();
217229

218-
outputWindow.setSession(mainSession, client.ns, mainKey);
230+
outputWindow.setSession(mainSession, client.ns, effectiveMainKey);
219231

220232
if (config.getConfig().autoEvaluateCode.onConnect.clj) {
221233
output.appendLineOtherOut(
222234
`Evaluating code from settings: 'calva.autoEvaluateCode.onConnect.clj'`
223235
);
224236
await evaluate.evaluateInOutputWindow(
225237
config.getConfig().autoEvaluateCode.onConnect.clj,
226-
mainKey,
238+
effectiveMainKey,
227239
outputWindow.getNs(),
228240
{}
229241
);
@@ -234,7 +246,12 @@ async function connectViaWebSocket(
234246
connectSequence.afterPrimaryReplConnectedCode ?? connectSequence.afterCLJReplJackInCode;
235247
if (afterMainReplCode) {
236248
output.appendLineOtherOut(`Evaluating 'afterPrimaryReplConnectedCode'`);
237-
await evaluate.evaluateInOutputWindow(afterMainReplCode, mainKey, outputWindow.getNs(), {});
249+
await evaluate.evaluateInOutputWindow(
250+
afterMainReplCode,
251+
effectiveMainKey,
252+
outputWindow.getNs(),
253+
{}
254+
);
238255
}
239256
if (!connectSequence.cljsType || connectSequence.cljsType === 'none') {
240257
output.maybePrintLegacyREPLWindowOutputMessage();
@@ -305,6 +322,9 @@ async function connectViaWebSocket(
305322
output.appendLineOtherOut('Browser REPL disconnected, waiting for reconnection...');
306323
state.connectionLogChannel().appendLine('Browser REPL disconnected');
307324

325+
// Preserve renames across browser reconnections
326+
preservedRenames = clientRegistry.getConnectionState(clientKey)?.renamedSessionNames;
327+
308328
// Clean up client and sessions but keep server alive
309329
clientTeardown.releaseClientSuffix(clientKey);
310330
const wasRegistered = clientRegistry.unregisterClient(clientKey);
@@ -466,6 +486,7 @@ async function connectToHost(
466486
connectSequence,
467487
baseSessionNames,
468488
suffix: resolution.suffix,
489+
renamedSessionNames: resolution.renamedSessionNames,
469490
},
470491
});
471492
localClient.addOnCloseHandler((c) => {
@@ -504,19 +525,26 @@ async function connectToHost(
504525
});
505526
clientRegistry.setCljcTargetForConnection(localClient.clientKey, 'primary');
506527

528+
// Apply preserved rename from previous connection
529+
const renamedPrimary = resolution.renamedSessionNames?.primary;
530+
if (renamedPrimary && renamedPrimary !== mainKey) {
531+
sessionRegistry.renameSession(mainKey, renamedPrimary);
532+
}
533+
const effectivePrimaryKey = renamedPrimary ?? mainKey;
534+
507535
status.update();
508-
output.appendLineOtherOut(`Connected session: ${mainKey}, port: ${port}`);
536+
output.appendLineOtherOut(`Connected session: ${effectivePrimaryKey}, port: ${port}`);
509537
replSession.updateReplSessionType();
510538

511-
outputWindow.setSession(mainSession, localClient.ns, mainKey);
539+
outputWindow.setSession(mainSession, localClient.ns, effectivePrimaryKey);
512540

513541
if (config.getConfig().autoEvaluateCode.onConnect.clj) {
514542
output.appendLineOtherOut(
515543
`Evaluating code from settings: 'calva.autoEvaluateCode.onConnect.clj'`
516544
);
517545
await evaluate.evaluateInOutputWindow(
518546
config.getConfig().autoEvaluateCode.onConnect.clj,
519-
mainKey,
547+
effectivePrimaryKey,
520548
outputWindow.getNs(),
521549
{}
522550
);
@@ -527,14 +555,19 @@ async function connectToHost(
527555
connectSequence.afterPrimaryReplConnectedCode ?? connectSequence.afterCLJReplJackInCode;
528556
if (afterMainReplCode) {
529557
output.appendLineOtherOut(`Evaluating 'afterPrimaryReplConnectedCode'`);
530-
await evaluate.evaluateInOutputWindow(afterMainReplCode, mainKey, outputWindow.getNs(), {});
558+
await evaluate.evaluateInOutputWindow(
559+
afterMainReplCode,
560+
effectivePrimaryKey,
561+
outputWindow.getNs(),
562+
{}
563+
);
531564
}
532565
if (!connectSequence.cljsType || connectSequence.cljsType === 'none') {
533566
output.maybePrintLegacyREPLWindowOutputMessage();
534567
}
535568
void output.replWindowAppendPrompt();
536569

537-
clojureDocs.probeAndSetSession(mainSession, mainKey);
570+
clojureDocs.probeAndSetSession(mainSession, effectivePrimaryKey);
538571

539572
let cljsSession = null,
540573
cljsBuild = null;
@@ -700,24 +733,34 @@ async function setUpCljsRepl(
700733
});
701734
clientRegistry.setCljcTargetForConnection(clientKey, 'secondary');
702735

703-
clojureDocs.probeAndSetSession(session, cljsKey);
736+
// Apply preserved rename from previous connection
737+
const renamedSecondary =
738+
clientRegistry.getConnectionState(clientKey)?.renamedSessionNames?.secondary;
739+
const effectiveCljsKey =
740+
renamedSecondary && renamedSecondary !== cljsKey
741+
? (sessionRegistry.renameSession(cljsKey, renamedSecondary), renamedSecondary)
742+
: cljsKey;
743+
744+
clojureDocs.probeAndSetSession(session, effectiveCljsKey);
704745

705746
status.update();
706-
output.appendLineOtherOut(`Connected session: ${cljsKey}${build ? ', repl: ' + build : ''}`);
747+
output.appendLineOtherOut(
748+
`Connected session: ${effectiveCljsKey}${build ? ', repl: ' + build : ''}`
749+
);
707750
outputWindow.appendLine(
708751
resultsOutputUtil.formatAsLineComments(outputWindow.CLJS_CONNECT_GREETINGS)
709752
);
710753
const description = await session.describe(true);
711754
const ns = description.aux?.['current-ns'] || 'user';
712755
await session.eval(`(in-ns '${ns})`, 'user').value;
713-
outputWindow.setSession(session, ns, cljsKey);
756+
outputWindow.setSession(session, ns, effectiveCljsKey);
714757
if (config.getConfig().autoEvaluateCode.onConnect.cljs) {
715758
output.appendLineOtherOut(
716759
`Evaluating code from settings: 'calva.autoEvaluateCode.onConnect.cljs'`
717760
);
718761
await evaluate.evaluateInOutputWindow(
719762
config.getConfig().autoEvaluateCode.onConnect.cljs,
720-
cljsKey,
763+
effectiveCljsKey,
721764
ns,
722765
{}
723766
);

src/extension-test/integration/suite/websocket-connect-test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import * as docMirror from '../../../doc-mirror';
1212

1313
const WS_PORT_EVAL = 51340;
1414
const WS_PORT_LOAD = 51341;
15+
const WS_PORT_RENAME = 51342;
1516

1617
/**
1718
* Create a scittle webview via Joyride flare.
@@ -262,4 +263,102 @@ suite('WebSocket nREPL Connect suite', function () {
262263
testUtil.log(suite, `[TIMING] Test 2 complete: ${elapsed()}`);
263264
await vscode.commands.executeCommand('workbench.action.closeActiveEditor');
264265
});
266+
267+
test('Renamed session survives browser reconnection', async function () {
268+
const t0 = Date.now();
269+
const elapsed = () => `${Date.now() - t0}ms`;
270+
testUtil.log(suite, 'Renamed session survives browser reconnection');
271+
272+
const projectDir = path.join(
273+
testUtil.testDataDir,
274+
'..',
275+
'projects',
276+
'scittle-replicant-tic-tac-toe'
277+
);
278+
279+
const connectSequence: connectSequenceTypes.ReplConnectSequence = {
280+
name: 'scittle-ws-test',
281+
projectType: connectSequenceTypes.ProjectTypes['scittle'],
282+
cljsType: connectSequenceTypes.CljsTypes.none,
283+
webSocketPort: WS_PORT_RENAME,
284+
projectRootPath: [projectDir],
285+
};
286+
287+
// 1. Connect via WebSocket
288+
const connectPromise = connector.connect(connectSequence, true);
289+
290+
await testUtil.waitForCondition(
291+
() => testUtil.canConnectToPort(WS_PORT_RENAME),
292+
5_000,
293+
20,
294+
`WS server not listening on port ${WS_PORT_RENAME}`
295+
);
296+
297+
await vscode.commands.executeCommand('joyride.runCode', flareCode(WS_PORT_RENAME));
298+
const result = await connectPromise;
299+
assert.ok(result.connected, 'Initial WS connection should succeed');
300+
testUtil.log(suite, `[TIMING] Initial connection: ${elapsed()}`);
301+
302+
// 2. Verify original session name and rename it
303+
const originalSessions = sessionRegistry.listSessions();
304+
const originalKey = originalSessions[0].key;
305+
testUtil.log(suite, `Original session key: ${originalKey}`);
306+
307+
const renamedKey = 'my-scittle';
308+
const renamed = sessionRegistry.renameSession(originalKey, renamedKey);
309+
assert.ok(renamed, 'Rename should succeed');
310+
assert.ok(sessionRegistry.getSession(renamedKey), 'Session should exist under renamed key');
311+
testUtil.log(suite, `Renamed to: ${renamedKey}, ${elapsed()}`);
312+
313+
// 3. Close the webview (simulates browser tab close / reload)
314+
await vscode.commands.executeCommand('workbench.action.closeActiveEditor');
315+
testUtil.log(suite, `[TIMING] Webview closed: ${elapsed()}`);
316+
317+
// Wait for disconnect to clean up sessions
318+
await testUtil.waitForCondition(
319+
() => sessionRegistry.listSessions().length === 0,
320+
5_000,
321+
20,
322+
'Timed out waiting for session cleanup after webview close'
323+
);
324+
testUtil.log(suite, `[TIMING] Sessions cleaned up: ${elapsed()}`);
325+
326+
// 4. Re-open webview (browser reconnects to the still-listening WS server)
327+
await vscode.commands.executeCommand('joyride.runCode', flareCode(WS_PORT_RENAME));
328+
testUtil.log(suite, `[TIMING] Webview re-opened: ${elapsed()}`);
329+
330+
// 5. Wait for session to be re-registered
331+
await testUtil.waitForCondition(
332+
() => sessionRegistry.listSessions().length > 0,
333+
10_000,
334+
50,
335+
'Timed out waiting for session after browser reconnection'
336+
);
337+
338+
// 6. Verify the renamed key survived the reconnection
339+
const reconnectedSessions = sessionRegistry.listSessions();
340+
const reconnectedKeys = reconnectedSessions.map((s) => s.key);
341+
testUtil.log(suite, `Reconnected session keys: ${reconnectedKeys.join(', ')}`);
342+
343+
assert.ok(
344+
reconnectedKeys.includes(renamedKey),
345+
`Renamed key '${renamedKey}' should survive browser reconnection, got: ${reconnectedKeys.join(
346+
', '
347+
)}`
348+
);
349+
assert.ok(
350+
!reconnectedKeys.includes(originalKey),
351+
`Original key '${originalKey}' should not reappear after reconnection`
352+
);
353+
354+
// 7. Verify the session is functional
355+
const session = sessionRegistry.getSession(renamedKey);
356+
assert.ok(session, 'Should get session by renamed key');
357+
const evalResult = await session.eval('(+ 10 20)', 'user').value;
358+
assert.strictEqual(evalResult, '30', 'Eval through renamed session should work');
359+
testUtil.log(suite, `Eval via renamed session: (+ 10 20) => ${evalResult}`);
360+
361+
await vscode.commands.executeCommand('workbench.action.closeActiveEditor');
362+
testUtil.log(suite, `[TIMING] Test 3 complete: ${elapsed()}`);
363+
});
265364
});

src/extension-test/unit/nrepl/session-registry-test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,5 +233,35 @@ describe('session registry', () => {
233233
const nextSuffix = sessionNameSuffix.acquireNextAvailableSuffix();
234234
expectLib.expect(nextSuffix).toBe('2');
235235
});
236+
237+
it('stores renamedSessionNames in ConnectionState for primary', () => {
238+
clientRegistry.registerClient(createMockClient('client-a'), {
239+
connectionState: {
240+
sessionRoleKeys: { primary: 'clj', secondary: 'cljs' },
241+
},
242+
});
243+
sessionRegistry.registerSession('clj', createSession('client-a'), {});
244+
245+
sessionRegistry.renameSession('clj', 'my-clj');
246+
247+
const state = clientRegistry.getConnectionState('client-a');
248+
expectLib.expect(state?.renamedSessionNames).toEqual({ primary: 'my-clj' });
249+
});
250+
251+
it('stores renamedSessionNames in ConnectionState for secondary', () => {
252+
clientRegistry.registerClient(createMockClient('client-a'), {
253+
connectionState: {
254+
sessionRoleKeys: { primary: 'clj', secondary: 'cljs' },
255+
},
256+
});
257+
sessionRegistry.registerSession('cljs', createSession('client-a'), {
258+
isSecondary: true,
259+
});
260+
261+
sessionRegistry.renameSession('cljs', 'my-cljs');
262+
263+
const state = clientRegistry.getConnectionState('client-a');
264+
expectLib.expect(state?.renamedSessionNames).toEqual({ secondary: 'my-cljs' });
265+
});
236266
});
237267
});

src/extension-test/unit/session-name-resolver-test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,31 @@ describe('session-name-resolver', () => {
195195
expectLib.expect(resolution.suffix).toBe('2');
196196
});
197197

198+
it('passes through renamedSessionNames from previous connection', () => {
199+
const baseNames = { primary: 'clj', secondary: 'cljs' };
200+
const projectRoot = '/test-project';
201+
202+
clientRegistry.registerClient(createMockClient('client-a'), {
203+
projectRoot,
204+
host: 'localhost',
205+
port: 1234,
206+
connectionState: {
207+
baseSessionNames: baseNames,
208+
renamedSessionNames: { primary: 'my-clj' },
209+
},
210+
});
211+
212+
const resolution = sessionNameResolver.resolveSessionNames(
213+
baseNames,
214+
projectRoot,
215+
'localhost',
216+
1234
217+
);
218+
219+
expectLib.expect(resolution.reconnectClientKey).toBe('client-a');
220+
expectLib.expect(resolution.renamedSessionNames).toEqual({ primary: 'my-clj' });
221+
});
222+
198223
it('detects reconnection even when projectRoot differs (matches on host:port)', () => {
199224
const baseNames = { primary: 'clj', secondary: 'cljs' };
200225

src/nrepl/client-registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export interface ConnectionState {
2323
suffix?: string;
2424
/** Which session role should handle .cljc files for this connection */
2525
cljcTarget?: CljcTargetRole;
26+
/** User-assigned custom names per role, preserved across reconnection */
27+
renamedSessionNames?: Partial<sessionRoleUtils.SessionRoleKeys>;
2628
}
2729

2830
export interface RegisteredClient {

src/nrepl/session-name-resolver.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ export interface SessionNameResolution {
1919

2020
/** Client to disconnect for reconnection, if any */
2121
reconnectClientKey?: string;
22+
23+
/** User-assigned custom names from the previous connection, if any */
24+
renamedSessionNames?: Partial<sessionRoleUtils.SessionRoleKeys>;
2225
}
2326

2427
/**
@@ -157,6 +160,7 @@ export function resolveSessionNames(
157160
finalNames,
158161
suffix: existingSuffix,
159162
reconnectClientKey,
163+
renamedSessionNames: existingState?.renamedSessionNames,
160164
};
161165
}
162166

0 commit comments

Comments
 (0)