Skip to content

Commit ec4aa92

Browse files
fix: keep binary in same place, update if needed (#33)
Co-authored-by: Dean Sheather <[email protected]>
1 parent c2a2d8c commit ec4aa92

File tree

2 files changed

+196
-107
lines changed

2 files changed

+196
-107
lines changed

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
"build": "webpack",
8686
"watch": "webpack --watch",
8787
"package": "webpack --mode production --devtool hidden-source-map",
88+
"package:prerelease": "npx vsce package --pre-release",
8889
"lint": "eslint . --ext ts,md",
8990
"tsc:compile": "tsc",
9091
"tsc:watch": "tsc -w",

src/storage.ts

+195-107
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import axios from "axios"
22
import { execFile } from "child_process"
33
import { getBuildInfo } from "coder/site/src/api/api"
4-
import { createWriteStream } from "fs"
4+
import * as crypto from "crypto"
5+
import { createWriteStream, createReadStream } from "fs"
56
import { ensureDir } from "fs-extra"
67
import fs from "fs/promises"
78
import { IncomingMessage } from "http"
@@ -73,39 +74,16 @@ export class Storage {
7374
// fetchBinary returns the path to a Coder binary.
7475
// The binary will be cached if a matching server version already exists.
7576
public async fetchBinary(): Promise<string | undefined> {
77+
await this.cleanUpOldBinaries()
7678
const baseURL = this.getURL()
7779
if (!baseURL) {
7880
throw new Error("Must be logged in!")
7981
}
8082
const baseURI = vscode.Uri.parse(baseURL)
8183

8284
const buildInfo = await getBuildInfo()
83-
const binPath = this.binaryPath(buildInfo.version)
84-
const exists = await fs
85-
.stat(binPath)
86-
.then(() => true)
87-
.catch(() => false)
88-
if (exists) {
89-
// Even if the file exists, it could be corrupted.
90-
// We run `coder version` to ensure the binary can be executed.
91-
this.output.appendLine(`Using cached binary: ${binPath}`)
92-
const valid = await new Promise<boolean>((resolve) => {
93-
try {
94-
execFile(binPath, ["version"], (err) => {
95-
if (err) {
96-
this.output.appendLine("Check for binary corruption: " + err)
97-
}
98-
resolve(err === null)
99-
})
100-
} catch (ex) {
101-
this.output.appendLine("The cached binary cannot be executed: " + ex)
102-
resolve(false)
103-
}
104-
})
105-
if (valid) {
106-
return binPath
107-
}
108-
}
85+
const binPath = this.binaryPath()
86+
const exists = await this.checkBinaryExists(binPath)
10987
const os = goos()
11088
const arch = goarch()
11189
let binName = `coder-${os}-${arch}`
@@ -114,106 +92,153 @@ export class Storage {
11492
binName += ".exe"
11593
}
11694
const controller = new AbortController()
95+
96+
if (exists) {
97+
this.output.appendLine(`Found existing binary: ${binPath}`)
98+
const valid = await this.checkBinaryValid(binPath)
99+
if (!valid) {
100+
const removed = await this.rmBinary(binPath)
101+
if (!removed) {
102+
vscode.window.showErrorMessage("Failed to remove existing binary!")
103+
return undefined
104+
}
105+
}
106+
}
107+
const etag = await this.getBinaryETag()
108+
this.output.appendLine(`Using binName: ${binName}`)
109+
this.output.appendLine(`Using binPath: ${binPath}`)
110+
this.output.appendLine(`Using ETag: ${etag}`)
111+
117112
const resp = await axios.get("/bin/" + binName, {
118113
signal: controller.signal,
119114
baseURL: baseURL,
120115
responseType: "stream",
121116
headers: {
122117
"Accept-Encoding": "gzip",
118+
"If-None-Match": `"${etag}"`,
123119
},
124120
decompress: true,
125121
// Ignore all errors so we can catch a 404!
126122
validateStatus: () => true,
127123
})
128-
if (resp.status === 404) {
129-
vscode.window
130-
.showErrorMessage(
131-
"Coder isn't supported for your platform. Please open an issue, we'd love to support it!",
132-
"Open an Issue",
133-
)
134-
.then((value) => {
135-
if (!value) {
136-
return
137-
}
138-
const params = new URLSearchParams({
139-
title: `Support the \`${os}-${arch}\` platform`,
140-
body: `I'd like to use the \`${os}-${arch}\` architecture with the VS Code extension.`,
141-
})
142-
const uri = vscode.Uri.parse(`https://github.com/coder/vscode-coder/issues/new?` + params.toString())
143-
vscode.env.openExternal(uri)
144-
})
145-
return
146-
}
147-
if (resp.status !== 200) {
148-
vscode.window.showErrorMessage("Failed to fetch the Coder binary: " + resp.statusText)
149-
return
150-
}
124+
this.output.appendLine("Response status code: " + resp.status)
151125

152-
const contentLength = Number.parseInt(resp.headers["content-length"])
126+
switch (resp.status) {
127+
case 200: {
128+
const contentLength = Number.parseInt(resp.headers["content-length"])
153129

154-
// Ensure the binary directory exists!
155-
await fs.mkdir(path.dirname(binPath), { recursive: true })
130+
// Ensure the binary directory exists!
131+
await fs.mkdir(path.dirname(binPath), { recursive: true })
132+
const tempFile = binPath + ".temp-" + Math.random().toString(36).substring(8)
156133

157-
const completed = await vscode.window.withProgress<boolean>(
158-
{
159-
location: vscode.ProgressLocation.Notification,
160-
title: `Downloading the latest binary (${buildInfo.version} from ${baseURI.authority})`,
161-
cancellable: true,
162-
},
163-
async (progress, token) => {
164-
const readStream = resp.data as IncomingMessage
165-
let cancelled = false
166-
token.onCancellationRequested(() => {
167-
controller.abort()
168-
readStream.destroy()
169-
cancelled = true
170-
})
134+
const completed = await vscode.window.withProgress<boolean>(
135+
{
136+
location: vscode.ProgressLocation.Notification,
137+
title: `Downloading the latest binary (${buildInfo.version} from ${baseURI.authority})`,
138+
cancellable: true,
139+
},
140+
async (progress, token) => {
141+
const readStream = resp.data as IncomingMessage
142+
let cancelled = false
143+
token.onCancellationRequested(() => {
144+
controller.abort()
145+
readStream.destroy()
146+
cancelled = true
147+
})
171148

172-
let contentLengthPretty = ""
173-
// Reverse proxies might not always send a content length!
174-
if (!Number.isNaN(contentLength)) {
175-
contentLengthPretty = " / " + prettyBytes(contentLength)
176-
}
149+
let contentLengthPretty = ""
150+
// Reverse proxies might not always send a content length!
151+
if (!Number.isNaN(contentLength)) {
152+
contentLengthPretty = " / " + prettyBytes(contentLength)
153+
}
177154

178-
const writeStream = createWriteStream(binPath, {
179-
autoClose: true,
180-
mode: 0o755,
181-
})
182-
let written = 0
183-
readStream.on("data", (buffer: Buffer) => {
184-
writeStream.write(buffer, () => {
185-
written += buffer.byteLength
186-
progress.report({
187-
message: `${prettyBytes(written)}${contentLengthPretty}`,
188-
increment: (buffer.byteLength / contentLength) * 100,
155+
const writeStream = createWriteStream(tempFile, {
156+
autoClose: true,
157+
mode: 0o755,
189158
})
190-
})
159+
let written = 0
160+
readStream.on("data", (buffer: Buffer) => {
161+
writeStream.write(buffer, () => {
162+
written += buffer.byteLength
163+
progress.report({
164+
message: `${prettyBytes(written)}${contentLengthPretty}`,
165+
increment: (buffer.byteLength / contentLength) * 100,
166+
})
167+
})
168+
})
169+
try {
170+
await new Promise<void>((resolve, reject) => {
171+
readStream.on("error", (err) => {
172+
reject(err)
173+
})
174+
readStream.on("close", () => {
175+
if (cancelled) {
176+
return reject()
177+
}
178+
writeStream.close()
179+
resolve()
180+
})
181+
})
182+
return true
183+
} catch (ex) {
184+
return false
185+
}
186+
},
187+
)
188+
if (!completed) {
189+
return
190+
}
191+
this.output.appendLine(`Downloaded binary: ${binPath}`)
192+
const oldBinPath = binPath + ".old-" + Math.random().toString(36).substring(8)
193+
await fs.rename(binPath, oldBinPath).catch(() => {
194+
this.output.appendLine(`Warning: failed to rename ${binPath} to ${oldBinPath}`)
191195
})
192-
try {
193-
await new Promise<void>((resolve, reject) => {
194-
readStream.on("error", (err) => {
195-
reject(err)
196+
await fs.rename(tempFile, binPath)
197+
await fs.rm(oldBinPath, { force: true }).catch((error) => {
198+
this.output.appendLine(`Warning: failed to remove old binary: ${error}`)
199+
})
200+
return binPath
201+
}
202+
case 304: {
203+
this.output.appendLine(`Using cached binary: ${binPath}`)
204+
return binPath
205+
}
206+
case 404: {
207+
vscode.window
208+
.showErrorMessage(
209+
"Coder isn't supported for your platform. Please open an issue, we'd love to support it!",
210+
"Open an Issue",
211+
)
212+
.then((value) => {
213+
if (!value) {
214+
return
215+
}
216+
const params = new URLSearchParams({
217+
title: `Support the \`${os}-${arch}\` platform`,
218+
body: `I'd like to use the \`${os}-${arch}\` architecture with the VS Code extension.`,
196219
})
197-
readStream.on("close", () => {
198-
if (cancelled) {
199-
return reject()
200-
}
201-
writeStream.close()
202-
resolve()
220+
const uri = vscode.Uri.parse(`https://github.com/coder/vscode-coder/issues/new?` + params.toString())
221+
vscode.env.openExternal(uri)
222+
})
223+
return undefined
224+
}
225+
default: {
226+
vscode.window
227+
.showErrorMessage("Failed to download binary. Please open an issue.", "Open an Issue")
228+
.then((value) => {
229+
if (!value) {
230+
return
231+
}
232+
const params = new URLSearchParams({
233+
title: `Failed to download binary on \`${os}-${arch}\``,
234+
body: `Received status code \`${resp.status}\` when downloading the binary.`,
203235
})
236+
const uri = vscode.Uri.parse(`https://github.com/coder/vscode-coder/issues/new?` + params.toString())
237+
vscode.env.openExternal(uri)
204238
})
205-
return true
206-
} catch (ex) {
207-
return false
208-
}
209-
},
210-
)
211-
if (!completed) {
212-
return
239+
return undefined
240+
}
213241
}
214-
215-
this.output.appendLine(`Downloaded binary: ${binPath}`)
216-
return binPath
217242
}
218243

219244
// getBinaryCachePath returns the path where binaries are cached.
@@ -240,6 +265,23 @@ export class Storage {
240265
return path.join(this.globalStorageUri.fsPath, "url")
241266
}
242267

268+
public getBinaryETag(): Promise<string> {
269+
const hash = crypto.createHash("sha1")
270+
const stream = createReadStream(this.binaryPath())
271+
return new Promise((resolve, reject) => {
272+
stream.on("end", () => {
273+
hash.end()
274+
resolve(hash.digest("hex"))
275+
})
276+
stream.on("error", (err) => {
277+
reject(err)
278+
})
279+
stream.on("data", (chunk) => {
280+
hash.update(chunk)
281+
})
282+
})
283+
}
284+
243285
private appDataDir(): string {
244286
switch (process.platform) {
245287
case "darwin":
@@ -264,16 +306,62 @@ export class Storage {
264306
}
265307
}
266308

267-
private binaryPath(version: string): string {
309+
private async cleanUpOldBinaries(): Promise<void> {
310+
const binPath = this.binaryPath()
311+
const binDir = path.dirname(binPath)
312+
const files = await fs.readdir(binDir)
313+
for (const file of files) {
314+
const fileName = path.basename(file)
315+
if (fileName.includes(".old-")) {
316+
try {
317+
await fs.rm(path.join(binDir, file), { force: true })
318+
} catch (error) {
319+
this.output.appendLine(`Warning: failed to remove ${fileName}. Error: ${error}`)
320+
}
321+
}
322+
}
323+
}
324+
325+
private binaryPath(): string {
268326
const os = goos()
269327
const arch = goarch()
270-
let binPath = path.join(this.getBinaryCachePath(), `coder-${os}-${arch}-${version}`)
328+
let binPath = path.join(this.getBinaryCachePath(), `coder-${os}-${arch}`)
271329
if (os === "windows") {
272330
binPath += ".exe"
273331
}
274332
return binPath
275333
}
276334

335+
private async checkBinaryExists(binPath: string): Promise<boolean> {
336+
return await fs
337+
.stat(binPath)
338+
.then(() => true)
339+
.catch(() => false)
340+
}
341+
342+
private async rmBinary(binPath: string): Promise<boolean> {
343+
return await fs
344+
.rm(binPath, { force: true })
345+
.then(() => true)
346+
.catch(() => false)
347+
}
348+
349+
private async checkBinaryValid(binPath: string): Promise<boolean> {
350+
return await new Promise<boolean>((resolve) => {
351+
try {
352+
execFile(binPath, ["version"], (err) => {
353+
if (err) {
354+
this.output.appendLine("Check for binary corruption: " + err)
355+
}
356+
resolve(err === null)
357+
})
358+
} catch (ex) {
359+
this.output.appendLine("The cached binary cannot be executed: " + ex)
360+
resolve(false)
361+
}
362+
})
363+
}
364+
277365
private async updateSessionToken() {
278366
const token = await this.getSessionToken()
279367
if (token) {

0 commit comments

Comments
 (0)