mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-23 22:47:19 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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}%`);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user