perf: try to improve app opens speed (#3462)

* perf: try to improve app opens speed

* make /rao non blocking to allow faster conn swap
This commit is contained in:
Daniel Salazar
2026-07-28 11:12:44 -07:00
committed by GitHub
parent 7a7ff47442
commit 69529ee0db
7 changed files with 477 additions and 91 deletions
+82 -41
View File
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import { isAccessTokenActor, isAppActor } from '../../core/actor.js';
@@ -31,15 +32,66 @@ import DEFAULT_APP_ICON from './default-app-icon.js';
/**
* REST endpoints for app management.
*
* Delegates to AppDriver for the actual CRUD + permission logic —
* these routes are just thin shape adapters that translate REST
* conventions into driver calls.
* Delegates to AppDriver for the actual CRUD + permission logic — these routes
* are just thin shape adapters that translate REST conventions into driver
* calls.
*/
export class AppController extends PuterController {
get appStore() {
return this.stores.app;
}
// In-flight background app-open writes. Tracked only so tests and
// shutdown can wait for them — the request path never does.
#pendingOpenWrites = new Set();
/**
* Record an app open. `app_opens` is analytics: it backs the recent-apps
* list and the open counters, and no response field is derived from it. The
* client posts this without awaiting and updates its own recent list
* optimistically, so holding a response open for a primary write only added
* latency to the launch that write is measuring.
*
* Failures are logged, never surfaced — a dropped stat must not turn into a
* failed app open.
*
* @param {string} appUid
* @param {number} userId
* @returns {Promise<void>} Settles when the write and event emit finish
*/
#recordAppOpen(appUid, userId) {
const ts = Math.floor(Date.now() / 1000);
const work = (async () => {
try {
await this.clients.db.write(
'INSERT INTO `app_opens` (`app_uid`, `user_id`, `ts`) VALUES (?, ?, ?)',
[appUid, userId, ts],
);
} catch (e) {
console.warn('[rao] insert failed:', e);
}
try {
this.clients.event?.emitAndWait(
'app.opened',
{ app_uid: appUid, user_id: userId, ts },
{},
);
} catch {
// event emission best-effort
}
})();
this.#pendingOpenWrites.add(work);
work.finally(() => this.#pendingOpenWrites.delete(work));
return work;
}
/** Await every in-flight app-open write. */
async drainPendingAppOpens() {
await Promise.allSettled([...this.#pendingOpenWrites]);
}
get appDriver() {
// Drivers are wired into the shared driversContainers export by
// PuterServer at boot. Controllers get them lazily via this getter
@@ -148,33 +200,10 @@ export class AppController extends PuterController {
legacyCode: 'not_found',
});
// Persist open record
try {
await this.clients.db.write(
'INSERT INTO `app_opens` (`app_uid`, `user_id`, `ts`) VALUES (?, ?, ?)',
[
app_uid,
req.actor.user.id,
Math.floor(Date.now() / 1000),
],
);
} catch (e) {
console.warn('[rao] insert failed:', e);
}
try {
this.clients.event?.emitAndWait(
'app.opened',
{
app_uid,
user_id: req.actor.user.id,
ts: Math.floor(Date.now() / 1000),
},
{},
);
} catch {
// event emission best-effort
}
// Validation and authorization are settled by this point, so
// the caller learns the outcome now and the stats write lands
// on its own. See `#recordAppOpen`.
this.#recordAppOpen(app_uid, req.actor.user.id);
res.json({});
},
@@ -437,7 +466,11 @@ export class AppController extends PuterController {
}
setIconSecurityHeaders(res);
res.set('Content-Type', mime);
res.set('Cache-Control', 'public, max-age=60');
// Same freshness as the redirect path above — this is the
// same resource, just served inline because no CDN file
// exists yet. A 60s TTL made every app launch re-fetch the
// icon over a connection the launch itself is competing for.
res.set('Cache-Control', 'public, max-age=900');
res.send(Buffer.from(icon.slice(commaIdx + 1), 'base64'));
// Trigger background generation so next request hits the CDN
@@ -468,6 +501,14 @@ export class AppController extends PuterController {
}
onServerStart() {}
onServerPrepareShutdown() {}
/**
* Let outstanding stats writes finish before the process goes away —
* otherwise a deploy silently drops every open recorded in the seconds
* before it.
*/
async onServerPrepareShutdown() {
await this.drainPendingAppOpens();
}
onServerShutdown() {}
}
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import type { Request, RequestHandler, Response } from 'express';
@@ -285,12 +286,7 @@ describe('AppController POST /rao', () => {
const { res } = makeRes();
await expect(
withActor(actor, () =>
callRoute(
'post',
'/rao',
makeReq({ body: {}, actor }),
res,
),
callRoute('post', '/rao', makeReq({ body: {}, actor }), res),
),
).rejects.toMatchObject({ statusCode: 400 });
});
@@ -328,6 +324,9 @@ describe('AppController POST /rao', () => {
);
expect(captured.body).toEqual({});
// The stats write is deliberately not awaited by the request.
await server.controllers.apps.drainPendingAppOpens();
const rows = (await server.clients.db.read(
'SELECT `app_uid`, `user_id` FROM `app_opens` WHERE `app_uid` = ? AND `user_id` = ?',
[app.uid, owner.userId],
@@ -335,6 +334,74 @@ describe('AppController POST /rao', () => {
expect(rows).toHaveLength(1);
});
// `app_opens` is analytics: the response carries nothing derived from it
// and the client never awaits the post. Holding the response open for a
// primary write put that write's latency inside the app launch it was
// measuring, on a connection the launch was competing for.
it('responds without waiting for the stats write', async () => {
const owner = await makeUser();
const app = await createApp(owner.actor);
let releaseWrite = () => {};
const writeSpy = vi
.spyOn(server.clients.db, 'write')
.mockImplementation(
() =>
new Promise((resolve) => {
releaseWrite = () => resolve(undefined as never);
}),
);
try {
const { res, captured } = makeRes();
await withActor(owner.actor, () =>
callRoute(
'post',
'/rao',
makeReq({ body: { app_uid: app.uid }, actor: owner.actor }),
res,
),
);
// The handler returned while the INSERT is still outstanding.
expect(captured.body).toEqual({});
expect(writeSpy).toHaveBeenCalled();
} finally {
releaseWrite();
await server.controllers.apps.drainPendingAppOpens();
writeSpy.mockRestore();
}
});
// A stats failure must never turn into a failed app open — and since the
// write is now unawaited, it must not surface as an unhandled rejection.
it('swallows a failing stats write without rejecting the request', async () => {
const owner = await makeUser();
const app = await createApp(owner.actor);
const writeSpy = vi
.spyOn(server.clients.db, 'write')
.mockRejectedValue(new Error('primary is down'));
try {
const { res, captured } = makeRes();
await withActor(owner.actor, () =>
callRoute(
'post',
'/rao',
makeReq({ body: { app_uid: app.uid }, actor: owner.actor }),
res,
),
);
expect(captured.body).toEqual({});
await expect(
server.controllers.apps.drainPendingAppOpens(),
).resolves.toBeUndefined();
} finally {
writeSpy.mockRestore();
}
});
it('falls back to actor.app.uid when the body omits app_uid', async () => {
const owner = await makeUser();
const app = await createApp(owner.actor);
@@ -354,6 +421,8 @@ describe('AppController POST /rao', () => {
);
expect(captured.body).toEqual({});
await server.controllers.apps.drainPendingAppOpens();
const rows = (await server.clients.db.read(
'SELECT `app_uid` FROM `app_opens` WHERE `app_uid` = ? AND `user_id` = ?',
[app.uid, owner.userId],
@@ -666,6 +735,25 @@ describe('AppController GET /app-icon/:app_uid', () => {
expect((captured.body as Buffer).equals(png)).toBe(true);
});
// Icons are fetched on every app launch, over a connection the launch is
// already competing for. The inline path must not be cached more briefly
// than the redirect path that serves the same resource.
it('caches an inline data-URL icon as long as the redirect path', async () => {
const owner = await makeUser();
const dataUrl = `data:image/png;base64,${Buffer.from('x').toString('base64')}`;
const app = await createApp(owner.actor, { icon: dataUrl });
const { res, captured } = makeRes();
await callRoute(
'get',
'/app-icon/:app_uid',
makeReq({ params: { app_uid: app.uid } }),
res,
);
expect(captured.headers['cache-control']).toBe('public, max-age=900');
});
it('falls back to the default icon when the data-URL MIME is not allowlisted', async () => {
const owner = await makeUser();
// text/html is NOT in the icon allowlist — must NOT be echoed back.
@@ -2068,6 +2068,59 @@ describe('AuthController.handleGetUserAppToken + handleCheckApp', () => {
expect(decoded.app_uid).toBe(app.uid);
});
// This handler sits in the app-launch critical path. The permission
// grant, the token mint, and the AppData mkdir are mutually independent,
// so they must overlap rather than run as three serial round trips —
// awaiting any one of them before starting the others silently triples
// the latency with every test still passing.
it('runs the permission grant, token mint and AppData mkdir concurrently', async () => {
const order: string[] = [];
const defer = <T,>(label: string, value: T) => {
order.push(`${label}:start`);
return new Promise<T>((resolve) =>
setTimeout(() => {
order.push(`${label}:end`);
resolve(value);
}, 20),
);
};
const permSpy = vi
.spyOn(server.services.permission, 'grantUserAppPermission')
.mockImplementation(() => defer('grant', undefined as never));
const tokenSpy = vi
.spyOn(server.services.auth, 'getUserAppToken')
.mockImplementation(() => defer('token', 'signed.jwt.value'));
const mkdirSpy = vi
.spyOn(server.services.fs, 'mkdir')
.mockImplementation(() => defer('mkdir', undefined as never));
try {
await inCtx(actor, () =>
controller.handleGetUserAppToken(
makeReq({ app_uid: app.uid }, { actor }),
makeRes(),
),
);
} finally {
permSpy.mockRestore();
tokenSpy.mockRestore();
mkdirSpy.mockRestore();
}
// All three must be in flight before any of them settles.
const firstEnd = order.findIndex((e) => e.endsWith(':end'));
const starts = order.slice(0, firstEnd);
expect(starts).toHaveLength(3);
expect(starts).toEqual(
expect.arrayContaining([
'grant:start',
'token:start',
'mkdir:start',
]),
);
});
it('after get-user-app-token, check-app reports authenticated:true and returns a token', async () => {
// Ensure the flag is granted (re-run is idempotent).
await inCtx(actor, () =>
@@ -3174,9 +3174,9 @@ export class AuthController extends PuterController {
legacyCode: 'not_found',
});
}
// Grant the app-is-authenticated flag
const userPermGrantPromise =
await this.services.permission.grantUserAppPermission(
this.services.permission.grantUserAppPermission(
req.actor!,
app_uid,
'flag:app-is-authenticated',
@@ -3184,7 +3184,7 @@ export class AuthController extends PuterController {
{},
);
const token = await this.services.auth.getUserAppToken(
const tokenPromise = this.services.auth.getUserAppToken(
req.actor!,
app_uid,
);
@@ -3211,7 +3211,11 @@ export class AuthController extends PuterController {
}
})();
await Promise.all([userPermGrantPromise, missingFSPathPromise]);
const [, token] = await Promise.all([
userPermGrantPromise,
tokenPromise,
missingFSPathPromise,
]);
try {
const a = app as {
+69 -26
View File
@@ -3,22 +3,28 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import UIWindowSaveAccount from '../UIWindowSaveAccount.js';
// How long a completed usage load stays fresh enough to skip a repeat. Long
// enough to absorb the init/onActivate/routing burst on a single dashboard
// open, short enough that a user watching the panel still sees movement.
const USAGE_REFRESH_DEBOUNCE_MS = 5_000;
function buildRecentAppsHTML() {
let h = '';
@@ -73,7 +79,8 @@ function buildUsageHTML() {
// Your Plan section
h +=
'<div class="bento-usage-section bento-usage-card bento-plan-section">';
h += '<a href="#" class="bento-usage-card-header bento-plan-header" data-target-tab="usage">';
h +=
'<a href="#" class="bento-usage-card-header bento-plan-header" data-target-tab="usage">';
h += `<h3>${i18n('your_plan')}</h3>`;
h += '<span class="bento-usage-card-arrow"></span>';
h += '</a>';
@@ -224,8 +231,10 @@ const TabHome = {
h += '</div>';
// Open Desktop card (spans full width, links to the desktop interface)
h += '<a href="/desktop" target="_blank" rel="noopener" class="bento-card bento-desktop allow-native-ctxmenu">';
h += '<div class="bento-card-fancy-icon bento-card-fancy-icon-desktop">';
h +=
'<a href="/desktop" target="_blank" rel="noopener" class="bento-card bento-desktop allow-native-ctxmenu">';
h +=
'<div class="bento-card-fancy-icon bento-card-fancy-icon-desktop">';
h +=
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>';
h += '</div>';
@@ -253,7 +262,10 @@ const TabHome = {
// 2. Visibility / focus — user returning to puter (e.g. from
// Stripe Customer Portal in another tab). Portal mutations
// bypass our dispatch, so we re-pull state + broadcast.
const refresh = () => this.loadUsageData($el_window);
// Deliberate refreshes (subscription changed, returning from the
// billing portal) must bypass the freshness window — they exist to
// replace state we already know is stale.
const refresh = () => this.loadUsageData($el_window, { force: true });
const refreshAndBroadcast = async () => {
// Pull fresh whoami, then broadcast. The dispatched event is handled
// by the `refresh` listener below, so we don't call refresh() here —
@@ -264,7 +276,7 @@ const TabHome = {
try {
await Promise.race([
window.refresh_user_data?.(puter.authToken),
new Promise(resolve => setTimeout(resolve, 8000)),
new Promise((resolve) => setTimeout(resolve, 8000)),
]);
} catch {}
try {
@@ -286,7 +298,8 @@ const TabHome = {
refreshAndBroadcast();
};
const onVisibility = () => {
if (document.visibilityState === 'visible') scheduleRefreshAndBroadcast();
if (document.visibilityState === 'visible')
scheduleRefreshAndBroadcast();
};
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('focus', scheduleRefreshAndBroadcast);
@@ -366,7 +379,36 @@ const TabHome = {
.html(buildRecentAppsHTML());
},
async loadUsageData($el_window) {
// `init()` runs for every tab and `onActivate()` fires again for the
// active one (and again on back/forward routing), so a single dashboard
// open lands here several times in quick succession — each pass costing
// three API calls that compete for connections with whatever app is
// launching. Concurrent callers share one in-flight load, and repeats
// arriving while the data is still fresh are dropped.
//
// `force` bypasses the freshness window for the callers that exist
// precisely to pull new state (subscription change, returning from the
// billing portal) — those must never be served a cached decision.
async loadUsageData($el_window, { force = false } = {}) {
if (this._usageLoadInFlight) return this._usageLoadInFlight;
if (
!force &&
this._usageLoadedAt &&
Date.now() - this._usageLoadedAt < USAGE_REFRESH_DEBOUNCE_MS
) {
return;
}
this._usageLoadInFlight = this._loadUsageDataUncached($el_window);
try {
return await this._usageLoadInFlight;
} finally {
this._usageLoadInFlight = null;
this._usageLoadedAt = Date.now();
}
},
async _loadUsageDataUncached($el_window) {
// Load plan data — fetch live from /marketplace/subscriptions/current
// rather than reading `window.user.subscription` (which is set once
// from whoami at page-load and goes stale after subscribe / portal
@@ -471,7 +513,9 @@ const TabHome = {
try {
const res = await puter.fs.space();
// Guard capacity 0 — 0/0 would render literally as "NaN%".
let usage_percentage = res.capacity ? ((res.used / res.capacity) * 100).toFixed(0) : '0';
let usage_percentage = res.capacity
? ((res.used / res.capacity) * 100).toFixed(0)
: '0';
usage_percentage = usage_percentage > 100 ? 100 : usage_percentage;
let general_used = res.used;
@@ -499,7 +543,8 @@ const TabHome = {
// Load monthly usage data
try {
const res = await puter.auth.getMonthlyUsage();
const monthlyAllowance = res.allowanceInfo?.monthUsageAllowance || 0;
const monthlyAllowance =
res.allowanceInfo?.monthUsageAllowance || 0;
// Actual month-to-date spend.
const totalUsage = res.usage?.total ?? 0;
// Purchased credits extend the monthly allowance. `remaining` is the
@@ -530,14 +575,12 @@ const TabHome = {
.text(
`${window.number_format(totalUsage / 100_000_000, { decimals: 2, prefix: '$' })} Used`,
);
$el_window
.find('.bento-resources-capacity')
.text(
window.number_format(capacity / 100_000_000, {
decimals: 2,
prefix: '$',
}),
);
$el_window.find('.bento-resources-capacity').text(
window.number_format(capacity / 100_000_000, {
decimals: 2,
prefix: '$',
}),
);
$el_window
.find('.bento-resources-percent')
.text(`${displayPercentage}%`);
+39 -5
View File
@@ -1,6 +1,16 @@
import * as utils from '../../lib/utils.js';
import { dedupe } from '../../lib/networkUtils.js';
import { parseCallbackOptions } from './lib/args.js';
// How long a caller may attach to an already-pending `/whoami`. This is
// in-flight coalescing, not a cache — the entry is dropped the moment the
// request settles, so a later caller always gets a fresh read and can never
// be served a stale user. The window only bounds how long we'll wait on a
// request that hasn't come back; past it, a new caller issues its own rather
// than inheriting a possibly-hung one. Kept just above observed `/whoami`
// latency so it catches the boot burst and nothing else.
const WHOAMI_DEDUPE_WINDOW_MS = 1000;
/** @typedef {import('../../../types/modules/auth').User} User */
/** @typedef {import('../../../types/shared').RequestCallbacks<User>} UserCallbacks */
@@ -34,9 +44,33 @@ export function user (...args) {
query = `?${new URLSearchParams(options.query).toString()}`;
}
return new Promise((resolve, reject) => {
const xhr = utils.initXhr(`/whoami${query}`, puter.APIOrigin, puter.authToken, 'get');
utils.setupXhrEventHandlers(xhr, options.success, options.error, resolve, reject);
xhr.send();
});
// The GUI reaches /whoami from several independent boot paths (session
// restore, post-login, popup auth, desktop refresh), landing identical
// requests in the same moment. Coalesce those onto one request; the key
// carries origin, token and query so a different user or response shape
// never shares a result.
const key = `os:user:${puter.APIOrigin}:${puter.authToken}:${query}`;
const request = dedupe(
key,
() => new Promise((resolve, reject) => {
const xhr = utils.initXhr(`/whoami${query}`, puter.APIOrigin, puter.authToken, 'get');
// Callbacks are deliberately not passed here: this promise is
// shared, so per-caller callbacks are invoked below instead —
// otherwise a coalesced caller's `success` would never fire.
utils.setupXhrEventHandlers(xhr, undefined, undefined, resolve, reject);
xhr.send();
}),
{ windowMs: WHOAMI_DEDUPE_WINDOW_MS },
);
return request.then(
value => {
options.success?.(value);
return value;
},
error => {
options.error?.(error);
throw error;
},
);
}
+123
View File
@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Every XHR the module opens lands here so tests can settle them by hand.
const { pending } = vi.hoisted(() => ({ pending: [] }));
vi.mock('../../lib/utils.js', () => ({
initXhr: (path, origin, token, method) => ({
path,
origin,
token,
method,
send () {
pending.push(this);
},
}),
setupXhrEventHandlers: (xhr, success, error, resolve, reject) => {
xhr.settle = resolve;
xhr.fail = reject;
},
}));
const { user } = await import('./user.js');
const ctx = () => ({
puter: { APIOrigin: 'https://api.test', authToken: 'tok-1' },
});
beforeEach(() => {
pending.length = 0;
});
describe('os.user /whoami dedup', () => {
it('coalesces concurrent callers onto a single request', async () => {
const first = user.call(ctx(), {});
const second = user.call(ctx(), {});
expect(pending).toHaveLength(1);
pending[0].settle({ username: 'alice' });
await expect(first).resolves.toEqual({ username: 'alice' });
await expect(second).resolves.toEqual({ username: 'alice' });
});
// The shared promise carries no per-caller callbacks, so `user` has to
// invoke each caller's own — otherwise a coalesced caller silently never
// hears back.
it('still fires every coalesced caller\'s success callback', async () => {
const firstSuccess = vi.fn();
const secondSuccess = vi.fn();
const first = user.call(ctx(), { success: firstSuccess });
const second = user.call(ctx(), { success: secondSuccess });
expect(pending).toHaveLength(1);
pending[0].settle({ username: 'alice' });
await Promise.all([first, second]);
expect(firstSuccess).toHaveBeenCalledWith({ username: 'alice' });
expect(secondSuccess).toHaveBeenCalledWith({ username: 'alice' });
});
it('rejects every coalesced caller and fires their error callbacks', async () => {
const firstError = vi.fn();
const secondError = vi.fn();
const boom = new Error('unauthorized');
const first = user.call(ctx(), { error: firstError });
const second = user.call(ctx(), { error: secondError });
expect(pending).toHaveLength(1);
pending[0].fail(boom);
await expect(first).rejects.toBe(boom);
await expect(second).rejects.toBe(boom);
expect(firstError).toHaveBeenCalledWith(boom);
expect(secondError).toHaveBeenCalledWith(boom);
});
// This is in-flight coalescing, not a cache. Once a read settles the next
// caller must hit the network again, or the GUI would render a user whose
// state changed underneath it.
it('issues a fresh request once the previous one has settled', async () => {
const first = user.call(ctx(), {});
pending[0].settle({ username: 'alice' });
await first;
const second = user.call(ctx(), {});
expect(pending).toHaveLength(2);
pending[1].settle({ username: 'alice' });
await second;
});
it('does not share a result across different query shapes', async () => {
const plain = user.call(ctx(), {});
const sized = user.call(ctx(), { query: { icon_size: '64' } });
expect(pending).toHaveLength(2);
expect(pending[0].path).toBe('/whoami');
expect(pending[1].path).toBe('/whoami?icon_size=64');
pending[0].settle({ username: 'alice' });
pending[1].settle({ username: 'alice', icon: 'x' });
await Promise.all([plain, sized]);
});
it('does not share a result across different auth tokens', async () => {
const asFirstUser = user.call(ctx(), {});
const asSecondUser = user.call(
{ puter: { APIOrigin: 'https://api.test', authToken: 'tok-2' } },
{},
);
expect(pending).toHaveLength(2);
pending[0].settle({ username: 'alice' });
pending[1].settle({ username: 'bob' });
await expect(asFirstUser).resolves.toEqual({ username: 'alice' });
await expect(asSecondUser).resolves.toEqual({ username: 'bob' });
});
});