Skip to content

Commit dec3b7a

Browse files
authored
Merge branch 'dev' into toggle-comment-unformatted-partial-selection
2 parents e6b44f7 + 3e383d9 commit dec3b7a

File tree

4 files changed

+133
-38
lines changed

4 files changed

+133
-38
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Changes to Calva.
66

77
- Fix: [Toggle comments off, indent glitch](https://github.com/BetterThanTomorrow/calva/issues/3078)
88
- Fix: [Toggle Line Comment can break structure for unformatted partial multiline selections](https://github.com/BetterThanTomorrow/calva/issues/3083)
9+
- Fix: [Clojure-lsp not starting when offline](https://github.com/BetterThanTomorrow/calva/issues/1299)
910

1011
## [2.0.555] - 2026-02-19
1112

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import * as expect from 'expect';
2+
import * as path from 'node:path';
3+
import * as fs from 'node:fs';
4+
import * as os from 'node:os';
5+
import { downloadWithBackupRecovery } from '../../../lsp/client/downloader-utils';
6+
7+
describe('downloader', () => {
8+
let tmpDir: string;
9+
let binaryPath: string;
10+
const binaryContent = 'existing-clojure-lsp-binary';
11+
12+
beforeEach(() => {
13+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'calva-downloader-test-'));
14+
binaryPath = path.join(tmpDir, 'clojure-lsp');
15+
fs.writeFileSync(binaryPath, binaryContent);
16+
});
17+
18+
afterEach(() => {
19+
fs.rmSync(tmpDir, { recursive: true, force: true });
20+
});
21+
22+
it('replaces binary with new version on successful download', async () => {
23+
const result = await downloadWithBackupRecovery(binaryPath, () => {
24+
fs.writeFileSync(binaryPath, 'new-version');
25+
return Promise.resolve();
26+
});
27+
28+
expect(result.restored).toBe(false);
29+
expect(fs.readFileSync(binaryPath, 'utf8')).toBe('new-version');
30+
const backupPath = path.join(tmpDir, 'backup', 'clojure-lsp');
31+
expect(fs.existsSync(backupPath)).toBe(false);
32+
});
33+
34+
it('restores binary to original path after failed download so offline startup works', async () => {
35+
await downloadWithBackupRecovery(binaryPath, () => {
36+
return Promise.reject(new Error('network unavailable'));
37+
});
38+
39+
expect(fs.existsSync(binaryPath)).toBe(true);
40+
expect(fs.readFileSync(binaryPath, 'utf8')).toBe(binaryContent);
41+
});
42+
43+
it('restores original binary even when download corrupts the file before failing', async () => {
44+
await downloadWithBackupRecovery(binaryPath, () => {
45+
fs.writeFileSync(binaryPath, 'corrupted-partial-download');
46+
return Promise.reject(new Error('connection reset'));
47+
});
48+
49+
expect(fs.existsSync(binaryPath)).toBe(true);
50+
expect(fs.readFileSync(binaryPath, 'utf8')).toBe(binaryContent);
51+
});
52+
});

src/lsp/client/downloader-utils.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import * as path from 'node:path';
2+
import * as fs from 'node:fs';
3+
4+
async function backupFile(filePath: string): Promise<string | null> {
5+
const backupDir = path.join(path.dirname(filePath), 'backup');
6+
const backupPath = path.join(backupDir, path.basename(filePath));
7+
8+
if (!fs.existsSync(filePath)) {
9+
return null;
10+
}
11+
12+
try {
13+
await fs.promises.mkdir(backupDir, { recursive: true });
14+
await fs.promises.rename(filePath, backupPath);
15+
return backupPath;
16+
} catch (e) {
17+
console.log('Error while backing up existing file.', e.message);
18+
return null;
19+
}
20+
}
21+
22+
async function restoreFile(backupPath: string, originalPath: string): Promise<boolean> {
23+
try {
24+
await fs.promises.rename(backupPath, originalPath);
25+
return true;
26+
} catch {
27+
return false;
28+
}
29+
}
30+
31+
export async function downloadWithBackupRecovery(
32+
filePath: string,
33+
download: () => Promise<void>
34+
): Promise<{
35+
path: string;
36+
restored: boolean;
37+
}> {
38+
const backupPath = await backupFile(filePath);
39+
try {
40+
await download();
41+
if (backupPath) {
42+
await fs.promises.unlink(backupPath).catch((_) => undefined);
43+
}
44+
return { path: filePath, restored: false };
45+
} catch (e) {
46+
if (!backupPath) {
47+
throw e;
48+
}
49+
console.log('Download failed, recovering from backup:', e.message);
50+
const restored = await restoreFile(backupPath, filePath);
51+
return {
52+
path: restored ? filePath : backupPath,
53+
restored: true,
54+
};
55+
}
56+
}

src/lsp/client/downloader.ts

Lines changed: 24 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import * as config from '../../config';
55
import * as path from 'node:path';
66
import * as vscode from 'vscode';
77
import * as fs from 'node:fs';
8+
import { downloadWithBackupRecovery } from './downloader-utils';
9+
10+
const VERSION_CHECK_TIMEOUT_MS = 10_000;
11+
const DOWNLOAD_TIMEOUT_MS = 120_000;
812

913
const versionFileName = 'clojure-lsp-version';
1014

@@ -56,39 +60,23 @@ export async function readVersionFile(extensionPath: string) {
5660

5761
async function getLatestVersion(): Promise<string> {
5862
try {
59-
const releasesJSON = await util.fetchFromUrl(
60-
'https://api.github.com/repos/clojure-lsp/clojure-lsp/releases'
61-
);
63+
const releasesJSON = await Promise.race([
64+
util.fetchFromUrl('https://api.github.com/repos/clojure-lsp/clojure-lsp/releases'),
65+
new Promise<never>((_, reject) =>
66+
setTimeout(() => reject(new Error('Version check timed out')), VERSION_CHECK_TIMEOUT_MS)
67+
),
68+
]);
6269
const releases = JSON.parse(releasesJSON);
6370
return releases[0].tag_name;
6471
} catch (err) {
6572
return '';
6673
}
6774
}
6875

69-
async function backupExistingFile(clojureLspPath: string): Promise<string> {
70-
const backupDir = path.join(path.dirname(clojureLspPath), 'backup');
71-
const backupPath = path.join(backupDir, path.basename(clojureLspPath));
72-
73-
if (fs.existsSync(clojureLspPath)) {
74-
try {
75-
await fs.promises.mkdir(backupDir, {
76-
recursive: true,
77-
});
78-
console.log('Backing up existing clojure-lsp to', backupPath);
79-
await fs.promises.rename(clojureLspPath, backupPath);
80-
} catch (e) {
81-
console.log('Error while backing up existing clojure-lsp file.', e.message);
82-
}
83-
}
84-
85-
return fs.existsSync(backupPath) ? backupPath : null;
86-
}
87-
8876
function downloadArtifact(url: string, filePath: string): Promise<void> {
8977
console.log('Downloading clojure-lsp from', url);
9078
return new Promise((resolve, reject) => {
91-
https
79+
const request = https
9280
.get(url, (response) => {
9381
if (response.statusCode === 200) {
9482
const writeStream = fs.createWriteStream(filePath);
@@ -100,11 +88,14 @@ function downloadArtifact(url: string, filePath: string): Promise<void> {
10088
})
10189
.pipe(writeStream);
10290
} else {
103-
response.resume(); // Consume response to free up memory
91+
response.resume();
10492
reject(new Error(response.statusMessage));
10593
}
10694
})
10795
.on('error', reject);
96+
request.setTimeout(DOWNLOAD_TIMEOUT_MS, () => {
97+
request.destroy(new Error('Download timed out'));
98+
});
10899
});
109100
}
110101

@@ -135,8 +126,8 @@ async function downloadClojureLsp(extensionPath: string, version: string): Promi
135126
: `https://github.com/clojure-lsp/clojure-lsp-dev-builds/releases/latest/download/${artifactName}`;
136127
const downloadPath = path.join(extensionPath, artifactName);
137128
const clojureLspPath = getClojureLspPath(extensionPath);
138-
const backupPath = await backupExistingFile(clojureLspPath);
139-
try {
129+
130+
const result = await downloadWithBackupRecovery(clojureLspPath, async () => {
140131
await downloadArtifact(url, downloadPath);
141132
if (path.extname(downloadPath) === '.zip') {
142133
await unzipFile(downloadPath, extensionPath);
@@ -145,19 +136,14 @@ async function downloadClojureLsp(extensionPath: string, version: string): Promi
145136
await fs.promises.chmod(clojureLspPath, 0o775);
146137
}
147138
writeVersionFile(extensionPath, version);
148-
} catch (e) {
149-
console.log(`Error downloading clojure-lsp, version: ${version}, from ${url}`, e);
150-
if (backupPath) {
151-
console.log('Using backup clojure-lsp');
152-
void vscode.window.showWarningMessage(
153-
`Error downloading clojure-lsp, version: ${version}, from ${url}. Using backup clojure-lsp`
154-
);
155-
return backupPath;
156-
} else {
157-
throw new Error(`Error downloading clojure-lsp, version: ${version}, from ${url}`);
158-
}
139+
});
140+
141+
if (result.restored) {
142+
void vscode.window.showWarningMessage(
143+
`Error downloading clojure-lsp, version: ${version}. Using previously downloaded clojure-lsp`
144+
);
159145
}
160-
return clojureLspPath;
146+
return result.path;
161147
}
162148

163149
export const ensureServerDownloaded = async (

0 commit comments

Comments
 (0)