Skip to content

feat: establish terminal reconnection foundation #18693

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions site/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from "./useClickable";
export * from "./useClickableTableRow";
export * from "./useClipboard";
export * from "./usePagination";
export * from "./useWithRetry";
250 changes: 250 additions & 0 deletions site/src/hooks/useWithRetry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
import { act, renderHook } from "@testing-library/react";
import { useWithRetry } from "./useWithRetry";

// Mock timers
jest.useFakeTimers();

describe("useWithRetry", () => {
let mockFn: jest.Mock;

beforeEach(() => {
mockFn = jest.fn();
jest.clearAllTimers();
});

afterEach(() => {
jest.clearAllMocks();
});

it("should initialize with correct default state", () => {
const { result } = renderHook(() => useWithRetry(mockFn));

expect(result.current.isLoading).toBe(false);
expect(result.current.retryAt).toBe(undefined);
});

it("should execute function successfully on first attempt", async () => {
mockFn.mockResolvedValue(undefined);

const { result } = renderHook(() => useWithRetry(mockFn));

await act(async () => {
await result.current.call();
});

expect(mockFn).toHaveBeenCalledTimes(1);
expect(result.current.isLoading).toBe(false);
expect(result.current.retryAt).toBe(undefined);
});

it("should set isLoading to true during execution", async () => {
let resolvePromise: () => void;
const promise = new Promise<void>((resolve) => {
resolvePromise = resolve;
});
mockFn.mockReturnValue(promise);

const { result } = renderHook(() => useWithRetry(mockFn));

act(() => {
result.current.call();
});

expect(result.current.isLoading).toBe(true);

await act(async () => {
resolvePromise!();
await promise;
});

expect(result.current.isLoading).toBe(false);
});

it("should retry on failure with exponential backoff", async () => {
mockFn
.mockRejectedValueOnce(new Error("First failure"))
.mockRejectedValueOnce(new Error("Second failure"))
.mockResolvedValueOnce(undefined);

const { result } = renderHook(() => useWithRetry(mockFn));

// Start the call
await act(async () => {
await result.current.call();
});

expect(mockFn).toHaveBeenCalledTimes(1);
expect(result.current.isLoading).toBe(false);
expect(result.current.retryAt).not.toBe(null);

// Fast-forward to first retry (1 second)
await act(async () => {
jest.advanceTimersByTime(1000);
});

expect(mockFn).toHaveBeenCalledTimes(2);
expect(result.current.isLoading).toBe(false);
expect(result.current.retryAt).not.toBe(null);

// Fast-forward to second retry (2 seconds)
await act(async () => {
jest.advanceTimersByTime(2000);
});

expect(mockFn).toHaveBeenCalledTimes(3);
expect(result.current.isLoading).toBe(false);
expect(result.current.retryAt).toBe(undefined);
});

it("should stop retrying after max attempts (10)", async () => {
mockFn.mockRejectedValue(new Error("Always fails"));

const { result } = renderHook(() => useWithRetry(mockFn));

// Start the call
await act(async () => {
await result.current.call();
});

expect(mockFn).toHaveBeenCalledTimes(1);
expect(result.current.isLoading).toBe(false);
expect(result.current.retryAt).not.toBe(null);

// Fast-forward through all retries
for (let i = 1; i < 10; i++) {
const delay = Math.min(1000 * 2 ** (i - 1), 600000); // exponential backoff with max delay
await act(async () => {
jest.advanceTimersByTime(delay);
});
expect(mockFn).toHaveBeenCalledTimes(i + 1);
}

// After 10 attempts, should stop retrying
expect(result.current.isLoading).toBe(false);
expect(result.current.retryAt).toBe(undefined);
});

it("should respect max delay of 10 minutes", async () => {
mockFn.mockRejectedValue(new Error("Always fails"));

const { result } = renderHook(() => useWithRetry(mockFn));

// Start the call
await act(async () => {
await result.current.call();
});

expect(result.current.isLoading).toBe(false);

// Fast-forward through several retries to reach max delay
// After attempt 9, delay would be 1000 * 2^9 = 512000ms, which is less than 600000ms (10 min)
// After attempt 10, delay would be 1000 * 2^10 = 1024000ms, which should be capped at 600000ms

// Skip to attempt 9 (delay calculation: 1000 * 2^8 = 256000ms)
for (let i = 1; i < 9; i++) {
const delay = 1000 * 2 ** (i - 1);
await act(async () => {
jest.advanceTimersByTime(delay);
});
}

expect(mockFn).toHaveBeenCalledTimes(9);
expect(result.current.retryAt).not.toBe(null);

// The 9th retry should use max delay (600000ms = 10 minutes)
await act(async () => {
jest.advanceTimersByTime(600000);
});

expect(mockFn).toHaveBeenCalledTimes(10);
expect(result.current.isLoading).toBe(false);
expect(result.current.retryAt).toBe(undefined);
});

it("should cancel previous retry when call is invoked again", async () => {
mockFn
.mockRejectedValueOnce(new Error("First failure"))
.mockResolvedValueOnce(undefined);

const { result } = renderHook(() => useWithRetry(mockFn));

// Start the first call
await act(async () => {
await result.current.call();
});

expect(mockFn).toHaveBeenCalledTimes(1);
expect(result.current.isLoading).toBe(false);
expect(result.current.retryAt).not.toBe(null);

// Call again before retry happens
await act(async () => {
await result.current.call();
});

expect(mockFn).toHaveBeenCalledTimes(2);
expect(result.current.isLoading).toBe(false);
expect(result.current.retryAt).toBe(undefined);

// Advance time to ensure previous retry was cancelled
await act(async () => {
jest.advanceTimersByTime(5000);
});

expect(mockFn).toHaveBeenCalledTimes(2); // Should not have been called again
});

it("should set retryAt when scheduling retry", async () => {
mockFn
.mockRejectedValueOnce(new Error("Failure"))
.mockResolvedValueOnce(undefined);

const { result } = renderHook(() => useWithRetry(mockFn));

// Start the call
await act(async () => {
await result.current.call();
});

const retryAt = result.current.retryAt;
expect(retryAt).not.toBe(null);
expect(retryAt).toBeInstanceOf(Date);

// retryAt should be approximately 1 second in the future
const expectedTime = Date.now() + 1000;
const actualTime = retryAt!.getTime();
expect(Math.abs(actualTime - expectedTime)).toBeLessThan(100); // Allow 100ms tolerance

// Advance past retry time
await act(async () => {
jest.advanceTimersByTime(1000);
});

expect(result.current.retryAt).toBe(undefined);
});

it("should cleanup timer on unmount", async () => {
mockFn.mockRejectedValue(new Error("Failure"));

const { result, unmount } = renderHook(() => useWithRetry(mockFn));

// Start the call to create timer
await act(async () => {
await result.current.call();
});

expect(result.current.isLoading).toBe(false);
expect(result.current.retryAt).not.toBe(null);

// Unmount should cleanup timer
unmount();

// Advance time to ensure timer was cleared
await act(async () => {
jest.advanceTimersByTime(5000);
});

// Function should not have been called again
expect(mockFn).toHaveBeenCalledTimes(1);
});
});
94 changes: 94 additions & 0 deletions site/src/hooks/useWithRetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useCallback, useEffect, useRef, useState } from "react";

const MAX_ATTEMPTS = 10;
const DELAY_MS = 1_000;
const MAX_DELAY_MS = 600_000; // 10 minutes
// Determines how much the delay between retry attempts increases after each
// failure.
const MULTIPLIER = 2;

interface UseWithRetryResult {
call: () => Promise<void>;
retryAt: Date | undefined;
isLoading: boolean;
}

interface RetryState {
isLoading: boolean;
retryAt: Date | undefined;
attemptCount: number;
}

/**
* Hook that wraps a function with automatic retry functionality
* Provides a simple interface for executing functions with exponential backoff retry
*/
export function useWithRetry(fn: () => Promise<void>): UseWithRetryResult {
const [state, setState] = useState<RetryState>({
isLoading: false,
retryAt: undefined,
attemptCount: 0,
});

const timeoutRef = useRef<number | null>(null);

const clearTimeout = useCallback(() => {
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);

const call = useCallback(async () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wait one more thing, just want to check on the async intent here. Is the idea that call should return a promise that resolves once fn completes successfully? Right now it resolves after the first run of fn (whether it succeeds or fails). Did you have a use in mind for this?

clearTimeout();

const executeAttempt = async (attempt: number): Promise<void> => {
setState((prev) => ({ ...prev, isLoading: true, attemptCount: attempt }));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it be weird for retryAt to still be set since it will be a time in the past now?

Also I wonder if we explicitly do retryAt: prev.retryAt, I feel like the spread makes it too easy to forget to set some state (like in the previous version).

Oh also if the user manually calls this then the retryAt will be wrong for a while (until fn() finishes).


try {
await fn();
setState({ isLoading: false, retryAt: undefined, attemptCount: 0 });
} catch (error) {
// Since attempts start from 0, we need to add +1 to make the condition work
// This ensures exactly MAX_ATTEMPTS total attempts (attempt 0, 1, 2, ..., 9)
if (attempt + 1 < MAX_ATTEMPTS) {
const delayMs = Math.min(
DELAY_MS * MULTIPLIER ** attempt,
MAX_DELAY_MS,
);

setState((prev) => ({
...prev,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we explicitly attemptCount: prev.attemptCount to avoid accidentally forgetting props that need to be set?

isLoading: false,
retryAt: new Date(Date.now() + delayMs),
}));

timeoutRef.current = window.setTimeout(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this code still run after an unmount, if fn was in flight while unmounting? I think it would, right? So we might start a new timeout even though the component is unmounted?

setState((prev) => ({ ...prev, retryAt: undefined }));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see you unset retryAt here. Should we do it in executeAttempt() to also cover manually calling?

executeAttempt(attempt + 1);
}, delayMs);
} else {
setState((prev) => ({
...prev,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

isLoading: false,
retryAt: undefined,
}));
}
}
};

await executeAttempt(0);
}, [fn, clearTimeout]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use that useEffectEvent thing or is the expectation that the user memoizes the function they pass in?


useEffect(() => {
return () => {
clearTimeout();
};
}, [clearTimeout]);

return {
call,
retryAt: state.retryAt,
isLoading: state.isLoading,
};
}
Loading