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
23 changes: 19 additions & 4 deletions src/vs/sessions/browser/parts/sessionRemoteConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ import { ISessionsProvidersService } from '../../services/sessions/browser/sessi
import { IRemoteHostUnavailableEmptyStateContent } from './remoteHostUnavailableEmptyState.js';
import { ISessionReadOnlyBannerContent } from './sessionReadOnlyBanner.js';

const RECONNECTING_BANNER_DELAY = 1_000;
/**
* How long a host must stay unreachable before the banner appears.
*
* Sized to outlast a transport blip the protocol client heals by itself: such a
* reconnect preserves session state, so the user would not otherwise notice it.
*/
const RECONNECTING_BANNER_DELAY = 5_000;

function isSameRemoteConnectionStatus(a: SessionRemoteConnectionStatus | undefined, b: SessionRemoteConnectionStatus | undefined): boolean {
if (!a || !b) {
Expand Down Expand Up @@ -76,17 +82,26 @@ export class SessionRemoteConnection extends Disposable {
&& this._attempt.read(reader) === undefined;
});

/**
* When the current outage began, or `undefined` while the host is reachable.
*
* Recomputed eagerly so the cache cannot outlive its observers: nothing reads
* the reconnecting state while the host is connected, and a derived that
* stops being observed keeps its last value without ever recomputing it, so a
* later outage would inherit the previous one's start time and skip the delay.
*
* Keyed by session only to guard future reuse: a ChatGroupView is currently
* created per session, so the cache cannot outlive the session it belongs to.
*/
private readonly _reconnectingSince = derivedObservableWithCache<{ readonly session: IActiveSession; readonly since: number } | undefined>(this, (reader, last) => {
const session = this._session.read(reader);
const status = this._getEffectiveStatus(reader);
const attempt = this._attempt.read(reader);
if (!session || status?.kind !== 'reconnecting' || attempt?.kind === 'active') {
return undefined;
}
// Keyed by session only to guard future reuse: a ChatGroupView is currently
// created per session, so the cache cannot outlive the session it belongs to.
return last?.session === session ? last : { session, since: Date.now() };
});
}).recomputeInitiallyAndOnChange(this._store);

private readonly _reconnectingBannerVisible = derived(this, reader => {
const reconnecting = this._reconnectingSince.read(reader);
Expand Down
52 changes: 40 additions & 12 deletions src/vs/sessions/test/browser/chatGroupsView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1087,7 +1087,9 @@ suite('Sessions - ChatGroupsView', () => {

remoteConnectionStatus.set({ kind: 'reconnecting' }, undefined);
remoteConnectionStatus.set({ kind: 'connected' }, undefined);
await timeout(1_000);
// Past the delay, so this proves the settled connection suppresses the
// banner rather than the threshold simply not having elapsed.
await timeout(6_000);

assert.deepStrictEqual(readBanner(view), { visible: false, message: 'This chat is read-only', action: undefined });
});
Expand All @@ -1102,9 +1104,9 @@ suite('Sessions - ChatGroupsView', () => {
const session = new TestActiveSession([chat], undefined, true, provider.id, { kind: 'reconnecting' });
view.setSession(session, options);

await timeout(500);
await timeout(3_000);
chat.status.set(SessionStatus.Error, undefined);
await timeout(500);
await timeout(3_000);

assert.deepStrictEqual(readBanner(view), {
visible: true,
Expand All @@ -1119,16 +1121,16 @@ suite('Sessions - ChatGroupsView', () => {
const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables);
const provider = new TestAgentHostProvider();
sessionsProvidersService.provider = provider;
const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'reconnecting', nextAttemptAt: Date.now() + 6_000 });
const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'reconnecting', nextAttemptAt: Date.now() + 12_000 });
view.setSession(session, options);
chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined);

await timeout(1_000);
await timeout(5_500);
const banner = readBanner(view);
view.element.querySelector<HTMLElement>('.session-readonly-banner-action-link')?.click();

assert.deepStrictEqual({ banner, reconnectNowCalls: provider.reconnectNowCalls }, {
banner: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 5s', action: 'Try Now' },
banner: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 7s', action: 'Try Now' },
reconnectNowCalls: 1,
});
});
Expand All @@ -1139,21 +1141,47 @@ suite('Sessions - ChatGroupsView', () => {
const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables);
const provider = new TestAgentHostProvider();
sessionsProvidersService.provider = provider;
const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'reconnecting', nextAttemptAt: Date.now() + 7_000 });
const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'reconnecting', nextAttemptAt: Date.now() + 13_000 });
view.setSession(session, options);
chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined);

await timeout(1_000);
await timeout(5_500);
const beforeTick = readBanner(view);
await timeout(1_000);

assert.deepStrictEqual({ beforeTick, afterTick: readBanner(view) }, {
beforeTick: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 6s', action: 'Try Now' },
afterTick: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 5s', action: 'Try Now' },
beforeTick: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 8s', action: 'Try Now' },
afterTick: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 7s', action: 'Try Now' },
});
});
});

test('stays quiet while a flapping transport keeps healing itself', async () => {
await runWithFakedTimers({ useFakeTimers: true }, async () => {
const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables);
const provider = new TestAgentHostProvider();
sessionsProvidersService.provider = provider;
const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'connected' });
const remoteConnectionStatus = session.remoteConnectionStatus;
assert.ok(remoteConnectionStatus);
view.setSession(session, options);
chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined);

// Every outage heals well inside the delay, so none is worth a banner.
const banners: boolean[] = [];
for (let i = 0; i < 5; i++) {
remoteConnectionStatus.set({ kind: 'reconnecting' }, undefined);
await timeout(2_100);
banners.push(readBanner(view).visible);
remoteConnectionStatus.set({ kind: 'connected' }, undefined);
await timeout(3_700);
banners.push(readBanner(view).visible);
}

assert.deepStrictEqual(banners, [false, false, false, false, false, false, false, false, false, false]);
});
});

test('shows a plain reconnecting banner while a reconnect attempt is in flight', async () => {
await runWithFakedTimers({ useFakeTimers: true }, async () => {
const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables);
Expand All @@ -1163,7 +1191,7 @@ suite('Sessions - ChatGroupsView', () => {
view.setSession(session, options);
chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined);

await timeout(1_000);
await timeout(6_000);

assert.deepStrictEqual(readBanner(view), {
visible: true,
Expand Down Expand Up @@ -1196,7 +1224,7 @@ suite('Sessions - ChatGroupsView', () => {
}

remoteConnectionStatus.set({ kind: 'reconnecting' }, undefined);
await timeout(1_000);
await timeout(6_000);

assert.deepStrictEqual({ connectCalls: provider.connectCalls, banner: readBanner(view) }, {
connectCalls: 1,
Expand Down
Loading