Fix the session id never reaching the host, and the split lost on restart

Two real defects, both found by testing rather than reading:

1. The client sent sessionId in the POST body while the route read it from
   the query string, so the host always saw an empty id and answered
   total:null -- the pill was correct to stay silent. The client now sends
   the id in both places and the route reads both.
2. The ledger stored only a session total, so a restart kept the sum but
   dropped the per-purpose split and the call count (9.99 total against
   6.24 in parts, seen on the live route). Sessions are now stored as
   objects; a bare number from an older ledger is still accepted.

Also made the client report why it cannot read a total: swallowing the
error made 'nothing spent yet' indistinguishable from 'request failed'.
This commit is contained in:
2026-10-15 15:40:08 +03:00
parent d2a9b5a0f5
commit 6edd2e3d52
2 changed files with 94 additions and 18 deletions
+45 -12
View File
@@ -148,7 +148,7 @@ function cancelTimeout(handle) {
}
/** One JSON request to the host half. */
function rpc(method, args) {
function rpc(method, args, query) {
let timedOut = false;
const controller = new AbortController();
const timer = scheduleTimeout(() => {
@@ -156,7 +156,10 @@ function rpc(method, args) {
controller.abort();
}, RPC_TIMEOUT_MS);
const done = () => cancelTimeout(timer);
return fetch(RPC_BASE + '/' + method, {
const suffix = query && Object.keys(query).length > 0
? '?' + new URLSearchParams(query).toString()
: '';
return fetch(RPC_BASE + '/' + method + suffix, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(args ?? {}),
@@ -223,16 +226,36 @@ function installStyles() {
*/
function useSessionId(props) {
return React.useMemo(() => {
// Why the fallback chain exists: the host route answers `total: null` for an
// id it does not know, and the pill then stays silent -- so a wrong id here
// looks exactly like "nothing spent yet" unless it is reported.
const seen = [];
const record = (source, value) => seen.push(source + '=' + JSON.stringify(value ?? null));
let resolved = '';
try {
if (typeof props.sessionId === 'string' && props.sessionId.length > 0) return props.sessionId;
if (props.session && typeof props.session.sessionId === 'string') return props.session.sessionId;
const sessions = runtimeCtx && runtimeCtx.get ? runtimeCtx.get('sessions') : null;
const current = sessions && sessions.list ? sessions.list.getSnapshot().current : null;
if (typeof current === 'string' && current.length > 0) return current;
record('props.sessionId', props.sessionId);
record('props.session.sessionId', props.session && props.session.sessionId);
record('props keys', Object.keys(props).slice(0, 12));
if (typeof props.sessionId === 'string' && props.sessionId.length > 0) resolved = props.sessionId;
else if (props.session && typeof props.session.sessionId === 'string') resolved = props.session.sessionId;
else {
const sessions = runtimeCtx && runtimeCtx.get ? runtimeCtx.get('sessions') : null;
const current = sessions && sessions.list ? sessions.list.getSnapshot().current : null;
record('sessions.list.current', current);
if (typeof current === 'string' && current.length > 0) resolved = current;
}
} catch (err) {
/* fall through to the empty id, which the host answers with null */
record('error', String((err && err.message) || err));
}
return '';
seen.push('resolved=' + JSON.stringify(resolved));
// Report once per distinct outcome so the console stays readable.
const summary = seen.join(' | ');
if (globalThis.__dshSessionCostId !== summary) {
globalThis.__dshSessionCostId = summary;
console.info('[dsh-session-cost] session id: ' + summary);
}
return resolved;
}, [props.sessionId, props.session]);
}
@@ -244,14 +267,24 @@ function useSessionCost(sessionId) {
let timer = null;
const load = () => {
rpc('session', { sessionId })
// The id goes in the query string as well as the body: the route reads
// either, so this keeps working against a host that predates the fix.
rpc('session', { sessionId }, { sessionId })
.then((data) => {
if (cancelled) return;
setState({ total: data && typeof data.total === 'number' ? data.total : null, loading: false, error: null });
})
.catch(() => {
.catch((err) => {
if (cancelled) return;
setState((prev) => ({ total: prev.total, loading: false, error: 'unavailable' }));
// Say why, once per distinct failure, instead of silently keeping the
// pill empty: "no spend yet" and "cannot reach the host" are
// indistinguishable otherwise, and only one of them is normal.
const why = String((err && err.message) || err);
if (globalThis.__dshSessionCostError !== why) {
globalThis.__dshSessionCostError = why;
console.warn('[dsh-session-cost] cannot read ' + RPC_BASE + '/session: ' + why);
}
setState((prev) => ({ total: prev.total, loading: false, error: why }));
})
.finally(() => {
if (cancelled) return;