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:
+45
-12
@@ -148,7 +148,7 @@ function cancelTimeout(handle) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** One JSON request to the host half. */
|
/** One JSON request to the host half. */
|
||||||
function rpc(method, args) {
|
function rpc(method, args, query) {
|
||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = scheduleTimeout(() => {
|
const timer = scheduleTimeout(() => {
|
||||||
@@ -156,7 +156,10 @@ function rpc(method, args) {
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
}, RPC_TIMEOUT_MS);
|
}, RPC_TIMEOUT_MS);
|
||||||
const done = () => cancelTimeout(timer);
|
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',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify(args ?? {}),
|
body: JSON.stringify(args ?? {}),
|
||||||
@@ -223,16 +226,36 @@ function installStyles() {
|
|||||||
*/
|
*/
|
||||||
function useSessionId(props) {
|
function useSessionId(props) {
|
||||||
return React.useMemo(() => {
|
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 {
|
try {
|
||||||
if (typeof props.sessionId === 'string' && props.sessionId.length > 0) return props.sessionId;
|
record('props.sessionId', props.sessionId);
|
||||||
if (props.session && typeof props.session.sessionId === 'string') return props.session.sessionId;
|
record('props.session.sessionId', props.session && props.session.sessionId);
|
||||||
const sessions = runtimeCtx && runtimeCtx.get ? runtimeCtx.get('sessions') : null;
|
record('props keys', Object.keys(props).slice(0, 12));
|
||||||
const current = sessions && sessions.list ? sessions.list.getSnapshot().current : null;
|
|
||||||
if (typeof current === 'string' && current.length > 0) return current;
|
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) {
|
} 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]);
|
}, [props.sessionId, props.session]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,14 +267,24 @@ function useSessionCost(sessionId) {
|
|||||||
let timer = null;
|
let timer = null;
|
||||||
|
|
||||||
const load = () => {
|
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) => {
|
.then((data) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setState({ total: data && typeof data.total === 'number' ? data.total : null, loading: false, error: null });
|
setState({ total: data && typeof data.total === 'number' ? data.total : null, loading: false, error: null });
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((err) => {
|
||||||
if (cancelled) return;
|
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(() => {
|
.finally(() => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|||||||
+49
-6
@@ -149,11 +149,17 @@ export default {
|
|||||||
for (const [id, cost] of Object.entries(parsed.costs ?? {})) {
|
for (const [id, cost] of Object.entries(parsed.costs ?? {})) {
|
||||||
costByGeneration.set(id, cost);
|
costByGeneration.set(id, cost);
|
||||||
}
|
}
|
||||||
for (const [id, total] of Object.entries(parsed.sessions ?? {})) {
|
// Sessions are stored as objects so the per-purpose split survives a
|
||||||
|
// restart; older ledgers wrote a bare number, which is still accepted.
|
||||||
|
for (const [id, value] of Object.entries(parsed.sessions ?? {})) {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
sessionTotals.set(id, { total: finiteAmount(value) ?? 0, calls: 0, byPurpose: {} });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
sessionTotals.set(id, {
|
sessionTotals.set(id, {
|
||||||
total: finiteAmount(total) ?? 0,
|
total: finiteAmount(value?.total) ?? 0,
|
||||||
calls: 0,
|
calls: typeof value?.calls === 'number' ? value.calls : 0,
|
||||||
byPurpose: {},
|
byPurpose: value?.byPurpose && typeof value.byPurpose === 'object' ? { ...value.byPurpose } : {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const id of parsed.settled ?? []) settled.add(id);
|
for (const id of parsed.settled ?? []) settled.add(id);
|
||||||
@@ -172,7 +178,10 @@ export default {
|
|||||||
version: 1,
|
version: 1,
|
||||||
costs: Object.fromEntries(costByGeneration),
|
costs: Object.fromEntries(costByGeneration),
|
||||||
sessions: Object.fromEntries(
|
sessions: Object.fromEntries(
|
||||||
[...sessionTotals].map(([id, value]) => [id, value.total]),
|
[...sessionTotals].map(([id, value]) => [
|
||||||
|
id,
|
||||||
|
{ total: value.total, calls: value.calls, byPurpose: value.byPurpose },
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
settled: [...settled],
|
settled: [...settled],
|
||||||
};
|
};
|
||||||
@@ -417,6 +426,36 @@ export default {
|
|||||||
res.end(body);
|
res.end(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read a request body, capped: this route only ever carries a session id. */
|
||||||
|
function readJsonBody(req, limit = 16 * 1024) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const chunks = [];
|
||||||
|
let size = 0;
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > limit) {
|
||||||
|
resolve({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
||||||
|
if (raw.length === 0) {
|
||||||
|
resolve({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
resolve(parsed && typeof parsed === 'object' ? parsed : {});
|
||||||
|
} catch {
|
||||||
|
resolve({});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on('error', () => resolve({}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ctx.inject(['connection', 'webServer'], (webCtx) => {
|
ctx.inject(['connection', 'webServer'], (webCtx) => {
|
||||||
webCtx.effect(() => {
|
webCtx.effect(() => {
|
||||||
const dispose = webCtx.webServer.register({
|
const dispose = webCtx.webServer.register({
|
||||||
@@ -435,7 +474,11 @@ export default {
|
|||||||
respond(res, 404, { error: 'unknown method' });
|
respond(res, 404, { error: 'unknown method' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sessionId = url.searchParams.get('sessionId') ?? '';
|
// The client posts a JSON body; the query string is kept as a
|
||||||
|
// fallback so either shape works.
|
||||||
|
const body = req.method === 'POST' || req.method === 'PUT' ? await readJsonBody(req) : {};
|
||||||
|
const raw = body.sessionId ?? url.searchParams.get('sessionId') ?? '';
|
||||||
|
const sessionId = typeof raw === 'string' ? raw : String(raw ?? '');
|
||||||
respond(res, 200, tail === '/session' ? snapshot(sessionId) : snapshot(''));
|
respond(res, 200, tail === '/session' ? snapshot(sessionId) : snapshot(''));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
respond(res, 500, { error: String(error?.message ?? error) });
|
respond(res, 500, { error: String(error?.message ?? error) });
|
||||||
|
|||||||
Reference in New Issue
Block a user