Skip to content

Commit f0df04e

Browse files
authored
feat: set remote.SSH.reconnectionGraceTime and refactor settings manipulation (#826)
- Extract SSH settings manipulation from remote.ts into a reusable userSettings.ts module with buildSshOverrides() and applySettingOverrides() - Automatically set reconnectionGraceTime (8h), serverShutdownTimeout (8h), and maxReconnectionAttempts (unlimited) on first connection to prevent disconnects during overnight workspace sleep - Add "Coder: Apply Recommended SSH Settings" command to overwrite all recommended SSH settings at once via the command palette Closes #823
1 parent 55e8486 commit f0df04e

File tree

10 files changed

+488
-75
lines changed

10 files changed

+488
-75
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
### Added
66

7+
- Automatically set `reconnectionGraceTime`, `serverShutdownTimeout`, and `maxReconnectionAttempts`
8+
on first connection to prevent disconnects during overnight workspace sleep.
9+
- New **Coder: Apply Recommended SSH Settings** command to overwrite all recommended SSH settings at once.
710
- Proxy log directory now defaults to the extension's global storage when `coder.proxyLogDirectory`
811
is not set, so SSH connection logs are always captured without manual configuration. Also respects
912
the `CODER_SSH_LOG_DIR` environment variable as a fallback.

package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,11 @@
320320
"title": "Refresh Tasks",
321321
"category": "Coder",
322322
"icon": "$(refresh)"
323+
},
324+
{
325+
"command": "coder.applyRecommendedSettings",
326+
"title": "Apply Recommended SSH Settings",
327+
"category": "Coder"
323328
}
324329
],
325330
"menus": {
@@ -386,6 +391,9 @@
386391
{
387392
"command": "coder.tasks.refresh",
388393
"when": "false"
394+
},
395+
{
396+
"command": "coder.applyRecommendedSettings"
389397
}
390398
],
391399
"view/title": [

src/commands.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ import { type Logger } from "./logging/logger";
2424
import { type LoginCoordinator } from "./login/loginCoordinator";
2525
import { withProgress } from "./progress";
2626
import { maybeAskAgent, maybeAskUrl } from "./promptUtils";
27+
import {
28+
RECOMMENDED_SSH_SETTINGS,
29+
applySettingOverrides,
30+
} from "./remote/userSettings";
2731
import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util";
2832
import { vscodeProposed } from "./vscodeProposed";
2933
import {
@@ -310,6 +314,55 @@ export class Commands {
310314
}
311315
}
312316

317+
/**
318+
* Apply recommended SSH settings for reliable Coder workspace connections.
319+
*/
320+
public async applyRecommendedSettings(): Promise<void> {
321+
const entries = Object.entries(RECOMMENDED_SSH_SETTINGS);
322+
const summary = entries.map(([, s]) => s.label).join("\n");
323+
const confirm = await vscodeProposed.window.showWarningMessage(
324+
"Apply Recommended SSH Settings",
325+
{
326+
useCustom: true,
327+
modal: true,
328+
detail: summary,
329+
},
330+
"Apply",
331+
);
332+
if (confirm !== "Apply") {
333+
return;
334+
}
335+
336+
const overrides = entries.map(([key, setting]) => ({
337+
key,
338+
value: setting.value,
339+
}));
340+
const ok = await applySettingOverrides(
341+
this.pathResolver.getUserSettingsPath(),
342+
overrides,
343+
this.logger,
344+
);
345+
if (!ok) {
346+
const action = await vscode.window.showErrorMessage(
347+
"Failed to write SSH settings. Check the Coder output for details.",
348+
"Show Output",
349+
);
350+
if (action === "Show Output") {
351+
this.logger.show();
352+
}
353+
} else if (this.remoteWorkspaceClient) {
354+
const action = await vscode.window.showInformationMessage(
355+
"Applied recommended SSH settings. Reload the window for changes to take effect.",
356+
"Reload Window",
357+
);
358+
if (action === "Reload Window") {
359+
await vscode.commands.executeCommand("workbench.action.reloadWindow");
360+
}
361+
} else {
362+
vscode.window.showInformationMessage("Applied recommended SSH settings.");
363+
}
364+
}
365+
313366
/**
314367
* Create a new workspace for the currently logged-in deployment.
315368
*

src/extension.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
271271
"coder.manageCredentials",
272272
commands.manageCredentials.bind(commands),
273273
),
274+
vscode.commands.registerCommand(
275+
"coder.applyRecommendedSettings",
276+
commands.applyRecommendedSettings.bind(commands),
277+
),
274278
);
275279

276280
const remote = new Remote(serviceContainer, commands, ctx);

src/logging/logger.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ export interface Logger {
44
info(message: string, ...args: unknown[]): void;
55
warn(message: string, ...args: unknown[]): void;
66
error(message: string, ...args: unknown[]): void;
7+
show(): void;
78
}

src/remote/remote.ts

Lines changed: 12 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {
44
type Workspace,
55
type WorkspaceAgent,
66
} from "coder/site/src/api/typesGenerated";
7-
import * as jsonc from "jsonc-parser";
87
import * as fs from "node:fs/promises";
98
import * as os from "node:os";
109
import * as path from "node:path";
@@ -60,6 +59,7 @@ import {
6059
} from "./sshConfig";
6160
import { SshProcessMonitor } from "./sshProcess";
6261
import { computeSshProperties, sshSupportsSetEnv } from "./sshSupport";
62+
import { applySettingOverrides, buildSshOverrides } from "./userSettings";
6363
import { WorkspaceStateMachine } from "./workspaceStateMachine";
6464

6565
export interface RemoteDetails extends vscode.Disposable {
@@ -459,83 +459,20 @@ export class Remote {
459459
const inbox = await Inbox.create(workspace, workspaceClient, this.logger);
460460
disposables.push(inbox);
461461

462-
// Do some janky setting manipulation.
463462
this.logger.info("Modifying settings...");
464-
const remotePlatforms = vscodeProposed.workspace
465-
.getConfiguration()
466-
.get<Record<string, string>>("remote.SSH.remotePlatform", {});
467-
const connTimeout = vscodeProposed.workspace
468-
.getConfiguration()
469-
.get<number | undefined>("remote.SSH.connectTimeout");
470-
471-
// We have to directly munge the settings file with jsonc because trying to
472-
// update properly through the extension API hangs indefinitely. Possibly
473-
// VS Code is trying to update configuration on the remote, which cannot
474-
// connect until we finish here leading to a deadlock. We need to update it
475-
// locally, anyway, and it does not seem possible to force that via API.
476-
let settingsContent = "{}";
477-
try {
478-
settingsContent = await fs.readFile(
463+
const overrides = buildSshOverrides(
464+
vscodeProposed.workspace.getConfiguration(),
465+
parts.sshHost,
466+
agent.operating_system,
467+
);
468+
if (overrides.length > 0) {
469+
const ok = await applySettingOverrides(
479470
this.pathResolver.getUserSettingsPath(),
480-
"utf8",
481-
);
482-
} catch {
483-
// Ignore! It's probably because the file doesn't exist.
484-
}
485-
486-
// Add the remote platform for this host to bypass a step where VS Code asks
487-
// the user for the platform.
488-
let mungedPlatforms = false;
489-
if (
490-
!remotePlatforms[parts.sshHost] ||
491-
remotePlatforms[parts.sshHost] !== agent.operating_system
492-
) {
493-
remotePlatforms[parts.sshHost] = agent.operating_system;
494-
settingsContent = jsonc.applyEdits(
495-
settingsContent,
496-
jsonc.modify(
497-
settingsContent,
498-
["remote.SSH.remotePlatform"],
499-
remotePlatforms,
500-
{},
501-
),
471+
overrides,
472+
this.logger,
502473
);
503-
mungedPlatforms = true;
504-
}
505-
506-
// VS Code ignores the connect timeout in the SSH config and uses a default
507-
// of 15 seconds, which can be too short in the case where we wait for
508-
// startup scripts. For now we hardcode a longer value. Because this is
509-
// potentially overwriting user configuration, it feels a bit sketchy. If
510-
// microsoft/vscode-remote-release#8519 is resolved we can remove this.
511-
const minConnTimeout = 1800;
512-
let mungedConnTimeout = false;
513-
if (!connTimeout || connTimeout < minConnTimeout) {
514-
settingsContent = jsonc.applyEdits(
515-
settingsContent,
516-
jsonc.modify(
517-
settingsContent,
518-
["remote.SSH.connectTimeout"],
519-
minConnTimeout,
520-
{},
521-
),
522-
);
523-
mungedConnTimeout = true;
524-
}
525-
526-
if (mungedPlatforms || mungedConnTimeout) {
527-
try {
528-
await fs.writeFile(
529-
this.pathResolver.getUserSettingsPath(),
530-
settingsContent,
531-
);
532-
} catch (ex) {
533-
// This could be because the user's settings.json is read-only. This is
534-
// the case when using home-manager on NixOS, for example. Failure to
535-
// write here is not necessarily catastrophic since the user will be
536-
// asked for the platform and the default timeout might be sufficient.
537-
mungedPlatforms = mungedConnTimeout = false;
538-
this.logger.warn("Failed to configure settings", ex);
474+
if (ok) {
475+
this.logger.info("Settings modified successfully");
539476
}
540477
}
541478

src/remote/userSettings.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { formatDuration, intervalToDuration } from "date-fns";
2+
import * as jsonc from "jsonc-parser";
3+
import * as fs from "node:fs/promises";
4+
5+
import type { WorkspaceConfiguration } from "vscode";
6+
7+
import type { Logger } from "../logging/logger";
8+
9+
export interface SettingOverride {
10+
key: string;
11+
value: unknown;
12+
}
13+
14+
interface RecommendedSetting {
15+
readonly value: number | null;
16+
readonly label: string;
17+
}
18+
19+
function recommended(
20+
shortName: string,
21+
value: number | null,
22+
): RecommendedSetting {
23+
if (value === null) {
24+
return { value, label: `${shortName}: max allowed` };
25+
}
26+
const humanized = formatDuration(
27+
intervalToDuration({ start: 0, end: value * 1000 }),
28+
);
29+
return { value, label: `${shortName}: ${humanized}` };
30+
}
31+
32+
/**
33+
* Applied by the "Apply Recommended SSH Settings" command.
34+
* These are more aggressive (24h) than AUTO_SETUP_DEFAULTS (8h) because the
35+
* user is explicitly opting in via the command palette.
36+
*/
37+
export const RECOMMENDED_SSH_SETTINGS = {
38+
"remote.SSH.connectTimeout": recommended("Connect Timeout", 1800),
39+
"remote.SSH.reconnectionGraceTime": recommended(
40+
"Reconnection Grace Time",
41+
86400,
42+
),
43+
"remote.SSH.serverShutdownTimeout": recommended(
44+
"Server Shutdown Timeout",
45+
86400,
46+
),
47+
"remote.SSH.maxReconnectionAttempts": recommended(
48+
"Max Reconnection Attempts",
49+
null,
50+
),
51+
} as const satisfies Record<string, RecommendedSetting>;
52+
53+
type SshSettingKey = keyof typeof RECOMMENDED_SSH_SETTINGS;
54+
55+
/** Defaults set during connection when the user hasn't configured a value. */
56+
const AUTO_SETUP_DEFAULTS = {
57+
"remote.SSH.reconnectionGraceTime": 28800, // 8h
58+
"remote.SSH.serverShutdownTimeout": 28800, // 8h
59+
"remote.SSH.maxReconnectionAttempts": null, // max allowed
60+
} as const satisfies Partial<Record<SshSettingKey, number | null>>;
61+
62+
/**
63+
* Build the list of VS Code setting overrides needed for a remote SSH
64+
* connection to a Coder workspace.
65+
*/
66+
export function buildSshOverrides(
67+
config: Pick<WorkspaceConfiguration, "get">,
68+
sshHost: string,
69+
agentOS: string,
70+
): SettingOverride[] {
71+
const overrides: SettingOverride[] = [];
72+
73+
// Set the remote platform for this host to bypass the platform prompt.
74+
const remotePlatforms = config.get<Record<string, string>>(
75+
"remote.SSH.remotePlatform",
76+
{},
77+
);
78+
if (remotePlatforms[sshHost] !== agentOS) {
79+
overrides.push({
80+
key: "remote.SSH.remotePlatform",
81+
value: { ...remotePlatforms, [sshHost]: agentOS },
82+
});
83+
}
84+
85+
// Default 15s is too short for startup scripts; enforce a minimum.
86+
const connTimeoutKey: SshSettingKey = "remote.SSH.connectTimeout";
87+
const { value: minConnTimeout } = RECOMMENDED_SSH_SETTINGS[connTimeoutKey];
88+
const connTimeout = config.get<number>(connTimeoutKey);
89+
if (minConnTimeout && (!connTimeout || connTimeout < minConnTimeout)) {
90+
overrides.push({ key: connTimeoutKey, value: minConnTimeout });
91+
}
92+
93+
// Set conservative defaults for settings the user hasn't configured.
94+
for (const [key, value] of Object.entries(AUTO_SETUP_DEFAULTS)) {
95+
if (config.get(key) === undefined) {
96+
overrides.push({ key, value });
97+
}
98+
}
99+
100+
return overrides;
101+
}
102+
103+
/**
104+
* Apply setting overrides to the user's settings.json file.
105+
*
106+
* We munge the file directly with jsonc instead of using the VS Code API
107+
* because the API hangs indefinitely during remote connection setup (likely
108+
* a deadlock from trying to update config on the not-yet-connected remote).
109+
*/
110+
export async function applySettingOverrides(
111+
settingsFilePath: string,
112+
overrides: SettingOverride[],
113+
logger: Logger,
114+
): Promise<boolean> {
115+
if (overrides.length === 0) {
116+
return true;
117+
}
118+
119+
let settingsContent = "{}";
120+
try {
121+
settingsContent = await fs.readFile(settingsFilePath, "utf8");
122+
} catch {
123+
// File probably doesn't exist yet.
124+
}
125+
126+
for (const { key, value } of overrides) {
127+
settingsContent = jsonc.applyEdits(
128+
settingsContent,
129+
jsonc.modify(settingsContent, [key], value, {}),
130+
);
131+
}
132+
133+
try {
134+
await fs.writeFile(settingsFilePath, settingsContent);
135+
return true;
136+
} catch (ex) {
137+
// Could be read-only (e.g. home-manager on NixOS). Not catastrophic.
138+
logger.warn("Failed to configure settings", ex);
139+
return false;
140+
}
141+
}

test/mocks/testHelpers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ export function createMockLogger(): Logger {
391391
info: vi.fn(),
392392
warn: vi.fn(),
393393
error: vi.fn(),
394+
show: vi.fn(),
394395
};
395396
}
396397

test/unit/error/serverCertificateError.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ describe("Certificate errors", () => {
4040
info: throwingLog,
4141
warn: throwingLog,
4242
error: throwingLog,
43+
show: () => {},
4344
};
4445

4546
const disposers: Array<() => void> = [];

0 commit comments

Comments
 (0)