Skip to content

Commit 19084ea

Browse files
committed
fix: address shared workspaces review findings
verifyAndUpdateSession keeps verify-before-apply: it verifies the rotated token before touching the live client, rotates credentials in place when the user is unchanged (no revision bump or tree rebuild), and recovers a session that was suspended mid-verify instead of getting stuck signed out (CRF-1, CRF-2). Also: - bound fetch() retries with a per-attempt disposed check, and dispose a metadata watcher created while dispose() races the fetch (CRF-3, CRF-8) - dispose the tree-data EventEmitter on dispose() (CRF-15) - rename clearSideEffects to clearAuthState (CRF-12) - document the session snapshot revision contract (CRF-13) - drop the dead getAxiosInstance mock helper and restore an unrelated blank line (CRF-14, CRF-16) - trim comments that restated the code (CRF-9) - add tests for dispose lifecycle, verify failure, signed-out startup, and stale-fetch recovery (CRF-4 through CRF-7)
1 parent 080604a commit 19084ea

8 files changed

Lines changed: 240 additions & 62 deletions

File tree

src/deployment/deploymentManager.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ export class DeploymentManager
200200
this.#authListenerDisposable?.dispose();
201201
this.#authListenerDisposable = undefined;
202202
this.#sessionStore.signOut(null);
203-
this.clearSideEffects();
203+
this.clearAuthState();
204204
this.telemetryService.setDeploymentUrl("");
205205

206206
await this.secretsManager.setCurrentDeployment(undefined);
@@ -212,10 +212,10 @@ export class DeploymentManager
212212
*/
213213
public suspendSession(): void {
214214
this.#sessionStore.signOut(this.#sessionStore.current.deployment);
215-
this.clearSideEffects();
215+
this.clearAuthState();
216216
}
217217

218-
private clearSideEffects(): void {
218+
private clearAuthState(): void {
219219
this.oauthSessionManager.clearDeployment();
220220
this.client.setCredentials(undefined, undefined);
221221
this.updateAuthContexts(undefined);
@@ -278,6 +278,10 @@ export class DeploymentManager
278278
);
279279
}
280280

281+
/**
282+
* Handle a token change while already signed in: verify the new token before
283+
* applying it, so an unverified token never reaches the live client.
284+
*/
281285
private async verifyAndUpdateSession(
282286
deployment: Deployment & { token: string },
283287
): Promise<void> {
@@ -287,10 +291,30 @@ export class DeploymentManager
287291
deployment.url,
288292
deployment.token,
289293
);
290-
if (this.#disposed || this.#sessionStore.current !== sessionBefore) {
294+
if (this.#disposed) {
291295
return;
292296
}
293-
await this.setDeployment({ ...deployment, user });
297+
const current = this.#sessionStore.current;
298+
299+
if (current === sessionBefore) {
300+
// Same-user rotation only needs fresh credentials; skipping
301+
// setDeployment avoids a revision bump and tree rebuild.
302+
if (current.kind === "signedIn" && current.user.id === user.id) {
303+
this.client.setCredentials(deployment.url, deployment.token);
304+
} else {
305+
await this.setDeployment({ ...deployment, user });
306+
}
307+
return;
308+
}
309+
310+
// Session changed under us: recover only a same-deployment suspension
311+
// (e.g. a concurrent 401); a logout or switch wins.
312+
if (
313+
current.kind === "signedOut" &&
314+
current.deployment?.safeHostname === deployment.safeHostname
315+
) {
316+
await this.setDeployment({ ...deployment, user });
317+
}
294318
} catch (e) {
295319
this.logger.warn("Failed to authenticate updated session:", e);
296320
}

src/deployment/sessionStore.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,12 @@ export class SessionStore implements WorkspaceSessionState {
3838

3939
public readonly onDidChange = this.#onDidChange.event;
4040

41-
/** The full current session, including deployment and user. */
41+
/** Full session state, including deployment and user. */
4242
public get current(): SessionData {
4343
return this.#data;
4444
}
4545

46-
/** The lean projection exposed to session-state consumers. */
46+
/** Lean projection for consumers that only track auth status and revision. */
4747
public getSnapshot(): WorkspaceSessionSnapshot {
4848
if (this.#data.kind === "signedIn") {
4949
return {

src/extension.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ async function doActivate(
101101
? await secretsManager.getSessionAuth(deployment.safeHostname)
102102
: undefined;
103103
tracer.setAuthState(deploymentSessionAuth ? "stored" : "none");
104+
104105
// Shared handler for auth failures (used by interceptor + session manager)
105106
const handleAuthFailure = (): Promise<void> => {
106107
deploymentManager.suspendSession();

src/workspace/session.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import type * as vscode from "vscode";
22

3+
/**
4+
* A point-in-time view of the deployment session for workspace providers.
5+
*
6+
* `revision` increments on every sign-in or sign-out. Consumers snapshot the
7+
* revision before an async call and compare afterward to detect that the
8+
* session changed while the call was in flight.
9+
*/
310
export type WorkspaceSessionSnapshot =
411
| { readonly kind: "signedOut"; readonly revision: number }
512
| {

src/workspace/workspacesProvider.ts

Lines changed: 60 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ export interface WorkspaceProviderOptions {
6060
readonly refreshIntervalMs?: number;
6161
}
6262

63+
// Bounds fetch() retries when the session keeps changing mid-request.
64+
const MAX_FETCH_ATTEMPTS = 3;
65+
6366
/**
6467
* Polls workspaces using the provided REST client and renders them in a tree.
6568
*
@@ -119,7 +122,6 @@ export class WorkspaceProvider
119122
this.fetching = false;
120123
}
121124

122-
// Keep polling only after a clean fetch while still signed in and visible.
123125
if (
124126
!hadError &&
125127
!this.disposed &&
@@ -143,65 +145,75 @@ export class WorkspaceProvider
143145
* signed out, and throws if the query fails.
144146
*/
145147
private async fetch(): Promise<WorkspaceTreeItem[]> {
146-
const session = this.sessionState.getSnapshot();
147-
if (session.kind !== "signedIn") {
148-
return [];
149-
}
148+
for (let attempt = 0; attempt < MAX_FETCH_ATTEMPTS; attempt++) {
149+
if (this.disposed) {
150+
return [];
151+
}
152+
const session = this.sessionState.getSnapshot();
153+
if (session.kind !== "signedIn") {
154+
return [];
155+
}
150156

151-
const resp = await this.client.getWorkspaces({
152-
q: this.getWorkspacesQuery,
153-
});
157+
const resp = await this.client.getWorkspaces({
158+
q: this.getWorkspacesQuery,
159+
});
154160

155-
// If the session changed while the request was in flight, this result is
156-
// stale. Drop it and fetch again for the current session.
157-
const latest = this.sessionState.getSnapshot();
158-
if (latest.kind !== "signedIn" || latest.revision !== session.revision) {
159-
return this.fetch();
160-
}
161+
// Session changed mid-request; this result is stale, so retry.
162+
const latest = this.sessionState.getSnapshot();
163+
if (latest.kind !== "signedIn" || latest.revision !== session.revision) {
164+
continue;
165+
}
161166

162-
const workspaces = this.filterWorkspaces(resp.workspaces, session);
163-
const oldWatcherIds = [...this.agentWatchers.keys()];
164-
const reusedWatcherIds: string[] = [];
165-
166-
// TODO: I think it might make more sense for the tree items to contain
167-
// their own watchers, rather than recreate the tree items every time and
168-
// have this separate map held outside the tree.
169-
if (this.config.showMetadata) {
170-
const agents = extractAllAgents(workspaces);
171-
for (const agent of agents) {
172-
// If we have an existing watcher, re-use it.
173-
const oldWatcher = this.agentWatchers.get(agent.id);
174-
if (oldWatcher) {
175-
reusedWatcherIds.push(agent.id);
176-
} else {
177-
// Otherwise create a new watcher.
167+
const workspaces = this.filterWorkspaces(resp.workspaces, session);
168+
const oldWatcherIds = [...this.agentWatchers.keys()];
169+
const reusedWatcherIds: string[] = [];
170+
171+
// TODO: I think it might make more sense for the tree items to contain
172+
// their own watchers, rather than recreate the tree items every time
173+
// and have this separate map held outside the tree.
174+
if (this.config.showMetadata) {
175+
const agents = extractAllAgents(workspaces);
176+
for (const agent of agents) {
177+
// If we have an existing watcher, re-use it.
178+
const oldWatcher = this.agentWatchers.get(agent.id);
179+
if (oldWatcher) {
180+
reusedWatcherIds.push(agent.id);
181+
continue;
182+
}
178183
const watcher = await createAgentMetadataWatcher(
179184
agent.id,
180185
this.client,
181186
);
187+
// dispose() may have cleared the map mid-create; don't leak
188+
// this watcher.
189+
if (this.disposed) {
190+
watcher.dispose();
191+
return [];
192+
}
182193
watcher.onChange(() => this.refreshTree());
183194
this.agentWatchers.set(agent.id, watcher);
184195
}
185196
}
186-
}
187197

188-
// Dispose of watchers we ended up not reusing.
189-
for (const id of oldWatcherIds) {
190-
if (!reusedWatcherIds.includes(id)) {
191-
this.agentWatchers.get(id)?.dispose();
192-
this.agentWatchers.delete(id);
198+
// Dispose of watchers we ended up not reusing.
199+
for (const id of oldWatcherIds) {
200+
if (!reusedWatcherIds.includes(id)) {
201+
this.agentWatchers.get(id)?.dispose();
202+
this.agentWatchers.delete(id);
203+
}
193204
}
194-
}
195205

196-
// Create tree items for each workspace
197-
return workspaces.map(
198-
(workspace: Workspace) =>
199-
new WorkspaceTreeItem(
200-
workspace,
201-
this.config.showOwner,
202-
this.config.showMetadata,
203-
),
204-
);
206+
return workspaces.map(
207+
(workspace: Workspace) =>
208+
new WorkspaceTreeItem(
209+
workspace,
210+
this.config.showOwner,
211+
this.config.showMetadata,
212+
),
213+
);
214+
}
215+
// Session changed on every attempt; the next refresh will catch up.
216+
return [];
205217
}
206218

207219
private filterWorkspaces(
@@ -244,10 +256,7 @@ export class WorkspaceProvider
244256
}
245257
}
246258

247-
/**
248-
* Schedule a refresh if one is not already scheduled or underway and a
249-
* timeout length was provided.
250-
*/
259+
/** Schedule the next poll, unless one is pending or no interval is set. */
251260
private maybeScheduleRefresh() {
252261
if (this.options.refreshIntervalMs && !this.timeout) {
253262
this.timeout = setTimeout(() => {
@@ -370,6 +379,7 @@ export class WorkspaceProvider
370379
this.disposed = true;
371380
this.clearState();
372381
this.sessionChangeDisposable.dispose();
382+
this._onDidChangeTreeData.dispose();
373383
}
374384
}
375385

test/mocks/testHelpers.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1254,7 +1254,6 @@ interface WorkspacesResponse {
12541254
* tests drive the production watcher rather than a stub.
12551255
*/
12561256
export class MockWorkspacesClient {
1257-
baseURL: string | undefined = "https://coder.example.com";
12581257
readonly metadataStreams = new Map<
12591258
string,
12601259
MockEventStream<{ data: AgentMetadataEvent[] }>
@@ -1271,10 +1270,6 @@ export class MockWorkspacesClient {
12711270
return Promise.resolve(stream);
12721271
}
12731272

1274-
getAxiosInstance() {
1275-
return { defaults: { baseURL: this.baseURL } };
1276-
}
1277-
12781273
/** Resolve the next getWorkspaces call with these workspaces. */
12791274
respondOnce(workspaces: readonly Workspace[]): void {
12801275
this.getWorkspaces.mockResolvedValueOnce({

test/unit/deployment/deploymentManager.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,94 @@ describe("DeploymentManager", () => {
478478
expect(manager.getCurrentDeployment()).toBeNull();
479479
expect(currentUserId(manager)).toBeUndefined();
480480
});
481+
482+
it("rotates the token in place without a revision bump when the user is unchanged", async () => {
483+
const { mockClient, validationMockClient, secretsManager, manager } =
484+
createTestContext();
485+
const user = createMockUser({ id: "stable-user" });
486+
validationMockClient.setAuthenticatedUserResponse(user);
487+
488+
await manager.setDeployment({
489+
url: TEST_URL,
490+
safeHostname: TEST_HOSTNAME,
491+
token: "initial-token",
492+
user,
493+
});
494+
const revisionBefore = manager.getSnapshot().revision;
495+
496+
await secretsManager.setSessionAuth(TEST_HOSTNAME, {
497+
url: TEST_URL,
498+
token: "rotated-token",
499+
});
500+
await flush();
501+
502+
expect(mockClient.token).toBe("rotated-token");
503+
expect(currentUserId(manager)).toBe("stable-user");
504+
// No sign-in fired, so the workspace trees are not rebuilt.
505+
expect(manager.getSnapshot().revision).toBe(revisionBefore);
506+
});
507+
508+
it("keeps the existing session when verifying a rotated token fails", async () => {
509+
const { mockClient, validationMockClient, secretsManager, manager } =
510+
createTestContext();
511+
const user = createMockUser({ id: "stable-user" });
512+
513+
await manager.setDeployment({
514+
url: TEST_URL,
515+
safeHostname: TEST_HOSTNAME,
516+
token: "initial-token",
517+
user,
518+
});
519+
520+
// Verifying the rotated token fails (e.g. a transient network blip).
521+
validationMockClient.getAuthenticatedUser.mockRejectedValueOnce(
522+
new Error("network down"),
523+
);
524+
await secretsManager.setSessionAuth(TEST_HOSTNAME, {
525+
url: TEST_URL,
526+
token: "rotated-token",
527+
});
528+
await flush();
529+
530+
// Verify-before-apply: the unverified token never reaches the client.
531+
expect(mockClient.token).toBe("initial-token");
532+
expect(manager.isAuthenticated()).toBe(true);
533+
expect(currentUserId(manager)).toBe("stable-user");
534+
});
535+
536+
it("recovers a session suspended while a rotated token is verified", async () => {
537+
const { mockClient, validationMockClient, secretsManager, manager } =
538+
createTestContext();
539+
const user = createMockUser({ id: "stable-user" });
540+
const validation = Promise.withResolvers<typeof user>();
541+
validationMockClient.getAuthenticatedUser.mockReturnValueOnce(
542+
validation.promise,
543+
);
544+
545+
await manager.setDeployment({
546+
url: TEST_URL,
547+
safeHostname: TEST_HOSTNAME,
548+
token: "initial-token",
549+
user,
550+
});
551+
await secretsManager.setSessionAuth(TEST_HOSTNAME, {
552+
url: TEST_URL,
553+
token: "rotated-token",
554+
});
555+
await flush();
556+
557+
// A concurrent 401 on the old token suspends the session mid-verify.
558+
manager.suspendSession();
559+
expect(manager.isAuthenticated()).toBe(false);
560+
561+
validation.resolve(user);
562+
await flush();
563+
564+
// The verified token recovers the session instead of staying suspended.
565+
expect(manager.isAuthenticated()).toBe(true);
566+
expect(mockClient.token).toBe("rotated-token");
567+
expect(currentUserId(manager)).toBe("stable-user");
568+
});
481569
});
482570

483571
describe("logout", () => {

0 commit comments

Comments
 (0)