diff --git a/src/backend/controllers/apps/AppController.js b/src/backend/controllers/apps/AppController.js index 71b563c36..fa52d646e 100644 --- a/src/backend/controllers/apps/AppController.js +++ b/src/backend/controllers/apps/AppController.js @@ -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 . + * 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} 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() {} } diff --git a/src/backend/controllers/apps/AppController.test.ts b/src/backend/controllers/apps/AppController.test.ts index 1ad7c95c8..44e872b1c 100644 --- a/src/backend/controllers/apps/AppController.test.ts +++ b/src/backend/controllers/apps/AppController.test.ts @@ -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 . + * 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. diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index e01ae5a04..91e502211 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -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 = (label: string, value: T) => { + order.push(`${label}:start`); + return new Promise((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, () => diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index ada4fdf4c..6a9cdaa25 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -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 { diff --git a/src/gui/src/UI/Dashboard/TabHome.js b/src/gui/src/UI/Dashboard/TabHome.js index 7ce71a1a1..81f1c48bc 100644 --- a/src/gui/src/UI/Dashboard/TabHome.js +++ b/src/gui/src/UI/Dashboard/TabHome.js @@ -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 . + * 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 += '
'; - h += ''; + h += + ''; h += `

${i18n('your_plan')}

`; h += ''; h += '
'; @@ -224,8 +231,10 @@ const TabHome = { h += '
'; // Open Desktop card (spans full width, links to the desktop interface) - h += ''; - h += '
'; + h += + ''; + h += + '
'; h += ''; h += '
'; @@ -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}%`); diff --git a/src/puter-js/src/modules/os/user.js b/src/puter-js/src/modules/os/user.js index 31bd81317..4d1bfd4ed 100644 --- a/src/puter-js/src/modules/os/user.js +++ b/src/puter-js/src/modules/os/user.js @@ -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} 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; + }, + ); } diff --git a/src/puter-js/src/modules/os/user.test.js b/src/puter-js/src/modules/os/user.test.js new file mode 100644 index 000000000..cb016d3e7 --- /dev/null +++ b/src/puter-js/src/modules/os/user.test.js @@ -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' }); + }); +});