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: 21 additions & 2 deletions frontend/components/XamanLoginPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ function retryAfterMilliseconds(response: Response): number {
}

type LoginStartRetryReason = "rate-limited" | "temporarily-unavailable";
type EmbeddedLoginPreparationPhase = LoginStartRetryReason | "waking-up";

export async function startLoginWithRetry(
signal: AbortSignal,
Expand Down Expand Up @@ -319,6 +320,22 @@ export async function startLoginWithRetry(
throw new BackendRequestTimeoutError();
}

export async function prepareEmbeddedLogin(
signal: AbortSignal,
onProgress: (phase: EmbeddedLoginPreparationPhase) => void,
retryWindowMs = EMBEDDED_LOGIN_START_RETRY_WINDOW_MS
): Promise<LoginStartResponse> {
// A GET readiness probe reliably wakes a spun-down Render Free backend.
// Sending login/start as the first request can be rejected by Render's edge
// with 429 responses before the Python service has started.
if (signal.aborted) {
throw signal.reason ?? new Error("Login cancelled");
}
onProgress("waking-up");
await waitForBackendReady(BACKEND_BASE_URL, signal);
return startLoginWithRetry(signal, onProgress, retryWindowMs);
Comment thread
Copilot marked this conversation as resolved.
}

export function XamanLoginPanel() {
const [isLoading, setIsLoading] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false);
Expand Down Expand Up @@ -601,11 +618,13 @@ export function XamanLoginPanel() {
);

try {
const data = await startLoginWithRetry(
const data = await prepareEmbeddedLogin(
controller.signal,
(reason) => {
const message =
reason === "rate-limited"
reason === "waking-up"
? "Xaman is ready. Starting the secure CalorieApp service. This can take about a minute. Keep this page open."
: reason === "rate-limited"
? "Xaman is ready. CalorieApp is temporarily busy and will retry automatically. Keep this page open."
: "Xaman is ready. CalorieApp is still starting and will retry automatically. Keep this page open.";
setLoginStatus(message);
Expand Down
164 changes: 164 additions & 0 deletions tools/tests/xaman_login_start_retry.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,167 @@ test("login start retries transport errors and transient responses", async () =>
]);
assert.equal(result.state, "state-abcdefghijklmnopqrstuvwxyz-0123456789");
});

test("embedded login wakes the backend before creating login state", async () => {
const typescript = requireFromFrontend("typescript");
const source = await readFile(COMPONENT_PATH, "utf8");
const compiled = typescript.transpileModule(source, {
compilerOptions: {
jsx: typescript.JsxEmit.ReactJSX,
module: typescript.ModuleKind.CommonJS,
target: typescript.ScriptTarget.ES2022,
},
}).outputText;

const events = [];
const loginResponse = {
ok: true,
status: 200,
headers: { get: () => null },
json: async () => ({
state: "state-abcdefghijklmnopqrstuvwxyz-0123456789",
expires_at: "2099-01-01T00:00:00Z",
wordpress_signin_url: "https://calorietoken.net/?xl-signin=1",
browser_handoff_token: "token-abcdefghijklmnopqrstuvwxyz-0123456789",
}),
};

class TestBackendRequestTimeoutError extends Error {}
const module = { exports: {} };
const context = vm.createContext({
AbortController,
Error,
Math,
Number,
Promise,
URL,
clearImmediate,
console,
module,
exports: module.exports,
process: { env: {} },
require(specifier) {
if (specifier === "react") {
return {};
}
if (specifier === "react/jsx-runtime") {
return { Fragment: Symbol("Fragment"), jsx() {}, jsxs() {} };
}
if (specifier === "@/components/authEvents") {
return { announceAuthState() {} };
}
if (specifier === "@/lib/backendRequest") {
return {
backendRequest: async () => {
events.push("login-start");
return loginResponse;
},
backendUnavailableMessage: (_error, fallback) => fallback,
BackendRequestTimeoutError: TestBackendRequestTimeoutError,
waitForBackendReady: async () => {
events.push("backend-ready");
},
};
}
throw new Error(`Unexpected require: ${specifier}`);
},
setImmediate,
window: {
clearTimeout(timer) {
clearImmediate(timer);
},
setTimeout(callback) {
return setImmediate(callback);
},
},
});

vm.runInContext(compiled, context);
const phases = [];
const result = await module.exports.prepareEmbeddedLogin(
new AbortController().signal,
(phase) => phases.push(phase),
10_000
);

assert.deepEqual(events, ["backend-ready", "login-start"]);
assert.deepEqual(phases, ["waking-up"]);
assert.equal(result.state, "state-abcdefghijklmnopqrstuvwxyz-0123456789");
});

test("embedded login does not report progress after cancellation", async () => {
const typescript = requireFromFrontend("typescript");
const source = await readFile(COMPONENT_PATH, "utf8");
const compiled = typescript.transpileModule(source, {
compilerOptions: {
jsx: typescript.JsxEmit.ReactJSX,
module: typescript.ModuleKind.CommonJS,
target: typescript.ScriptTarget.ES2022,
},
}).outputText;

const events = [];
class TestBackendRequestTimeoutError extends Error {}
const module = { exports: {} };
const context = vm.createContext({
AbortController,
Error,
Math,
Number,
Promise,
URL,
clearImmediate,
console,
module,
exports: module.exports,
process: { env: {} },
require(specifier) {
if (specifier === "react") {
return {};
}
if (specifier === "react/jsx-runtime") {
return { Fragment: Symbol("Fragment"), jsx() {}, jsxs() {} };
}
if (specifier === "@/components/authEvents") {
return { announceAuthState() {} };
}
if (specifier === "@/lib/backendRequest") {
return {
backendRequest: async () => {
events.push("login-start");
},
backendUnavailableMessage: (_error, fallback) => fallback,
BackendRequestTimeoutError: TestBackendRequestTimeoutError,
waitForBackendReady: async () => {
events.push("backend-ready");
},
};
}
throw new Error(`Unexpected require: ${specifier}`);
},
setImmediate,
window: {
clearTimeout(timer) {
clearImmediate(timer);
},
setTimeout(callback) {
return setImmediate(callback);
},
},
});

vm.runInContext(compiled, context);
const controller = new AbortController();
const abortReason = new Error("cancelled before login");
controller.abort(abortReason);

await assert.rejects(
module.exports.prepareEmbeddedLogin(
controller.signal,
(phase) => events.push(phase),
10_000
),
abortReason
);
assert.deepEqual(events, []);
});