Skip to content

Commit c254bc1

Browse files
osortegaCopilot
andcommitted
Fix sandbox activation races and initial connection interactivity
Reject stale activation results after cancellation, feature teardown, or provider replacement. Keep initial sandbox connections read-only while preserving input during self-healing reconnects. Add regression coverage for review feedback on #334644, including actual environment lookup failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 631adcc commit c254bc1

4 files changed

Lines changed: 192 additions & 83 deletions

File tree

src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts

Lines changed: 31 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -463,44 +463,45 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo
463463
return authority ? byAuthority.get(authority) : undefined;
464464
}
465465

466-
/**
467-
* Async-activation hook for a sandbox session type: make the session openable, either by
468-
* connecting to an environment that is already online — resolving once the host advertises the
469-
* agent backing this session type, so the chat can load — or by serving its persisted history
470-
* read-only. Returns false only when the environment is unknown, or nothing can be shown at
471-
* all: no live host and no history to fall back on.
472-
*/
466+
/** Opens an online environment through its host, or an offline session from persisted history. */
473467
protected async _waitForActivation(sessionType: string): Promise<boolean> {
474468
const address = this._findAddressForSessionType(sessionType);
475469
const env = address ? this._environments.get(address) : undefined;
476-
if (!address || !env) {
470+
const provider = address ? this._providerInstances.get(address) : undefined;
471+
if (!address || !env || !provider) {
477472
return false;
478473
}
479-
// Resuming is a side effect the user has to ask for. Mission Control wakes the environment
480-
// behind `/connect`, and it cannot say in advance whether a dormant one will come back, so
481-
// opening a session must not gamble minutes of wake on the guess. Read the environment's
482-
// state first and dial only what is already online; anything else opens from history with
483-
// the connection banner offering the connect.
484-
//
485-
// Without a task there is no history to serve, and refusing to open would leave the
486-
// session unreachable entirely — so fall through to the connect, which is the only way
487-
// such a session can show anything at all.
488-
if (env.taskId && !await this._isEnvironmentOnline(env)) {
474+
const token = this._enabledCts.token;
475+
const isCurrentActivation = () => {
476+
const current = !token.isCancellationRequested
477+
&& this._isEnabled()
478+
&& this._environments.has(address)
479+
&& this._providerInstances.get(address) === provider;
480+
if (!current) {
481+
this._logService.trace(`${LOG_PREFIX} Abandoning activation for ${address} after teardown.`);
482+
}
483+
return current;
484+
};
485+
486+
// Without a task there is no history fallback, so connecting is the only way to open it.
487+
const shouldConnect = !env.taskId || await this._isEnvironmentOnline(env, token);
488+
if (!isCurrentActivation()) {
489+
return false;
490+
}
491+
if (!shouldConnect) {
489492
this._logService.info(`${LOG_PREFIX} Environment for ${address} is not online; serving history and leaving the connect to the user.`);
490-
return this._activateReadOnly(sessionType, address, env, this._fetchTaskHistory(env));
493+
return this._activateReadOnly(sessionType, address, env, this._fetchTaskHistory(env, token));
491494
}
492495

493496
const connectError = await this
494497
.connect({ environmentId: env.environmentId, sessionId: env.sessionId, name: env.name })
495498
.then(() => undefined, (error: unknown) => error ?? new Error('connect failed'));
499+
if (!isCurrentActivation()) {
500+
return false;
501+
}
496502
if (connectError !== undefined) {
497503
this._logService.warn(`${LOG_PREFIX} connect-on-open failed for ${address}: ${connectError instanceof Error ? connectError.message : String(connectError)}`);
498-
// Serve history whatever the reason: `/connect` fails in several ways for a deleted
499-
// sandbox, so gating on any one of them would leave the rest with no history.
500-
if (this._isEnabled() && !this._enabledCts.token.isCancellationRequested) {
501-
return this._activateReadOnly(sessionType, address, env, this._fetchTaskHistory(env));
502-
}
503-
return false;
504+
return this._activateReadOnly(sessionType, address, env, this._fetchTaskHistory(env, token));
504505
}
505506
const authority = agentHostAuthority(address);
506507
while (true) {
@@ -519,34 +520,23 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo
519520
}
520521
}
521522

522-
/**
523-
* Whether the environment currently has a daemon listening on the relay. Only `online` does, so
524-
* only `online` can be dialled without triggering a resume.
525-
*
526-
* An unreadable record answers `false`: this gates a side effect the user has not asked for, so
527-
* a Mission Control blip must cost a click rather than an unrequested wake.
528-
*/
529-
private async _isEnvironmentOnline(env: ICloudSandboxEnvironment): Promise<boolean> {
523+
/** An unreadable record must not trigger an automatic resume. */
524+
private async _isEnvironmentOnline(env: ICloudSandboxEnvironment, token: CancellationToken): Promise<boolean> {
530525
try {
531-
const record = await this._apiService.getEnvironment(env.environmentId, this._enabledCts.token);
526+
const record = await this._apiService.getEnvironment(env.environmentId, token);
532527
return record.status === 'online';
533528
} catch (error) {
534529
this._logService.trace(`${LOG_PREFIX} Could not read the state of ${env.environmentId}; treating it as not online: ${error instanceof Error ? error.message : String(error)}`);
535530
return false;
536531
}
537532
}
538533

539-
/**
540-
* A task's persisted history, or `undefined` when there is no task or the read failed. Served
541-
* by Mission Control rather than the sandbox, so it stays readable while the environment is
542-
* asleep or gone. Never rejects.
543-
*/
544-
private _fetchTaskHistory(env: ICloudSandboxEnvironment): Promise<IReplayedTaskHistory | undefined> | undefined {
534+
/** Reads history from Mission Control without connecting to the sandbox. */
535+
private _fetchTaskHistory(env: ICloudSandboxEnvironment, token: CancellationToken): Promise<IReplayedTaskHistory | undefined> | undefined {
545536
const taskId = env.taskId;
546537
if (!taskId) {
547538
return undefined;
548539
}
549-
const token = this._enabledCts.token;
550540
return this._apiService.getSessionHistory(taskId, token).catch((error: unknown) => {
551541
this._logService.trace(`${LOG_PREFIX} History read for ${env.environmentId} did not complete: ${error instanceof Error ? error.message : String(error)}`);
552542
return undefined;

src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,7 @@ export interface IRemoteAgentHostSessionsProviderConfig {
9191
readonly omitHostFromWorkspaceLabel?: boolean;
9292
/** Type icon for this host's workspaces. See {@link ISessionWorkspace.typeIcon}. */
9393
readonly workspaceTypeIcon?: ThemeIcon;
94-
/**
95-
* Forces this host's sessions read-only whenever it is not connected. Set by hosts that cannot
96-
* accept work offline — a cloud sandbox has to be resumed before it can be sent to, so a
97-
* composer would take input that nothing will ever deliver. Hosts left with the default keep
98-
* their sessions writable while disconnected, queuing the input for the reconnect.
99-
*/
94+
/** Keeps unavailable and initially connecting sessions read-only, but permits self-healing reconnects. */
10095
readonly readOnlyWhenDisconnected?: boolean;
10196
/** See {@link IAgentHostAdapterOptions.defaultChangesetKind}. */
10297
readonly defaultChangesetKind?: ChangesetKind.Branch | ChangesetKind.Uncommitted | ChangesetKind.Session;
@@ -148,14 +143,6 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid
148143
private readonly _automationStore: ReconnectableAgentHostAutomationStore;
149144

150145
private readonly _connectionStatus = observableValue<RemoteAgentHostConnectionStatus>('connectionStatus', RemoteAgentHostConnectionStatus.disconnected);
151-
/**
152-
* Whether every session on this host is read-only, which hides the composer.
153-
*
154-
* Only ever true for hosts configured with
155-
* {@link IRemoteAgentHostSessionsProviderConfig.readOnlyWhenDisconnected}, and only while they
156-
* are disconnected: elsewhere a dropped host keeps its sessions writable so the input queues
157-
* for the reconnect.
158-
*/
159146
private readonly _readOnly: IObservable<boolean>;
160147
readonly connectionStatus: IObservable<RemoteAgentHostConnectionStatus> = this._connectionStatus;
161148

@@ -256,13 +243,10 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid
256243
this.connectionLabels = config.connectionLabels;
257244
this.canConnectOnDemand = !!config.connectOnDemand;
258245
this._readOnly = config.readOnlyWhenDisconnected
259-
// Deliberately not `!isConnected`: that would include `reconnecting`, which is the
260-
// protocol client restoring a dropped transport while the host itself is up. Input
261-
// sent then is delivered once the transport returns, so hiding the composer would
262-
// interrupt a conversation mid-sentence over a blip the user should not notice.
263246
? derived(this, reader => {
264247
const status = this._connectionStatus.read(reader);
265248
return RemoteAgentHostConnectionStatus.isDisconnected(status)
249+
|| RemoteAgentHostConnectionStatus.isConnecting(status)
266250
|| RemoteAgentHostConnectionStatus.isIncompatible(status);
267251
})
268252
: constObservable(false);

src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts

Lines changed: 134 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import assert from 'assert';
7+
import { DeferredPromise } from '../../../../../../base/common/async.js';
78
import { CancellationToken } from '../../../../../../base/common/cancellation.js';
9+
import { CancellationError } from '../../../../../../base/common/errors.js';
810
import { Event } from '../../../../../../base/common/event.js';
911
import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js';
1012
import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js';
@@ -31,7 +33,7 @@ import {
3133
} from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js';
3234
import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js';
3335
import { IObservable, observableValue } from '../../../../../../base/common/observable.js';
34-
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
36+
import { ConfigurationTarget, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
3537
import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js';
3638
import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
3739
import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js';
@@ -156,6 +158,7 @@ const GITHUB_SANDBOX_GROUP: IAgentHostGroup = {
156158
interface ITestHarness {
157159
readonly contribution: TestCloudSandboxContribution;
158160
readonly configurationService: TestConfigurationService;
161+
setEnabled(enabled: boolean): Promise<void>;
159162
/** Discovery's answer, mutable so a test can change what a later pass reports. */
160163
discovered: readonly ICloudSandboxDiscoveredSession[];
161164
/** Runs a discovery pass and waits for it to reconcile. */
@@ -170,6 +173,7 @@ interface ITestHarness {
170173
activate(environmentId: string): Promise<boolean>;
171174
readonly created: ICloudSandboxCreateSessionRequest[];
172175
readonly connectedTo: string[];
176+
readonly historyRequests: string[];
173177
/** Host groups currently declared to the filter service. */
174178
readonly hostGroups: IAgentHostGroup[];
175179
}
@@ -182,6 +186,7 @@ interface ITestHarness {
182186
async function createContribution(store: Pick<DisposableStore, 'add'>, sessions: readonly ICloudSandboxDiscoveredSession[], options?: {
183187
/** Task Mission Control returns from `createSession`, or a rejection. */
184188
readonly createSession?: () => Promise<ICloudSandboxCreatedSession>;
189+
readonly getEnvironment?: (id: string, token: CancellationToken) => Promise<ICloudSandboxEnvironmentRecord>;
185190
/** Whether the sandbox feature settings start on. Defaults to `true`. */
186191
readonly enabled?: boolean;
187192
}): Promise<ITestHarness> {
@@ -191,13 +196,24 @@ async function createContribution(store: Pick<DisposableStore, 'add'>, sessions:
191196
const instantiationService = store.add(new TestInstantiationService());
192197
const created: ICloudSandboxCreateSessionRequest[] = [];
193198
const connectedTo: string[] = [];
199+
const historyRequests: string[] = [];
194200
const harness: ITestHarness = {
195201
discovered: sessions,
196202
environmentStatus: 'offline',
197203
readOnlySessionTypes,
198204
created,
199205
connectedTo,
206+
historyRequests,
200207
hostGroups,
208+
setEnabled: async (enabled: boolean) => {
209+
await configurationService.setUserConfiguration(CloudSandboxEnabledSettingId, enabled);
210+
configurationService.onDidChangeConfigurationEmitter.fire({
211+
affectsConfiguration: key => key === CloudSandboxEnabledSettingId,
212+
affectedKeys: new Set([CloudSandboxEnabledSettingId]),
213+
change: { keys: [CloudSandboxEnabledSettingId], overrides: [] },
214+
source: ConfigurationTarget.USER,
215+
});
216+
},
201217
runDiscovery: async () => { await Promise.all(discoveryHandlers.map(handler => handler())); },
202218
activate: async (environmentId: string) => {
203219
const sessionType = remoteAgentHostSessionTypeId(agentHostAuthority(cloudSandboxAddress(environmentId)), CLOUD_SANDBOX_AGENT_PROVIDER);
@@ -209,10 +225,14 @@ async function createContribution(store: Pick<DisposableStore, 'add'>, sessions:
209225
override async listSessions(_token: CancellationToken): Promise<ICloudSandboxDiscoveryResult> {
210226
return { kind: 'complete', sessions: harness.discovered };
211227
}
212-
override async getEnvironment(id: string): Promise<ICloudSandboxEnvironmentRecord> {
228+
override async getEnvironment(id: string, token: CancellationToken): Promise<ICloudSandboxEnvironmentRecord> {
229+
if (options?.getEnvironment) {
230+
return options.getEnvironment(id, token);
231+
}
213232
return { id, status: harness.environmentStatus };
214233
}
215-
override async getSessionHistory(): Promise<IReplayedTaskHistory> {
234+
override async getSessionHistory(taskId: string): Promise<IReplayedTaskHistory> {
235+
historyRequests.push(taskId);
216236
return { sessions: [], truncated: false };
217237
}
218238
override async createSession(request: ICloudSandboxCreateSessionRequest): Promise<ICloudSandboxCreatedSession> {
@@ -414,13 +434,120 @@ suite('CloudSandboxAgentHostContribution', () => {
414434
});
415435

416436
test('does not wake an environment whose state could not be read', async () => {
417-
// A Mission Control blip must cost a click, not an unrequested resume.
418-
const harness = await createContribution(store, [discoveredSession()]);
419-
harness.environmentStatus = 'degraded';
437+
const harness = await createContribution(store, [discoveredSession()], {
438+
getEnvironment: async () => { throw new Error('Expected environment lookup failure'); },
439+
});
420440

421441
const opened = await harness.activate('env-1');
422442

423-
assert.deepStrictEqual({ opened, connectedTo: harness.connectedTo }, { opened: true, connectedTo: [] });
443+
assert.deepStrictEqual({
444+
opened,
445+
connectedTo: harness.connectedTo,
446+
historyRequests: harness.historyRequests,
447+
servedFromHistory: harness.readOnlySessionTypes.length,
448+
}, { opened: true, connectedTo: [], historyRequests: ['task-1'], servedFromHistory: 1 });
449+
});
450+
451+
for (const reenable of [false, true]) {
452+
test(`abandons a cancelled environment lookup when the feature is ${reenable ? 're-enabled' : 'disabled'}`, async () => {
453+
const environment = new DeferredPromise<ICloudSandboxEnvironmentRecord>();
454+
const requestedToken = new DeferredPromise<CancellationToken>();
455+
const harness = await createContribution(store, [discoveredSession()], {
456+
getEnvironment: (_id, token) => {
457+
void requestedToken.complete(token);
458+
return environment.p;
459+
},
460+
});
461+
const activation = harness.activate('env-1');
462+
const token = await requestedToken.p;
463+
464+
await harness.setEnabled(false);
465+
if (reenable) {
466+
await harness.setEnabled(true);
467+
await harness.runDiscovery();
468+
}
469+
await environment.error(new CancellationError());
470+
471+
assert.deepStrictEqual({
472+
opened: await activation,
473+
cancelled: token.isCancellationRequested,
474+
connectedTo: harness.connectedTo,
475+
historyRequests: harness.historyRequests,
476+
readOnlySessionTypes: harness.readOnlySessionTypes,
477+
}, { opened: false, cancelled: true, connectedTo: [], historyRequests: [], readOnlySessionTypes: [] });
478+
});
479+
}
480+
481+
for (const status of ['online', 'offline'] as const) {
482+
test(`does not reactivate a removed environment after a late ${status} record`, async () => {
483+
const environment = new DeferredPromise<ICloudSandboxEnvironmentRecord>();
484+
const harness = await createContribution(store, [discoveredSession()], {
485+
getEnvironment: () => environment.p,
486+
});
487+
const activation = harness.activate('env-1');
488+
harness.discovered = [];
489+
await harness.runDiscovery();
490+
await environment.complete({ id: 'env-1', status });
491+
492+
assert.deepStrictEqual({
493+
opened: await activation,
494+
connectedTo: harness.connectedTo,
495+
historyRequests: harness.historyRequests,
496+
readOnlySessionTypes: harness.readOnlySessionTypes,
497+
}, { opened: false, connectedTo: [], historyRequests: [], readOnlySessionTypes: [] });
498+
});
499+
}
500+
501+
test('does not register old history against a replacement provider at the same address', async () => {
502+
const environment = new DeferredPromise<ICloudSandboxEnvironmentRecord>();
503+
const harness = await createContribution(store, [discoveredSession()], {
504+
getEnvironment: () => environment.p,
505+
});
506+
const activation = harness.activate('env-1');
507+
harness.discovered = [];
508+
await harness.runDiscovery();
509+
harness.discovered = [discoveredSession({ taskId: 'task-2' })];
510+
await harness.runDiscovery();
511+
await environment.complete({ id: 'env-1', status: 'offline' });
512+
513+
assert.deepStrictEqual({
514+
opened: await activation,
515+
historyRequests: harness.historyRequests,
516+
readOnlySessionTypes: harness.readOnlySessionTypes,
517+
}, { opened: false, historyRequests: [], readOnlySessionTypes: [] });
518+
});
519+
520+
test('keeps activation valid across a discovery refresh of the same provider', async () => {
521+
const environment = new DeferredPromise<ICloudSandboxEnvironmentRecord>();
522+
const harness = await createContribution(store, [discoveredSession()], {
523+
getEnvironment: () => environment.p,
524+
});
525+
const activation = harness.activate('env-1');
526+
await harness.runDiscovery();
527+
await environment.complete({ id: 'env-1', status: 'offline' });
528+
529+
assert.deepStrictEqual({
530+
opened: await activation,
531+
connectedTo: harness.connectedTo,
532+
historyRequests: harness.historyRequests,
533+
}, { opened: true, connectedTo: [], historyRequests: ['task-1'] });
534+
});
535+
536+
test('does not restore history after an old connect fails across disable and re-enable', async () => {
537+
const harness = await createContribution(store, [discoveredSession()]);
538+
harness.environmentStatus = 'online';
539+
harness.onConnect = async () => {
540+
await harness.setEnabled(false);
541+
await harness.setEnabled(true);
542+
await harness.runDiscovery();
543+
throw new Error('Expected connection failure after teardown');
544+
};
545+
546+
assert.deepStrictEqual({
547+
opened: await harness.activate('env-1'),
548+
historyRequests: harness.historyRequests,
549+
readOnlySessionTypes: harness.readOnlySessionTypes,
550+
}, { opened: false, historyRequests: [], readOnlySessionTypes: [] });
424551
});
425552

426553
test('connects a dormant environment that has no history to fall back on', async () => {

0 commit comments

Comments
 (0)