Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

### Fixed

- Fixed SSH config writes failing on Windows when antivirus, cloud sync software,
or another process briefly locks the file.

### Added

- Automatically set `reconnectionGraceTime`, `serverShutdownTimeout`, and `maxReconnectionAttempts`
Expand Down
4 changes: 2 additions & 2 deletions src/core/cliCredentialManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as semver from "semver";
import { isKeyringEnabled } from "../cliConfig";
import { featureSetForVersion } from "../featureSet";
import { getHeaderArgs } from "../headers";
import { tempFilePath, toSafeHost } from "../util";
import { renameWithRetry, tempFilePath, toSafeHost } from "../util";

import * as cliUtils from "./cliUtils";

Expand Down Expand Up @@ -256,7 +256,7 @@ export class CliCredentialManager {
const tempPath = tempFilePath(filePath, "temp");
try {
await fs.writeFile(tempPath, content, { mode: 0o600 });
await fs.rename(tempPath, filePath);
await renameWithRetry(fs.rename, tempPath, filePath);
} catch (err) {
await fs.rm(tempPath, { force: true }).catch((rmErr) => {
this.logger.warn("Failed to delete temp file", tempPath, rmErr);
Expand Down
4 changes: 2 additions & 2 deletions src/remote/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import { vscodeProposed } from "../vscodeProposed";
import { WorkspaceMonitor } from "../workspace/workspaceMonitor";

import {
SSHConfig,
SshConfig,
type SSHValues,
mergeSshConfigValues,
parseCoderSshOptions,
Expand Down Expand Up @@ -766,7 +766,7 @@ export class Remote {
sshConfigFile = path.join(os.homedir(), sshConfigFile.slice(1));
}

const sshConfig = new SSHConfig(sshConfigFile);
const sshConfig = new SshConfig(sshConfigFile, this.logger);
await sshConfig.load();

// Merge SSH config from three sources (highest to lowest priority):
Expand Down
61 changes: 48 additions & 13 deletions src/remote/sshConfig.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
import {
mkdir,
readFile,
rename,
stat,
unlink,
writeFile,
} from "node:fs/promises";
import path from "node:path";

import { countSubstring, tempFilePath } from "../util";
import { countSubstring, renameWithRetry, tempFilePath } from "../util";

class SSHConfigBadFormat extends Error {}
import type { Logger } from "../logging/logger";

class SshConfigBadFormat extends Error {}

interface Block {
raw: string;
Expand All @@ -25,6 +34,7 @@ export interface FileSystem {
readFile: typeof readFile;
rename: typeof rename;
stat: typeof stat;
unlink: typeof unlink;
writeFile: typeof writeFile;
}

Expand All @@ -33,6 +43,7 @@ const defaultFileSystem: FileSystem = {
readFile,
rename,
stat,
unlink,
writeFile,
};

Expand Down Expand Up @@ -163,9 +174,10 @@ export function mergeSshConfigValues(
return merged;
}

export class SSHConfig {
export class SshConfig {
private readonly filePath: string;
private readonly fileSystem: FileSystem;
private readonly logger: Logger;
private raw: string | undefined;

private startBlockComment(safeHostname: string): string {
Expand All @@ -179,16 +191,25 @@ export class SSHConfig {
: `# --- END CODER VSCODE ---`;
}

constructor(filePath: string, fileSystem: FileSystem = defaultFileSystem) {
constructor(
filePath: string,
logger: Logger,
fileSystem: FileSystem = defaultFileSystem,
) {
this.filePath = filePath;
this.logger = logger;
this.fileSystem = fileSystem;
}

async load() {
try {
this.raw = await this.fileSystem.readFile(this.filePath, "utf-8");
this.logger.debug("Loaded SSH config", this.filePath);
} catch {
// Probably just doesn't exist!
this.logger.debug(
"SSH config file not found, starting fresh",
this.filePath,
);
this.raw = "";
}
}
Expand All @@ -204,8 +225,10 @@ export class SSHConfig {
const block = this.getBlock(safeHostname);
const newBlock = this.buildBlock(safeHostname, values, overrides);
if (block) {
this.logger.debug("Replacing SSH config block", safeHostname);
this.replaceBlock(block, newBlock);
} else {
this.logger.debug("Appending new SSH config block", safeHostname);
this.appendBlock(newBlock);
}
await this.save();
Expand All @@ -222,13 +245,13 @@ export class SSHConfig {
const startBlockCount = countSubstring(startBlock, raw);
const endBlockCount = countSubstring(endBlock, raw);
if (startBlockCount !== endBlockCount) {
throw new SSHConfigBadFormat(
throw new SshConfigBadFormat(
`Malformed config: ${this.filePath} has an unterminated START CODER VSCODE ${safeHostname ? safeHostname + " " : ""}block. Each START block must have an END block.`,
);
}

if (startBlockCount > 1 || endBlockCount > 1) {
throw new SSHConfigBadFormat(
throw new SshConfigBadFormat(
`Malformed config: ${this.filePath} has ${startBlockCount} START CODER VSCODE ${safeHostname ? safeHostname + " " : ""}sections. Please remove all but one.`,
);
}
Expand All @@ -241,15 +264,15 @@ export class SSHConfig {
}

if (startBlockIndex === -1) {
throw new SSHConfigBadFormat("Start block not found");
throw new SshConfigBadFormat("Start block not found");
}

if (startBlockIndex === -1) {
throw new SSHConfigBadFormat("End block not found");
throw new SshConfigBadFormat("End block not found");
}

if (endBlockIndex < startBlockIndex) {
throw new SSHConfigBadFormat(
throw new SshConfigBadFormat(
"Malformed config, end block is before start block",
);
}
Expand Down Expand Up @@ -357,8 +380,20 @@ export class SSHConfig {
}

try {
await this.fileSystem.rename(tempPath, this.filePath);
await renameWithRetry(
(src, dest) => this.fileSystem.rename(src, dest),
tempPath,
this.filePath,
);
this.logger.debug("Saved SSH config", this.filePath);
} catch (err) {
await this.fileSystem.unlink(tempPath).catch((unlinkErr: unknown) => {
this.logger.warn(
"Failed to clean up temp SSH config file",
tempPath,
unlinkErr,
);
});
throw new Error(
`Failed to rename temporary SSH config file at ${tempPath} to ${this.filePath}: ${
err instanceof Error ? err.message : String(err)
Expand All @@ -370,7 +405,7 @@ export class SSHConfig {

public getRaw() {
if (this.raw === undefined) {
throw new Error("SSHConfig is not loaded. Try sshConfig.load()");
throw new Error("SshConfig is not loaded. Try sshConfig.load()");
}

return this.raw;
Expand Down
45 changes: 45 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,51 @@ export function countSubstring(needle: string, haystack: string): number {
return count;
}

const transientRenameCodes: ReadonlySet<string> = new Set([
"EPERM",
"EACCES",
"EBUSY",
]);

/**
* Rename with retry for transient Windows filesystem errors (EPERM, EACCES,
* EBUSY). On Windows, antivirus, Search Indexer, cloud sync, or concurrent
* processes can briefly lock files causing renames to fail.
*
* On non-Windows platforms, calls renameFn directly with no retry.
*
* Matches the strategy used by VS Code (pfs.ts) and graceful-fs: 60s
* wall-clock timeout with linear backoff (10ms increments) capped at 100ms.
*/
export async function renameWithRetry(
renameFn: (src: string, dest: string) => Promise<void>,
source: string,
destination: string,
timeoutMs = 60_000,
delayCapMs = 100,
): Promise<void> {
if (process.platform !== "win32") {
return renameFn(source, destination);
}
const startTime = Date.now();
for (let attempt = 1; ; attempt++) {
try {
return await renameFn(source, destination);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (
!code ||
!transientRenameCodes.has(code) ||
Date.now() - startTime >= timeoutMs
) {
throw err;
}
const delay = Math.min(delayCapMs, attempt * 10);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}

export function escapeCommandArg(arg: string): string {
const escapedString = arg.replaceAll('"', String.raw`\"`);
return `"${escapedString}"`;
Expand Down
Loading