Skip to content

Commit bf287af

Browse files
committed
fix: queue workspace updates as one build and stop autostart races
`coder update` runs a stop build and then a start build. Between the two, the extension's own `coder ssh` reconnect autostarts the workspace on the old template version, so the update's start build is rejected with "A workspace build is already active" and the workspace comes back unchanged. On servers from 2.36, post a single build that carries the start in `on_success`, so nothing can take the build slot in between. Leave `template_version_id` unset on the follow-up build, because pinning it requires template update permission and the update targets the active version anyway. Servers before 2.36 keep the `coder update` path, and CLIs before 2.24 keep the REST path. Pass `--disable-autostart` as an internal SSH flag so a custom `coder.sshFlags` value cannot drop it, and ask before connecting to the existing version when an update fails instead of falling back silently. Fixes #1095
1 parent e772660 commit bf287af

11 files changed

Lines changed: 400 additions & 101 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@
183183
]
184184
},
185185
"coder.sshFlags": {
186-
"markdownDescription": "Additional flags to pass to the `coder ssh` command when establishing SSH connections. Enter each flag as a separate array item; values are passed verbatim and in order. See the [CLI ssh reference](https://coder.com/docs/reference/cli/ssh) for available flags.\n\nNote: `--network-info-dir` and `--ssh-host-prefix` are ignored (managed internally). Prefer `#coder.proxyLogDirectory#` over `--log-dir`/`-l` for full functionality.",
186+
"markdownDescription": "Additional flags to pass to the `coder ssh` command when establishing SSH connections. Enter each flag as a separate array item; values are passed verbatim and in order. See the [CLI ssh reference](https://coder.com/docs/reference/cli/ssh) for available flags.\n\nNote: `--disable-autostart`, `--network-info-dir`, and `--ssh-host-prefix` are managed internally. Prefer `#coder.proxyLogDirectory#` over `--log-dir`/`-l` for full functionality.",
187187
"type": "array",
188188
"items": {
189189
"type": "string"

‎src/api/workspace.ts‎

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,30 @@
11
import { spawn } from "node:child_process";
2+
import * as semver from "semver";
23
import * as vscode from "vscode";
34

5+
import { versionAtLeast, type FeatureSet } from "../featureSet";
46
import { getGlobalFlags, type CliAuth } from "../settings/cli";
57

68
import { errToStr, createWorkspaceIdentifier } from "./api-helper";
79

810
import type { Api } from "coder/site/src/api/api";
911
import type {
12+
CreateWorkspaceBuildOnSuccessRequest,
1013
ProvisionerJobLog,
1114
Workspace,
1215
WorkspaceAgentLog,
1316
WorkspaceBuildParameter,
1417
} from "coder/site/src/api/typesGenerated";
1518

16-
import type { FeatureSet } from "../featureSet";
1719
import type { UnidirectionalStream } from "../websocket/eventStreamConnection";
1820

1921
import type { CoderApi } from "./coderApi";
2022

23+
/** Server version that stops and starts in one build via `on_success`. */
24+
const RESTART_BUILD_VERSION = "2.36.0";
25+
26+
const UPDATE_REASON = "vscode_connection";
27+
2128
/** Opens a stream once; subsequent open() calls are no-ops until closed. */
2229
export class LazyStream<T> {
2330
private stream: UnidirectionalStream<T> | null = null;
@@ -108,7 +115,7 @@ export async function startWorkspace(ctx: CliContext): Promise<Workspace> {
108115

109116
const args = ["start", "--yes"];
110117
if (ctx.featureSet.buildReason) {
111-
args.push("--reason", "vscode_connection");
118+
args.push("--reason", UPDATE_REASON);
112119
}
113120

114121
await runCliCommand(ctx, args);
@@ -118,13 +125,18 @@ export async function startWorkspace(ctx: CliContext): Promise<Workspace> {
118125
/**
119126
* Update a workspace to the latest template version. Callers must collect
120127
* any newly-required parameters via `collectUpdateParameters` first; this
121-
* function does not prompt. Falls back to the REST API on CLIs older than
122-
* 2.24.
128+
* function does not prompt. Servers older than 2.36 need two builds, via
129+
* `coder update` or, on CLIs older than 2.24, the REST API.
123130
*/
124131
export async function updateWorkspace(
125132
ctx: CliContext,
126133
parameters: WorkspaceBuildParameter[],
127134
): Promise<Workspace> {
135+
const { version } = await ctx.restClient.getBuildInfo();
136+
if (versionAtLeast(semver.parse(version), RESTART_BUILD_VERSION)) {
137+
return updateWorkspaceInOneBuild(ctx, parameters);
138+
}
139+
128140
if (!ctx.featureSet.cliUpdate) {
129141
return updateWorkspaceViaApi(ctx, parameters);
130142
}
@@ -137,6 +149,43 @@ export async function updateWorkspace(
137149
return ctx.restClient.getWorkspace(ctx.workspace.id);
138150
}
139151

152+
/**
153+
* Queues the stop and the start as a single build request, so no other build
154+
* can take the slot in between and start the outdated version. Returns the
155+
* accepted build; the server creates the start build once the stop succeeds.
156+
*/
157+
async function updateWorkspaceInOneBuild(
158+
ctx: CliContext,
159+
parameters: WorkspaceBuildParameter[],
160+
): Promise<Workspace> {
161+
// Re-read so the transition matches the current build, which may have
162+
// changed while parameters were being collected.
163+
const workspace = await ctx.restClient.getWorkspace(ctx.workspace.id);
164+
const start: CreateWorkspaceBuildOnSuccessRequest = {
165+
transition: "start",
166+
rich_parameter_values: parameters,
167+
};
168+
if (workspace.latest_build.status !== "running") {
169+
ctx.write("Starting workspace with the updated template...\r\n");
170+
const build = await ctx.restClient.postWorkspaceBuild(workspace.id, {
171+
...start,
172+
reason: UPDATE_REASON,
173+
template_version_id: workspace.template_active_version_id,
174+
});
175+
return { ...workspace, latest_build: build };
176+
}
177+
178+
ctx.write("Restarting workspace with the updated template...\r\n");
179+
// A follow-up build can only pin a template version with template update
180+
// permission, so leave it unset to take the active version at start time.
181+
const build = await ctx.restClient.postWorkspaceBuild(workspace.id, {
182+
transition: "stop",
183+
reason: UPDATE_REASON,
184+
on_success: start,
185+
});
186+
return { ...workspace, latest_build: build };
187+
}
188+
140189
async function updateWorkspaceViaApi(
141190
ctx: CliContext,
142191
parameters: WorkspaceBuildParameter[],
@@ -145,8 +194,8 @@ async function updateWorkspaceViaApi(
145194
ctx.write("Stopping workspace for update...\r\n");
146195
const stopBuild = await ctx.restClient.stopWorkspace(ctx.workspace.id);
147196
const stoppedJob = await ctx.restClient.waitForBuild(stopBuild);
148-
if (stoppedJob?.status === "canceled") {
149-
throw new Error("Workspace update cancelled during stop");
197+
if (stoppedJob?.status !== "succeeded") {
198+
throw new Error("Workspace update stop build did not succeed");
150199
}
151200
}
152201

‎src/featureSet.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ export interface FeatureSet {
1414
}
1515

1616
/**
17-
* True when the CLI version is at least `minVersion`, or is a dev build.
17+
* True when the version is at least `minVersion`, or is a dev build.
1818
* Returns false for null (unknown) versions.
1919
*/
20-
function versionAtLeast(
20+
export function versionAtLeast(
2121
version: semver.SemVer | null,
2222
minVersion: string,
2323
): boolean {

‎src/instrumentation/workspace.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type { TelemetryReporter } from "../telemetry/reporter";
1111
import type { Span } from "../telemetry/span";
1212

1313
export type WorkspacePromptAction = "start" | "update";
14-
export type WorkspaceUpdatePrompt = "parameters" | "confirmation";
14+
export type WorkspaceUpdatePrompt = "parameters" | "confirmation" | "failure";
1515

1616
/**
1717
* Emits `workspace.state_transitioned` for a detected workspace transition.
@@ -152,6 +152,19 @@ export class WorkspaceOperationTelemetry {
152152
});
153153
}
154154

155+
/** Records whether the user connects to the existing version anyway. */
156+
public traceFailurePrompt(fn: () => Promise<boolean>): Promise<boolean> {
157+
return this.traceUpdatePrompt("failure", async (span) => {
158+
const connect = await fn();
159+
if (!connect) {
160+
span.markAborted();
161+
return false;
162+
}
163+
span.setProperty("action", "connect");
164+
return true;
165+
});
166+
}
167+
155168
private traceUpdatePrompt<T>(
156169
prompt: WorkspaceUpdatePrompt,
157170
fn: (span: Span) => Promise<T>,

‎src/remote/remote.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -855,6 +855,7 @@ export class Remote {
855855
const userSshFlags = getSshFlags(vscodeConfig);
856856
// Make sure to update the `coder.sshFlags` description if we add more internal flags here!
857857
const internalFlags = [
858+
"--disable-autostart",
858859
"--stdio",
859860
"--usage-app=vscode",
860861
"--network-info-dir",

‎src/remote/workspaceStateMachine.ts‎

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import * as vscode from "vscode";
2-
31
import {
42
createWorkspaceIdentifier,
53
errToStr,
@@ -27,6 +25,7 @@ import type {
2725
Workspace,
2826
WorkspaceAgentLog,
2927
} from "coder/site/src/api/typesGenerated";
28+
import type * as vscode from "vscode";
3029

3130
import type { CoderApi } from "../api/coderApi";
3231
import type { ServiceContainer } from "../core/container";
@@ -48,6 +47,8 @@ export class WorkspaceStateMachine implements vscode.Disposable {
4847

4948
private agent: { id: string; name: string } | undefined;
5049
private workspace: Workspace | undefined;
50+
/** Build number of the update we queued, once one has been accepted. */
51+
private updatedBuildNumber: number | undefined;
5152

5253
private readonly logger: Logger;
5354

@@ -78,6 +79,18 @@ export class WorkspaceStateMachine implements vscode.Disposable {
7879
workspace: Workspace,
7980
progress: vscode.Progress<{ message?: string }>,
8081
): Promise<boolean> {
82+
if (this.updatedBuildNumber !== undefined) {
83+
// Monitor events queued before the update must not resolve the
84+
// connection or move log streaming back to the previous build.
85+
if (workspace.latest_build.build_number < this.updatedBuildNumber) {
86+
return false;
87+
}
88+
this.updatedBuildNumber = workspace.latest_build.build_number;
89+
}
90+
if (workspace.latest_build.id !== this.workspace?.latest_build.id) {
91+
// Build logs stream from one build, so a new build needs a new stream.
92+
this.buildLogStream.close();
93+
}
8194
this.workspace = workspace;
8295
const workspaceName = createWorkspaceIdentifier(workspace);
8396

@@ -90,17 +103,29 @@ export class WorkspaceStateMachine implements vscode.Disposable {
90103
progress,
91104
);
92105
if (updated) {
93-
workspace = updated;
94106
// Agent IDs may have changed after an update.
95107
this.resetAgent();
96-
if (workspace.latest_build.status !== "running") return false;
108+
return this.processWorkspace(updated, progress);
97109
}
98110
break;
99111
}
100112

101113
case "stopped":
102114
case "failed": {
103115
this.buildLogStream.close();
116+
if (this.updatedBuildNumber !== undefined) {
117+
if (workspace.latest_build.status === "failed") {
118+
throw new Error(
119+
`Update failed for ${workspaceName}. Check the workspace in the dashboard before retrying.`,
120+
);
121+
}
122+
// The server starts the workspace once the stop build
123+
// succeeds; starting it here would race that build.
124+
progress.report({
125+
message: `waiting for the server to start ${workspaceName}...`,
126+
});
127+
return false;
128+
}
104129

105130
if (this.startupMode === "none") {
106131
const choice = await this.confirmStartOrUpdate(
@@ -119,13 +144,11 @@ export class WorkspaceStateMachine implements vscode.Disposable {
119144
progress,
120145
);
121146
if (updated) {
122-
workspace = updated;
123147
// Agent IDs may have changed after an update.
124148
this.resetAgent();
125-
if (workspace.latest_build.status !== "running") return false;
126-
break;
149+
return this.processWorkspace(updated, progress);
127150
}
128-
// Either we weren't in update mode, or the update failed: start.
151+
// Start only when no update was requested.
129152
await this.triggerStart(workspace, workspaceName, progress);
130153
return false;
131154
}
@@ -289,7 +312,7 @@ export class WorkspaceStateMachine implements vscode.Disposable {
289312
this.logger.info(`${workspaceName} start initiated`);
290313
}
291314

292-
/** No-op if not in update mode. Falls through to start on failure. */
315+
/** No-op outside update mode; asks before falling back to the old version. */
293316
private async maybeUpdate(
294317
workspace: Workspace,
295318
workspaceName: string,
@@ -309,8 +332,6 @@ export class WorkspaceStateMachine implements vscode.Disposable {
309332
this.workspace = await this.operationTelemetry.traceUpdate(() =>
310333
updateWorkspace(this.buildCliContext(workspace), parameters),
311334
);
312-
this.logger.info(`${workspaceName} update initiated`);
313-
return this.workspace;
314335
} catch (error) {
315336
if (error instanceof WorkspaceUpdateCancelledError) {
316337
this.logger.info(
@@ -320,11 +341,36 @@ export class WorkspaceStateMachine implements vscode.Disposable {
320341
}
321342
const reason = errToStr(error);
322343
this.logger.warn(`Update failed for ${workspaceName}: ${reason}`);
323-
vscode.window.showWarningMessage(
324-
`Workspace update failed: ${reason}. Continuing with the existing version.`,
344+
const connect = await this.operationTelemetry.traceFailurePrompt(() =>
345+
this.confirmConnectToExisting(workspaceName, reason),
325346
);
347+
if (!connect) {
348+
throw error;
349+
}
350+
this.logger.info(`Connecting to the existing ${workspaceName} version`);
326351
return undefined;
327352
}
353+
this.updatedBuildNumber = this.workspace.latest_build.build_number;
354+
this.logger.info(`${workspaceName} update initiated`);
355+
return this.workspace;
356+
}
357+
358+
/** Offers the existing version when the update could not be queued. */
359+
private async confirmConnectToExisting(
360+
workspaceName: string,
361+
reason: string,
362+
): Promise<boolean> {
363+
const action = "Connect Anyway";
364+
const choice = await vscodeProposed.window.showWarningMessage(
365+
`Failed to update ${workspaceName}`,
366+
{
367+
useCustom: true,
368+
modal: true,
369+
detail: `${reason}\n\nTo connect without updating, choose ${action}.`,
370+
},
371+
action,
372+
);
373+
return choice === action;
328374
}
329375

330376
private async confirmStartOrUpdate(

0 commit comments

Comments
 (0)