actual-mcp-server 0.16.2 → 0.16.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -774,7 +774,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
774
774
 
775
775
  ---
776
776
 
777
- **Version:** 0.16.2 | **Tool Count:** 74 (verified LibreChat-compatible)
777
+ **Version:** 0.16.5 | **Tool Count:** 74 (verified LibreChat-compatible)
778
778
 
779
779
  ## Sponsor
780
780
 
@@ -2,6 +2,9 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
4
  import api from '@actual-app/api';
5
+ import { loadBudgetTracked } from './lib/budgetLoader.js';
6
+ import { withApiLock } from './lib/apiLock.js';
7
+ import { withOpTimeout } from './lib/opTimeout.js';
5
8
  import logger from './logger.js';
6
9
  import config from './config.js';
7
10
  import { connectionPool } from './lib/ActualConnectionPool.js';
@@ -24,6 +27,11 @@ export async function connectToActual() {
24
27
  return;
25
28
  }
26
29
  initializing = true;
30
+ // #396: reset before each attempt. It was set on failure and NEVER cleared, so once the
31
+ // silent-success bug is fixed and a bad load throws routinely, a single failure would poison
32
+ // every later session open for the process lifetime: a concurrent caller arriving while
33
+ // `initializing` is true rethrows whatever was left here.
34
+ initializationError = null;
27
35
  try {
28
36
  const SERVER_URL = config.ACTUAL_SERVER_URL;
29
37
  const PASSWORD = config.ACTUAL_PASSWORD;
@@ -36,20 +44,35 @@ export async function connectToActual() {
36
44
  if (!fs.existsSync(DATA_DIR))
37
45
  fs.mkdirSync(DATA_DIR, { recursive: true });
38
46
  log.info(`Initializing Actual API with dataDir=${DATA_DIR}`);
39
- await api.init({
40
- dataDir: DATA_DIR,
41
- serverURL: SERVER_URL,
42
- password: PASSWORD,
47
+ // #396: this is the SIXTH budget load site, and it is production reachable. Revision 0 of
48
+ // that ticket claimed it was `--test-actual-connection` only; it is not.
49
+ // `connectToActualForSession` below falls through to this function whenever
50
+ // USE_CONNECTION_POOL=false (a documented, supported variable), and httpServer calls it on
51
+ // every HTTP session open. It used to hand-write the download and record the loaded syncId
52
+ // UNCONDITIONALLY, so it had none of the guarantees the other five sites have.
53
+ //
54
+ // The lock is acquired HERE, around init plus load only, mirroring
55
+ // ActualConnectionPool.getConnection. Never around the `while (initializing)` spin-wait
56
+ // above: that would hold the mutex while waiting for its holder and deadlock. No caller of
57
+ // this function holds the api lock, so this acquisition is safe.
58
+ //
59
+ // This is NOT behaviour neutral, and that is intended: the download becomes bounded by
60
+ // ACTUAL_OP_TIMEOUT_MS with abandon-not-cancel semantics, and becomes a registered load that
61
+ // later lock acquisitions wait for. Both match what the pool sites already do.
62
+ await withApiLock(async () => {
63
+ // #396 review: BOUND the init. It was unbounded here before, which was survivable only
64
+ // because this call held no lock. Now that it does, an unbounded stalled login would never
65
+ // settle, `.finally(release)` would never run, and the process-global api mutex would be
66
+ // wedged permanently for every session: the exact #270 class. Both pool sites already bound
67
+ // their `api.init` this way, and this was the only `api.init` in the repo that did not.
68
+ await withOpTimeout(() => api.init({
69
+ dataDir: DATA_DIR,
70
+ serverURL: SERVER_URL,
71
+ password: PASSWORD,
72
+ }), 'connectToActual init');
73
+ log.info(`Downloading budget with sync ID: ${BUDGET_SYNC_ID}`);
74
+ await loadBudgetTracked(BUDGET_SYNC_ID, BUDGET_PASSWORD);
43
75
  });
44
- log.info(`Downloading budget with sync ID: ${BUDGET_SYNC_ID}`);
45
- // According to official docs, downloadBudget accepts optional second parameter for E2E encryption
46
- if (BUDGET_PASSWORD) {
47
- const apiWithOptions = api;
48
- await apiWithOptions.downloadBudget(BUDGET_SYNC_ID, { password: BUDGET_PASSWORD });
49
- }
50
- else {
51
- await api.downloadBudget(BUDGET_SYNC_ID);
52
- }
53
76
  if (TEST_ACTUAL_CONNECTION) {
54
77
  logger.info('Test flag detected (--test-actual-connection) — closing Actual session.');
55
78
  // Prefer the documented shutdown method only
@@ -17,6 +17,8 @@ import path from 'path';
17
17
  import os from 'os';
18
18
  import fs from 'fs';
19
19
  import { isApiInitialized, setApiInitialized } from './apiState.js';
20
+ import { withApiLock } from './apiLock.js';
21
+ import { loadBudgetTracked } from './budgetLoader.js';
20
22
  import { withOpTimeout } from './opTimeout.js';
21
23
  const DEFAULT_DATA_DIR = path.resolve(os.homedir() || '.', '.actual');
22
24
  class ActualConnectionPool {
@@ -237,27 +239,40 @@ class ActualConnectionPool {
237
239
  fs.mkdirSync(DATA_DIR, { recursive: true });
238
240
  }
239
241
  try {
240
- // Bound the session-open init/download (#270): this is the HTTP exposure.
241
- // A stalled upstream here would otherwise hang session open unbounded and,
242
- // because the api singleton is process-global, wedge other sessions too.
243
- await withOpTimeout(() => api.init({
244
- dataDir: DATA_DIR,
245
- serverURL: SERVER_URL,
246
- password: PASSWORD,
247
- }), 'pool init');
248
- logger.info(`[ConnectionPool] Downloading budget for session: ${sessionId}`);
249
- if (BUDGET_PASSWORD) {
250
- const apiWithOptions = api;
251
- await withOpTimeout(() => apiWithOptions.downloadBudget(BUDGET_SYNC_ID, { password: BUDGET_PASSWORD }), 'pool downloadBudget');
252
- }
253
- else {
254
- await withOpTimeout(() => api.downloadBudget(BUDGET_SYNC_ID), 'pool downloadBudget');
255
- }
256
- // Mark the singleton as live only AFTER the budget is downloaded (#270):
257
- // setting it before downloadBudget left a poisoning window where the
258
- // adapter's pool-cooperation branch could reuse a connection whose budget
259
- // never loaded (a stalled/failed download would leak isApiInitialized=true).
260
- setApiInitialized(true);
242
+ // #390 round 2: hold the api mutex across init + download.
243
+ //
244
+ // Recording the loaded budget and checking it inside the lock only NARROWED the race
245
+ // while this mutator stayed outside it. Reproduced: a pooled read passed the
246
+ // precondition, parked at an await still holding the lock, a new session opened here on
247
+ // a different budget, and the first session's raw call then executed against it. The
248
+ // window is not small: it is the whole duration of an operation, and since #378 made the
249
+ // drain sequential, the whole duration of a batch, with one check at the start.
250
+ //
251
+ // Safe against the non-reentrant mutex: the two callers of getConnection
252
+ // (actualConnection's session-open helper and switchBudget's slow path) are both OUTSIDE
253
+ // any withApiLock block. switchBudget's own downloadBudget takes the lock separately and
254
+ // has already released it before it reaches here.
255
+ await withApiLock(async () => {
256
+ // Bound the session-open init/download (#270): this is the HTTP exposure.
257
+ // A stalled upstream here would otherwise hang session open unbounded and,
258
+ // because the api singleton is process-global, wedge other sessions too.
259
+ await withOpTimeout(() => api.init({
260
+ dataDir: DATA_DIR,
261
+ serverURL: SERVER_URL,
262
+ password: PASSWORD,
263
+ }), 'pool init');
264
+ logger.info(`[ConnectionPool] Downloading budget for session: ${sessionId}`);
265
+ // #390: the shared tracked loader, not a hand-written download. It clears the record
266
+ // BEFORE starting and keeps an abandoned load registered, so a session-open download that
267
+ // outruns ACTUAL_OP_TIMEOUT_MS cannot silently re-point the singleton later.
268
+ await loadBudgetTracked(BUDGET_SYNC_ID, BUDGET_PASSWORD, 'pool downloadBudget');
269
+ // Mark the singleton as live only AFTER the budget is downloaded (#270):
270
+ // setting it before downloadBudget left a poisoning window where the
271
+ // adapter's pool-cooperation branch could reuse a connection whose budget
272
+ // never loaded (a stalled/failed download would leak isApiInitialized=true).
273
+ setApiInitialized(true);
274
+ }); // #390: end of the api-mutex block. Everything after this is pool bookkeeping and
275
+ // touches no singleton state, so it does not need the lock.
261
276
  conn = {
262
277
  sessionId,
263
278
  initialized: true,
@@ -276,17 +291,29 @@ class ActualConnectionPool {
276
291
  catch (err) {
277
292
  logger.error(`[ConnectionPool] Failed to initialize connection for session ${sessionId}:`, err);
278
293
  // Clean up the failed connection attempt
279
- // Try to shutdown the API to leave it in a clean state for the next attempt
280
- try {
294
+ // Try to shutdown the API to leave it in a clean state for the next attempt.
295
+ //
296
+ // #390 round 3: UNDER THE MUTEX, like the init above it. Locking the mutator and leaving
297
+ // its compensating mutator unlocked closes nothing: reproduced as this cleanup's
298
+ // api.shutdown() firing while ANOTHER session's operation was in flight inside the lock,
299
+ // so that operation observed a torn-down api. The lock is re-acquired rather than held
300
+ // across the whole try, because the init block released it on the way out and this
301
+ // function is never called from inside a lock (both callers are outside one).
302
+ // #393 review: the lock ITSELF can reject now (it settles an abandoned budget load on
303
+ // acquisition), and this is a CLEANUP path. If that rejection escaped, `setApiInitialized(false)`
304
+ // and the connections.delete below would be skipped and the caller would receive
305
+ // "abandoned budget load timed out" instead of the real session-open failure. Cleanup
306
+ // must not be able to fail louder than the thing it is cleaning up after, so the whole
307
+ // acquisition is swallowed here, not just the shutdown call.
308
+ await withApiLock(async () => {
281
309
  const maybeApi = api;
282
310
  if (typeof maybeApi.shutdown === 'function') {
283
311
  await maybeApi.shutdown();
284
312
  logger.debug(`[ConnectionPool] Cleaned up failed connection for session: ${sessionId}`);
285
313
  }
286
- }
287
- catch (cleanupErr) {
314
+ }).catch((cleanupErr) => {
288
315
  logger.debug(`[ConnectionPool] Error during cleanup (ignoring): ${cleanupErr}`);
289
- }
316
+ });
290
317
  // Singleton is back to torn-down state regardless of cleanup outcome.
291
318
  setApiInitialized(false);
292
319
  // Ensure this session is not in the connections map
@@ -312,22 +339,23 @@ class ActualConnectionPool {
312
339
  fs.mkdirSync(DATA_DIR, { recursive: true });
313
340
  }
314
341
  try {
315
- // Bound the session-open init/download (#270), same as getConnection.
316
- await withOpTimeout(() => api.init({
317
- dataDir: DATA_DIR,
318
- serverURL: SERVER_URL,
319
- password: PASSWORD,
320
- }), 'pool init');
321
- if (BUDGET_PASSWORD) {
322
- const apiWithOptions = api;
323
- await withOpTimeout(() => apiWithOptions.downloadBudget(BUDGET_SYNC_ID, { password: BUDGET_PASSWORD }), 'pool downloadBudget');
324
- }
325
- else {
326
- await withOpTimeout(() => api.downloadBudget(BUDGET_SYNC_ID), 'pool downloadBudget');
327
- }
328
- // Mark live only after a successful download (#270): avoids the poisoning
329
- // window where a stalled/failed download would leak isApiInitialized=true.
330
- setApiInitialized(true);
342
+ // #390 round 2: same reasoning as getConnection above; this mutates the same
343
+ // process-global singleton, so it takes the same mutex.
344
+ await withApiLock(async () => {
345
+ // Bound the session-open init/download (#270), same as getConnection.
346
+ await withOpTimeout(() => api.init({
347
+ dataDir: DATA_DIR,
348
+ serverURL: SERVER_URL,
349
+ password: PASSWORD,
350
+ }), 'pool init');
351
+ // #390: the shared tracked loader, not a hand-written download. It clears the record
352
+ // BEFORE starting and keeps an abandoned load registered, so a session-open download that
353
+ // outruns ACTUAL_OP_TIMEOUT_MS cannot silently re-point the singleton later.
354
+ await loadBudgetTracked(BUDGET_SYNC_ID, BUDGET_PASSWORD, 'pool downloadBudget');
355
+ // Mark live only after a successful download (#270): avoids the poisoning
356
+ // window where a stalled/failed download would leak isApiInitialized=true.
357
+ setApiInitialized(true);
358
+ }); // #390: end of the api-mutex block.
331
359
  this.sharedConnection = {
332
360
  sessionId: 'shared',
333
361
  initialized: true,
@@ -21,7 +21,9 @@ import { parseBudgetRegistry } from './budget-registry.js';
21
21
  import { getPreferredBudgetSyncId, setPreferredBudgetSyncId, pickAllowedPreferredBudget } from './budget-preference-store.js';
22
22
  import { requestContext } from './requestContext.js';
23
23
  import { connectionPool } from './ActualConnectionPool.js';
24
- import { isApiInitialized, setApiInitialized } from './apiState.js';
24
+ import { isApiInitialized, setApiInitialized, getLoadedBudgetSyncId, setLoadedBudgetSyncId } from './apiState.js';
25
+ import { withApiLock } from './apiLock.js';
26
+ import { loadBudgetTracked } from './budgetLoader.js';
25
27
  /**
26
28
  * Budget registry — all budgets configured via ACTUAL_* and BUDGET_n_* env vars.
27
29
  * Built once at startup; used by every withActualApi call.
@@ -86,12 +88,90 @@ function getActiveBudgetConfig() {
86
88
  * init/shutdown pairs corrupt the session. All callers (reads via withActualApi,
87
89
  * writes via processWriteQueue) must acquire this lock before touching the API.
88
90
  */
89
- let _apiSessionLock = Promise.resolve();
90
- function withApiLock(fn) {
91
- let release;
92
- const prevLock = _apiSessionLock;
93
- _apiSessionLock = new Promise(resolve => { release = resolve; });
94
- return prevLock.then(() => fn()).finally(() => release());
91
+ /**
92
+ * #390: make the loaded budget a CHECKED PRECONDITION of an operation rather than a side
93
+ * effect of whoever opened a session last.
94
+ *
95
+ * MUST be called inside the api lock. Outside it, another session can change the loaded budget
96
+ * between this check and the operation, which is the race this closes rather than narrows.
97
+ *
98
+ * The bug it fixes: `@actual-app/api` is process-global with ONE loaded budget, and both
99
+ * re-entry paths skip the download for good reasons of their own (`getConnection` returns
100
+ * early for an initialised entry; `initActualApiForOperation` returns early when the singleton
101
+ * is live, which is #134's fix for the #127 auth burst). Together they meant a session operated
102
+ * on whatever budget was loaded last, by anyone. Reproduced: session A opened on budget A and
103
+ * wrote to it, session B opened and switched to budget B, and session A's NEXT write landed in
104
+ * budget B.
105
+ *
106
+ * The budget ACL could not see this: `_enforceBudgetAcl` validates the budget the session
107
+ * BELIEVES it is on, while the operation executes against whatever is loaded, so the check and
108
+ * the effect were on different budgets.
109
+ *
110
+ * Cost in the common case is one string comparison. A single-budget deployment always resolves
111
+ * the same syncId, so nothing extra is downloaded and no upstream call is added.
112
+ */
113
+ /**
114
+ * #390: make the loaded budget a CHECKED PRECONDITION of an operation rather than a side
115
+ * effect of whoever opened a session last.
116
+ *
117
+ * MUST be called inside the api lock. Outside it, another session can change the loaded budget
118
+ * between this check and the operation.
119
+ *
120
+ * The bug: `@actual-app/api` is process-global with ONE loaded budget, and both re-entry paths
121
+ * skip the download for good reasons of their own (`getConnection` returns early for an
122
+ * initialised entry; `initActualApiForOperation` returns early when the singleton is live,
123
+ * which is #134's fix for the #127 auth burst). Together they meant a session operated on
124
+ * whatever budget was loaded last, by anyone. The budget ACL could not see it: it validates the
125
+ * budget the session BELIEVES it is on while the operation executes against whatever is loaded.
126
+ *
127
+ * Cost in the common case is one string comparison; a single-budget deployment always resolves
128
+ * the same syncId and downloads nothing. The multi-budget cost is real and is tracked as #391.
129
+ */
130
+ async function ensureLoadedBudgetMatchesSession() {
131
+ if (_skipApiInitForTests)
132
+ return;
133
+ // #393: no wait here any more. Settling an abandoned load is part of acquiring the api lock,
134
+ // and both callers of this function are already inside it. Guarding per call site is what
135
+ // produced the two P0s in #393; see withApiLock for why it moved.
136
+ if (!isApiInitialized()) {
137
+ // "The init path will download correctly" is true for the LEGACY branch and false for the
138
+ // POOLED one, which skips init entirely by design (#134). Returning here let a pooled
139
+ // operation run against a singleton nobody had loaded for it, which is how the abandoned-
140
+ // load test caught this: session B's failed session-open poisoned the singleton, A's
141
+ // precondition returned early on that, and A's raw call then ran while B's abandoned
142
+ // download landed. Initialise explicitly instead. No recursion: initActualApiForOperation
143
+ // only calls back into here when the singleton IS live, and it is not.
144
+ await initActualApiForOperation();
145
+ return;
146
+ }
147
+ const wanted = getActiveBudgetConfig();
148
+ const loaded = getLoadedBudgetSyncId();
149
+ if (loaded === wanted.syncId)
150
+ return;
151
+ // Deliberately a WARN: reaching here means two sessions are on different budgets and this
152
+ // one would otherwise have operated on the other's data.
153
+ logger.warn('[ADAPTER] loaded budget does not match this session; re-selecting before the operation', {
154
+ loadedSyncId: loaded ?? null,
155
+ wantedSyncId: wanted.syncId,
156
+ budget: wanted.name,
157
+ });
158
+ // Push anything pending for the budget we are about to close. Since #390 made re-selection
159
+ // per operation, a drain can now change budgets MID-BATCH, and the single trailing api.sync()
160
+ // covers only the last one. Without this, an earlier session's write is applied locally and
161
+ // silently waits for the next load of that budget to propagate, which an ephemeral data dir
162
+ // would lose entirely.
163
+ if (loaded) {
164
+ try {
165
+ await withOpTimeout(() => api.sync(), 'sync');
166
+ }
167
+ catch (syncErr) {
168
+ logger.warn('[ADAPTER] could not sync the outgoing budget before re-selecting', {
169
+ loadedSyncId: loaded,
170
+ error: syncErr instanceof Error ? syncErr.message : String(syncErr),
171
+ });
172
+ }
173
+ }
174
+ await loadBudgetTracked(wanted.syncId, wanted.encryptionPassword);
95
175
  }
96
176
  // Per-op timeout (#270) lives in ./opTimeout.ts so ActualConnectionPool can bound
97
177
  // its own session-open init/download without a circular import back into this
@@ -255,6 +335,9 @@ export async function withActualApi(rawOperation) {
255
335
  try {
256
336
  connectionReuseCount++;
257
337
  logger.debug(`[ADAPTER] Reusing pool connection for session ${sessionId} (reuses=${connectionReuseCount})`);
338
+ // #390: verify the singleton holds THIS session's budget before the operation runs.
339
+ // Inside the lock, so no other session can change it in between.
340
+ await ensureLoadedBudgetMatchesSession();
258
341
  return await withOpTimeout(operation);
259
342
  }
260
343
  catch (err) {
@@ -308,6 +391,9 @@ export async function withActualApiWrite(operation) {
308
391
  try {
309
392
  connectionReuseCount++;
310
393
  logger.debug(`[ADAPTER] Reusing pool connection for write session ${sessionId} (reuses=${connectionReuseCount})`);
394
+ // #390: verify the singleton holds THIS session's budget before the operation runs.
395
+ // Inside the lock, so no other session can change it in between.
396
+ await ensureLoadedBudgetMatchesSession();
311
397
  const result = await withOpTimeout(operation);
312
398
  // Propagate the write to the server so other clients (and our next
313
399
  // read) see it. Pre-#134 this happened implicitly via api.shutdown().
@@ -423,6 +509,9 @@ async function initActualApiForOperation() {
423
509
  setApiInitialized(true);
424
510
  return;
425
511
  }
512
+ // #393: the wait that used to be here moved into withApiLock. "Every path to the api has to
513
+ // settle it" was the right conclusion and the wrong implementation: enumerating paths is what
514
+ // kept missing one.
426
515
  // If the api singleton is already live (e.g. the connection pool initialised
427
516
  // it at MCP session open), don't redundantly call api.init() again — that
428
517
  // would trigger an extra upstream login and reintroduce the auth-burst
@@ -430,6 +519,17 @@ async function initActualApiForOperation() {
430
519
  // we just join in.
431
520
  if (isApiInitialized()) {
432
521
  logger.debug('[ADAPTER] api already initialised; skipping redundant init');
522
+ // #390 round 2: skipping the init is right (that is #134's fix for the #127 auth burst),
523
+ // but it also skips the downloadBudget that would have selected THIS session's budget. The
524
+ // legacy branch therefore leaked in both directions and, unlike the pooled branch, silently:
525
+ // reproduced as session A receiving session B's account list with no warning at all. It is
526
+ // reachable in ordinary HTTP operation because httpServer deliberately keeps an MCP session
527
+ // serving after its pool entry is dropped, and shutdownActualApi's sync-only branch leaves
528
+ // the singleton live whenever any other session still holds an entry.
529
+ //
530
+ // Checking HERE covers every legacy caller in one place: both withActualApi branches,
531
+ // withActualApiWrite, and the drain's legacy branch.
532
+ await ensureLoadedBudgetMatchesSession();
433
533
  return;
434
534
  }
435
535
  try {
@@ -452,14 +552,7 @@ async function initActualApiForOperation() {
452
552
  // Bound downloadBudget (#270): the legacy stdio path re-downloads on every
453
553
  // op, and a stall here was the production hang. On timeout it rejects and
454
554
  // the mutex releases.
455
- if (budget.encryptionPassword) {
456
- const encryptionPassword = budget.encryptionPassword;
457
- const apiWithOptions = api;
458
- await withOpTimeout(() => apiWithOptions.downloadBudget(budget.syncId, { password: encryptionPassword }), 'downloadBudget');
459
- }
460
- else {
461
- await withOpTimeout(() => api.downloadBudget(budget.syncId), 'downloadBudget');
462
- }
555
+ await loadBudgetTracked(budget.syncId, budget.encryptionPassword);
463
556
  setApiInitialized(true);
464
557
  logger.debug('[ADAPTER] Actual API initialized for operation');
465
558
  }
@@ -734,9 +827,26 @@ async function processWriteQueue() {
734
827
  //
735
828
  // Each op keeps its own withOpTimeout (#270) and still settles individually, so one
736
829
  // stalled or rejected operation cannot hold the api mutex or fail its siblings.
737
- for (const { operation, resolve, reject, preservesListings } of batch) {
830
+ for (const { operation, resolve, reject, preservesListings, requestStore } of batch) {
738
831
  try {
739
- const result = await withOpTimeout(operation);
832
+ // #390 round 2: run each op in the context it was ENQUEUED in.
833
+ //
834
+ // The precondition was originally checked ONCE per drain, against the ambient
835
+ // context. That was wrong twice over. A batch can span sessions, so one check for
836
+ // it is not well defined; and the ambient context here is NOT empty. The
837
+ // long-standing comment on scheduleWriteQueueDrain claims "setTimeout strips the
838
+ // ALS frame", and that is simply false: ALS propagates through timers, so the
839
+ // drain inherits the context of whichever session most recently scheduled it,
840
+ // which is the last enqueuer in the debounce window and is unrelated to this
841
+ // operation. Reproduced: with A and B on different budgets writing in one window,
842
+ // A's write landed in B's budget, and in the other ordering the precondition
843
+ // actively re-pointed the singleton at the wrong session, making a write that had
844
+ // been correct wrong. Re-entering the captured store makes each op resolve its own
845
+ // budget, which is what #158 captured a per-entry sessionId for in the first place.
846
+ const result = await requestContext.run(requestStore ?? {}, async () => {
847
+ await ensureLoadedBudgetMatchesSession();
848
+ return await withOpTimeout(operation);
849
+ });
740
850
  resolve(result);
741
851
  }
742
852
  catch (error) {
@@ -846,6 +956,31 @@ async function processWriteQueue() {
846
956
  });
847
957
  });
848
958
  }
959
+ catch (lockError) {
960
+ // #393 review: THE LOCK ITSELF CAN NOW REJECT, before `fn()` ever runs.
961
+ //
962
+ // The batch-rejection handler above lives INSIDE the lock callback, which was sound while
963
+ // withApiLock could only reject from the callback. Once acquiring the lock started settling
964
+ // an abandoned budget load, a timeout there bypasses that handler entirely, and there was
965
+ // no outer catch: every queued write then never settled (its residency timer was cleared at
966
+ // dispatch, so nothing could rescue it, which is the #278 signature), and because
967
+ // processWriteQueue is invoked unawaited from scheduleWriteQueueDrain the rejection escaped
968
+ // as an unhandledRejection that the allowlist does not cover, so src/index.ts called
969
+ // process.exit(1). A stalled upstream download would have taken the whole server down and
970
+ // every other tenant with it.
971
+ //
972
+ // Reject the batch here instead. The operations never ran and nothing was written, so this
973
+ // is the same contract queueWriteOperation's residency rejection uses.
974
+ logger.error('[WRITE QUEUE] Could not acquire the api lock for this batch:', lockError);
975
+ batch.forEach(({ reject }) => {
976
+ try {
977
+ reject(lockError);
978
+ }
979
+ catch (e) {
980
+ logger.error('[WRITE QUEUE] Error rejecting operation:', e);
981
+ }
982
+ });
983
+ }
849
984
  finally {
850
985
  isProcessingWrites = false;
851
986
  // #278: ALWAYS re-drain a non-empty queue. The old `&& writeSessionTimeout === null`
@@ -862,7 +997,11 @@ function queueWriteOperation(operation, options) {
862
997
  // op is never enqueued and no upstream resource is touched.
863
998
  _enforceBudgetAcl();
864
999
  // Capture sessionId from AsyncLocalStorage at enqueue time. The setTimeout
865
- // below strips the ALS frame, so without capturing here the pool-branch
1000
+ // below does NOT strip the ALS frame (that claim was here for a long time and is false:
1001
+ // ALS propagates through timers). What it does is inherit the context of whichever session
1002
+ // most recently SCHEDULED the drain, which is the last enqueuer in the debounce window and
1003
+ // has nothing to do with the op being run. So capturing per entry is still exactly right,
1004
+ // and #390 additionally re-enters the captured store per op. Without capturing, the pool-branch
866
1005
  // decision in processWriteQueue would always miss. See #158.
867
1006
  const sessionId = _resolveSessionId();
868
1007
  return new Promise((resolve, reject) => {
@@ -871,6 +1010,7 @@ function queueWriteOperation(operation, options) {
871
1010
  resolve,
872
1011
  reject,
873
1012
  sessionId,
1013
+ requestStore: requestContext.getStore(),
874
1014
  preservesListings: options?.preservesListings,
875
1015
  };
876
1016
  // #278: bound queue RESIDENCY, not just execution. #270's withOpTimeout bounds an
@@ -2512,14 +2652,7 @@ export async function switchBudget(name) {
2512
2652
  else {
2513
2653
  await withApiLock(async () => {
2514
2654
  // Bound the in-place budget reload too (#270).
2515
- if (found.encryptionPassword) {
2516
- const encryptionPassword = found.encryptionPassword;
2517
- const apiWithOptions = api;
2518
- await withOpTimeout(() => apiWithOptions.downloadBudget(found.syncId, { password: encryptionPassword }), 'downloadBudget');
2519
- }
2520
- else {
2521
- await withOpTimeout(() => api.downloadBudget(found.syncId), 'downloadBudget');
2522
- }
2655
+ await loadBudgetTracked(found.syncId, found.encryptionPassword);
2523
2656
  });
2524
2657
  }
2525
2658
  connectionPool.updateLoadedSyncId(sessionId, found.syncId);
@@ -2741,11 +2874,21 @@ export async function exportBudget() {
2741
2874
  export async function importBudget(input, opts = {}) {
2742
2875
  observability.incrementToolCall('actual.budgets.import').catch(() => { });
2743
2876
  const result = await queueWriteOperation(async () => {
2744
- return await withConcurrency(() => retry(() => rawImportBudget(input, opts), {
2877
+ const imported = await withConcurrency(() => retry(() => rawImportBudget(input, opts), {
2745
2878
  retries: 0,
2746
2879
  backoffMs: 200,
2747
2880
  isRetryable: isRetryableError,
2748
2881
  }));
2882
+ // #390: an import CHANGES the loaded budget (upstream documents importBudget as "loads the
2883
+ // imported budget"), and the singleton's record kept naming the pre-import one. That is the
2884
+ // unsafe direction: the precondition compares against it, passes, and a session then reads
2885
+ // the imported file while believing it is on its own budget. A sentinel rather than a real
2886
+ // syncId, because an imported file is outside the budget registry and outside the ACL, so
2887
+ // no session can ever legitimately match it and every session's next operation re-selects
2888
+ // its own budget. Recorded AT the call site so the guard in
2889
+ // tests/unit/budget_selection_precondition.test.js can see it.
2890
+ setLoadedBudgetSyncId(`imported:${imported.id}`);
2891
+ return imported;
2749
2892
  });
2750
2893
  // #349: an import CHANGES WHICH BUDGET IS LOADED, so the pool's record of it
2751
2894
  // must stop naming the old one.
@@ -0,0 +1,60 @@
1
+ import { awaitAbandonedBudgetLoad } from './apiState.js';
2
+ import { withOpTimeout } from './opTimeout.js';
3
+ import { createModuleLogger } from './loggerFactory.js';
4
+ const logger = createModuleLogger('API LOCK');
5
+ /**
6
+ * The process-global @actual-app/api session mutex.
7
+ *
8
+ * WHY IT LIVES HERE rather than in actual-adapter.ts, which is where it was written and where
9
+ * every existing caller still reaches it from (the adapter re-exports it, so importers are
10
+ * unchanged). #390 needed `ActualConnectionPool` to hold this lock around its own
11
+ * init + downloadBudget, and the pool cannot import from the adapter: the adapter imports the
12
+ * pool singleton, so that edge is circular. This is the same reason `apiState.ts` and
13
+ * `opTimeout.ts` were split out before it, and the comments there say so.
14
+ *
15
+ * WHAT IT GUARANTEES. `@actual-app/api` is a singleton over one SQLite connection, and
16
+ * concurrent init/shutdown pairs corrupt the session. Every path that touches the api takes
17
+ * this first: reads via `withActualApi`, writes via `processWriteQueue`, and since #390 the
18
+ * pool's session-open init and budget download.
19
+ *
20
+ * IT IS NOT REENTRANT, and that is load bearing. It is a strict FIFO chained-promise mutex, so
21
+ * an inner acquisition waits on the outer call's own release promise, which cannot fire until
22
+ * the outer callback resolves. The observable symptom is a stall ended by #270's operation
23
+ * timeout, NOT a hang, and it should be read as a probable nesting bug rather than a slow
24
+ * upstream. Before adding an acquisition, check that no caller above you already holds it.
25
+ */
26
+ let _apiSessionLock = Promise.resolve();
27
+ /**
28
+ * #393: settling an abandoned budget load is part of ACQUIRING the lock, not something call
29
+ * sites remember to do.
30
+ *
31
+ * Every previous round of #390 guarded per call site and every round missed one. Round 1 missed
32
+ * the legacy path (a silent cross-tenant read). Round 2 put the wait in the two adapter entry
33
+ * points and missed the three `loadBudgetTracked` sites that reach the api without passing
34
+ * through them, so a session opening during the window untracked the abandoned load and the
35
+ * leak stayed reachable. CLAUDE.md already names this hazard class for `withOpTimeout`: the
36
+ * protection is per-call-site rather than structural, so a new acquisition site added without
37
+ * it fails silently.
38
+ *
39
+ * Putting it here makes the set of call sites stop mattering. Nothing reaches
40
+ * `@actual-app/api` without this lock, so nothing reaches it without the wait.
41
+ *
42
+ * It is bounded and fails closed (see `awaitAbandonedBudgetLoad`): an unbounded wait here would
43
+ * wedge every session on one stuck download, which is what made the round-2 version worse than
44
+ * the bug it fixed.
45
+ */
46
+ export function withApiLock(fn) {
47
+ let release;
48
+ const prevLock = _apiSessionLock;
49
+ _apiSessionLock = new Promise((resolve) => {
50
+ release = resolve;
51
+ });
52
+ return prevLock
53
+ .then(async () => {
54
+ if (await awaitAbandonedBudgetLoad(withOpTimeout)) {
55
+ logger.warn('[API LOCK] waited for an abandoned budget load before running this operation');
56
+ }
57
+ return await fn();
58
+ })
59
+ .finally(() => release());
60
+ }
@@ -23,4 +23,111 @@ export function isApiInitialized() {
23
23
  }
24
24
  export function setApiInitialized(value) {
25
25
  _apiInitialized = value;
26
+ // A singleton that is not live holds no budget. Clearing here means a shutdown can never
27
+ // leave a stale claim behind for the next caller to trust.
28
+ if (!value)
29
+ _loadedBudgetSyncId = null;
30
+ }
31
+ /**
32
+ * #390: the syncId of the budget the SINGLETON currently holds, as distinct from the budget
33
+ * any given session believes it is on.
34
+ *
35
+ * WHY THIS HAS TO EXIST. `@actual-app/api` is process-global with ONE loaded budget, while the
36
+ * pool tracks up to MAX_CONCURRENT_SESSIONS entries that each carry their own syncId. Before
37
+ * this, nothing recorded which budget was actually loaded, so no code could tell that a
38
+ * session was about to operate on someone else's. Both re-entry paths skip the download for
39
+ * good reasons of their own: `getConnection` returns early for an initialised entry, and
40
+ * `initActualApiForOperation` returns early when the singleton is live (which is #134's fix
41
+ * for the #127 auth burst). Neither is wrong; together they meant the loaded budget was
42
+ * whatever the last session to open had asked for.
43
+ *
44
+ * Reproduced before the fix: session A opened on budget A and wrote to it, session B opened
45
+ * and switched to budget B, and session A's NEXT write landed in budget B. The budget ACL
46
+ * could not see it, because `_enforceBudgetAcl` validates the budget the session believes it
47
+ * is on while the operation executes against whatever is loaded.
48
+ *
49
+ * Written by every path that downloads a budget; read inside the api lock by the adapter's
50
+ * precondition check. Lives here for the same reason the live flag does: the pool must update
51
+ * it without importing the adapter.
52
+ */
53
+ let _loadedBudgetSyncId = null;
54
+ export function getLoadedBudgetSyncId() {
55
+ return _loadedBudgetSyncId;
56
+ }
57
+ export function setLoadedBudgetSyncId(syncId) {
58
+ _loadedBudgetSyncId = syncId;
59
+ }
60
+ /**
61
+ * #390 round 3: an ABANDONED budget load.
62
+ *
63
+ * `withOpTimeout` races; it does not cancel. When a `downloadBudget` exceeds
64
+ * ACTUAL_OP_TIMEOUT_MS the underlying call KEEPS RUNNING and eventually re-points the
65
+ * singleton, outside the mutex, at a moment nobody is waiting for. Upstream makes this worse
66
+ * than a failed no-op: `handlers['api/download-budget']` begins with `close-budget`, then
67
+ * `load-budget` and `sync-budget`, so an abandoned download CLOSES whatever budget is loaded
68
+ * and opens a different one mid-flight, underneath another session's lock.
69
+ *
70
+ * Two leaks were reproduced against the previous fix. The record stayed on the old budget
71
+ * (only set on success), so the next matching session passed the check and read someone else's
72
+ * data; and even after recording the true outcome, the re-point landed BETWEEN a session's
73
+ * check and its raw call, so the write still went to the wrong budget. A mutex cannot serialise
74
+ * a promise its holder has abandoned.
75
+ *
76
+ * So the load is tracked rather than merely bounded. The record is cleared BEFORE a download
77
+ * starts, so an abandonment can only leave it indeterminate (the safe direction, which forces a
78
+ * re-select), and the abandoned promise stays registered here until it settles. No operation
79
+ * may proceed past a pending abandoned load: `awaitAbandonedBudgetLoad` is awaited inside the
80
+ * api lock, so a late landing happens BEFORE the next check rather than between a check and its
81
+ * use.
82
+ */
83
+ const _pendingBudgetLoads = new Set();
84
+ /**
85
+ * Register an in-flight budget load.
86
+ *
87
+ * A SET, and each entry removes itself once it settles. Two earlier shapes were wrong:
88
+ * assigning to a single slot let a session opening during the window overwrite an outstanding
89
+ * abandoned load, whose success then cleared it, so the abandoned promise became untracked and
90
+ * landed later (that was #393's second P0). Chaining fixed the overwrite but left a completed
91
+ * load registered forever, because a chained promise is no longer the handle its creator holds,
92
+ * so nothing could clear it and "is a load outstanding" stopped meaning anything.
93
+ *
94
+ * With a set, concurrent loads all stay tracked and a finished one drops out on its own, so
95
+ * what remains is exactly the loads that are still in flight.
96
+ */
97
+ export function registerBudgetLoad(p) {
98
+ // Never let the registration raise an unhandled rejection: the caller races this promise and
99
+ // handles (or abandons) the failure on its own path.
100
+ const safe = p.then(() => undefined, () => undefined);
101
+ _pendingBudgetLoads.add(safe);
102
+ void safe.then(() => {
103
+ _pendingBudgetLoads.delete(safe);
104
+ });
105
+ }
106
+ /**
107
+ * Settle every outstanding load, BOUNDED, and FAIL CLOSED on timeout.
108
+ *
109
+ * #393: the unbounded version was a P0 worse than the leak it closed. Both call sites were
110
+ * inside the api mutex and outside any timeout, so ONE never-settling download blocked every
111
+ * session forever, with no error after the first line and no recovery short of a process
112
+ * restart. That is exactly the mode opTimeout.ts exists to remove and that #270 removed, so
113
+ * reintroducing it was a regression against a fixed bug.
114
+ *
115
+ * On timeout this THROWS and leaves the registrations in place. Both alternatives are worse:
116
+ * proceeding runs the operation against a singleton a landing download may re-point underneath
117
+ * it (the original leak), and clearing forgets the landing entirely. A persistent, legible
118
+ * per-request error with the mutex released each time is the honest failure for "an upstream
119
+ * load is stuck and we cannot cancel it".
120
+ *
121
+ * Returns true when it actually waited, so callers can log it and tests can assert it.
122
+ */
123
+ export async function awaitAbandonedBudgetLoad(bound) {
124
+ if (_pendingBudgetLoads.size === 0)
125
+ return false;
126
+ const outstanding = Promise.all([..._pendingBudgetLoads]);
127
+ await bound(() => outstanding, 'abandoned budget load');
128
+ return true;
129
+ }
130
+ /** Test hook: is a load currently outstanding? */
131
+ export function _hasPendingBudgetLoadForTests() {
132
+ return _pendingBudgetLoads.size > 0;
26
133
  }
@@ -0,0 +1,295 @@
1
+ import api from '@actual-app/api';
2
+ import { withOpTimeout } from './opTimeout.js';
3
+ import { setApiInitialized, setLoadedBudgetSyncId, registerBudgetLoad } from './apiState.js';
4
+ import { createModuleLogger } from './loggerFactory.js';
5
+ const log = createModuleLogger('BUDGET-LOADER');
6
+ /**
7
+ * #390 round 3: the ONE way this codebase loads a budget.
8
+ *
9
+ * Every site used to hand-write `withOpTimeout(() => api.downloadBudget(...))` followed by a
10
+ * record-on-success. That shape is wrong in a way no amount of care fixes, because
11
+ * `withOpTimeout` races rather than cancels: on timeout the download keeps running and later
12
+ * re-points the singleton outside the mutex, and recording only on success leaves the record
13
+ * naming the PREVIOUS budget while the singleton holds the new one, which is the one direction
14
+ * that makes the precondition silently pass.
15
+ *
16
+ * So: clear the record BEFORE starting (an abandonment can then only leave it indeterminate,
17
+ * which forces a re-select), register the promise so a late landing is waited for rather than
18
+ * discovered, and record the true outcome when it settles whether or not anyone is still
19
+ * listening.
20
+ *
21
+ * #396 adds a POST-CONDITION, because a resolved download is not proof a budget is open.
22
+ * See `assertBudgetOpen` below.
23
+ *
24
+ * INVARIANT: nothing in this file may call anything that takes `withApiLock`. Every caller
25
+ * already holds that non-reentrant mutex, so `adapter.*` here would deadlock. It is also what
26
+ * makes this loader safe to call from `actualConnection.ts`, which acquires the lock itself.
27
+ * (#402 tracks making this structural rather than conventional.)
28
+ */
29
+ /** The closed set of reasons upstream's `_loadBudget` can report, via `withErrorCode`. */
30
+ const KNOWN_LOAD_REASONS = new Set([
31
+ 'budget-not-found',
32
+ 'opening-budget',
33
+ 'out-of-sync-migrations',
34
+ 'out-of-sync-data',
35
+ 'loading-budget',
36
+ ]);
37
+ /** Upper bound on any upstream prose we echo to the client (#396 security review). */
38
+ const MAX_REASON_CHARS = 200;
39
+ /** Sentinel: the diagnostic load worked, so the failure was not deterministic. */
40
+ const RETRY_SUCCEEDED = 'the load succeeded on a second attempt, so this looks transient; retry the operation';
41
+ /** Reported when the syncId has no LOCAL copy. Deliberately says nothing about what does exist. */
42
+ const NO_LOCAL_COPY = 'no local copy of this budget was found';
43
+ /**
44
+ * Brand for "the download resolved but no budget is open", so the caller can tell it from an
45
+ * upstream stall without matching prose. Deliberately NOT a PreflightRefusal: this is an
46
+ * infrastructure failure, not a caller-fixable refusal (`errors.ts` taxonomy).
47
+ */
48
+ const BUDGET_NOT_OPEN = Symbol.for('actual-mcp.budgetNotOpen');
49
+ function isBudgetNotOpen(err) {
50
+ return !!err && typeof err === 'object' && err[BUDGET_NOT_OPEN] === true;
51
+ }
52
+ /**
53
+ * Read `.message` off anything upstream throws.
54
+ *
55
+ * NO `instanceof Error` GUARD, deliberately. `checkFileOpen()` throws `APIError(...)`, which
56
+ * RETURNS A PLAIN OBJECT (`{ type: 'APIError', message, meta }`). Measured against
57
+ * @actual-app/api 26.8.0: `err instanceof Error` is false and `String(err)` is `[object Object]`,
58
+ * while `.message` works. Same precedent as `actual-adapter.ts` ("Handle Actual APIError plain
59
+ * objects").
60
+ */
61
+ function messageOf(err) {
62
+ if (err && typeof err === 'object' && typeof err.message === 'string') {
63
+ return err.message;
64
+ }
65
+ return typeof err === 'string' ? err : '';
66
+ }
67
+ function codeOf(err) {
68
+ if (err && typeof err === 'object' && typeof err.code === 'string') {
69
+ return err.code;
70
+ }
71
+ return undefined;
72
+ }
73
+ /** A timeout from `withOpTimeout` is an upstream STALL, never an unloadable budget. */
74
+ function isOpTimeout(err) {
75
+ return /timed out after \d+ms \(ACTUAL_OP_TIMEOUT_MS\)/.test(messageOf(err));
76
+ }
77
+ /**
78
+ * #396 POST-CONDITION: is a budget actually open?
79
+ *
80
+ * `api.downloadBudget()` can RESOLVE while leaving nothing open. Upstream's
81
+ * `api/download-budget` discards the `{ error }` that `load-budget` returns, in BOTH branches
82
+ * (loot-core `src/server/api.ts:237` for a local copy, `:261` after a fresh download), and
83
+ * `load-budget` does not throw: it returns an error object having already called `closeBudget()`
84
+ * internally, which unloads prefs. The sync that follows cannot catch it either, because
85
+ * `_fullSync` opens with `if (... || !currentId) return []`, so with nothing loaded it reports
86
+ * success by returning nothing. Reproduced two ways against a real budget (a planted unknown
87
+ * migration id, and a `db.sqlite` that is not a database): the download resolves, nothing is
88
+ * open, and every later call throws `No budget file is open` permanently for that data dir.
89
+ *
90
+ * The probe is `api.getBudgetMonths()`, and it is cheap for a reason worth stating rather than
91
+ * assuming: it is `checkFileOpen()` plus `createAllBudgets()`, and `createAllBudgets()` already
92
+ * ran once inside `_loadBudget` against the SAME in-process `Spreadsheet` singleton, while
93
+ * `meta.createdMonths` is never persisted, so the second call filters to zero new months and does
94
+ * no work beyond one indexed SQLite read. Measured at 0ms with no network call.
95
+ */
96
+ async function assertBudgetOpen() {
97
+ try {
98
+ await withOpTimeout(() => api.getBudgetMonths(), 'budget-open probe');
99
+ }
100
+ catch (err) {
101
+ // An upstream STALL is not an unloadable budget. Propagate it unchanged so it keeps #270's
102
+ // semantics: diagnosing it as a version or data-dir problem would point the user at the wrong
103
+ // subsystem, and its message contains "timed out", which `_shouldDropPoolOnError` classes
104
+ // transient (correctly, for a stall).
105
+ if (isOpTimeout(err))
106
+ throw err;
107
+ // #396 review: classify as "not open" ONLY on the signal that actually means it. Treating
108
+ // EVERY probe rejection as not-open was wrong in the expensive direction: a transient
109
+ // getBudgetMonths failure against a perfectly healthy OPEN budget would poison the singleton,
110
+ // send `diagnose` off to CLOSE and reload that healthy budget, and hand the caller a
111
+ // permanent-sounding "no budget file is open". Anything else is a genuine failure and
112
+ // propagates unchanged; the enclosing `.catch` still fails closed either way.
113
+ if (!/no budget file is open/i.test(messageOf(err)))
114
+ throw err;
115
+ const notOpen = new Error('the budget did not load');
116
+ notOpen[BUDGET_NOT_OPEN] = true;
117
+ throw notOpen;
118
+ }
119
+ }
120
+ /**
121
+ * Strip anything path-shaped from upstream prose before it reaches an MCP client.
122
+ *
123
+ * There is NO redaction backstop on this path: `actualToolsManager.callTool` rethrows and
124
+ * `ActualMCPConnection.executeTool` propagates, so the message reaches the client verbatim, and
125
+ * `redactSecrets` is a winston format that covers log transports only. The five `getSyncError`
126
+ * strings interpolate just the budget UUID and are safe, but `_loadBudget` maps to that enum only
127
+ * for failures it CATCHES: `db.loadClock()`, `prefs.savePrefs` and `startBackupService(id)` sit
128
+ * outside every try/catch, so a raw fs or sqlite error carrying an ABSOLUTE PATH (and therefore a
129
+ * username) can reach here. Capping does not scrub a path, so scrub first.
130
+ */
131
+ function scrubReason(prose) {
132
+ if (!prose)
133
+ return undefined;
134
+ // #396 review: reject on ANY separator, not just one at a word boundary. The first version
135
+ // required `/` at the string start or after whitespace, a quote or a paren, so
136
+ // `ENOENT:/home/alice/...` and `C:/Users/alice/...` both slipped through WITH the OS username,
137
+ // on a path that has no redaction backstop. The five known-safe upstream reasons are plain
138
+ // prose plus a UUID and contain none of these characters, so rejecting outright costs nothing
139
+ // and cannot leak.
140
+ if (/[/\\~]/.test(prose))
141
+ return undefined;
142
+ return prose.length > MAX_REASON_CHARS ? `${prose.slice(0, MAX_REASON_CHARS)}...` : prose;
143
+ }
144
+ /**
145
+ * Ask upstream WHY the load failed, on the deterministic failure path only.
146
+ *
147
+ * Upstream logs the real reason and throws it away, which is the whole reason a user hitting this
148
+ * has nothing actionable to go on. `api/load-budget` (unlike `api/download-budget`) DOES check the
149
+ * error and throws it with `withErrorCode`, so re-attempting the load through that entry point
150
+ * recovers the exact reason.
151
+ *
152
+ * Runs where a CALLER IS WAITING, never inside the promise `registerBudgetLoad` registers: every
153
+ * `withApiLock` acquisition settles all registrations under ONE bound and fails closed on timeout,
154
+ * so putting two network calls in there would extend the window in which unrelated sessions' lock
155
+ * acquisitions throw, to build a message an abandoned load has no caller to read.
156
+ */
157
+ const reasonCache = new Map();
158
+ /** A load that genuinely succeeded clears any cached reason for that budget. */
159
+ function clearCachedReason(syncId) {
160
+ reasonCache.delete(syncId);
161
+ }
162
+ /** Test-only: the reason cache is module state, so a test suite must be able to reset it. */
163
+ export function _clearReasonCacheForTests() {
164
+ reasonCache.clear();
165
+ }
166
+ async function diagnose(syncId) {
167
+ // #396 review: memoise the reason STRING, never the fail-closed decision. Without this, every
168
+ // tool call while the condition persists pays two bounded network calls WHILE HOLDING the
169
+ // process-global api mutex, so a single unloadable budget stalls unrelated sessions for up to
170
+ // 2 x ACTUAL_OP_TIMEOUT_MS per call. The condition is deterministic, so the second diagnosis
171
+ // would report what the first did. The ticket sanctions exactly this and nothing more: the
172
+ // decision stays uncached, so the load is still re-attempted and still fails closed every time,
173
+ // and a genuinely successful load clears the entry.
174
+ // An empty string means "already diagnosed, and there was nothing safe to report". It must be
175
+ // cached like any other outcome: review round 2 found that caching only the reportable outcomes
176
+ // left the two most persistent failure classes re-diagnosing on every single call, which is
177
+ // precisely the cost this memo exists to remove.
178
+ const cached = reasonCache.get(syncId);
179
+ if (cached !== undefined) {
180
+ log.debug('reusing the cached load-failure reason', { syncId });
181
+ return cached || undefined;
182
+ }
183
+ try {
184
+ const budgets = (await withOpTimeout(() => api.getBudgets(), 'diagnose getBudgets'));
185
+ // `api/get-budgets` does NO deduplication: it concatenates the local scan with
186
+ // `get-remote-files`, so a budget synced both ways appears TWICE and only the LOCAL entry
187
+ // carries `id`. Measured: [{"id":"_test-budget"},{"state":"remote"}] for one groupId.
188
+ // Matching on groupId alone can select the remote twin and call loadBudget(undefined).
189
+ const local = budgets.find((b) => b.groupId === syncId && typeof b.id === 'string');
190
+ if (!local) {
191
+ // Deliberately does NOT list what IS available: that would enumerate every budget on the
192
+ // server to a caller the ACL scopes to one.
193
+ reasonCache.set(syncId, NO_LOCAL_COPY);
194
+ return NO_LOCAL_COPY;
195
+ }
196
+ const localId = local.id;
197
+ // Registered like any other load: it re-enters upstream's full `_loadBudget` against the
198
+ // process-global singleton, so an abandoned diagnostic could otherwise land later and
199
+ // re-point it outside the lock (the #390 leak with a new author).
200
+ const p = api.loadBudget(localId);
201
+ registerBudgetLoad(p);
202
+ try {
203
+ await withOpTimeout(() => p, 'diagnose loadBudget');
204
+ // The diagnostic load SUCCEEDED, so the singleton now holds a budget nothing selected.
205
+ // Record indeterminate: that forces a re-select on the next operation, which is the safe
206
+ // direction #390 established. This line also satisfies block (4) of
207
+ // budget_selection_precondition.test.js structurally rather than by exemption, and it must
208
+ // stay in THIS brace-balanced block: the guard's window ends at the first `}` that takes
209
+ // depth below zero, so a setter in a sibling `catch` or `finally` arm would be outside it.
210
+ setLoadedBudgetSyncId(null);
211
+ // #396 review: the retry SUCCEEDED, so this was not a deterministic unloadable budget. Say
212
+ // so rather than reporting "upstream discarded the reason", which reads as permanent. The
213
+ // operation still fails (the record is indeterminate by design, which forces a re-select),
214
+ // but the caller is told that retrying is the right move. Not cached: it is not a reason,
215
+ // and the condition it describes is transient.
216
+ return RETRY_SUCCEEDED;
217
+ }
218
+ catch (loadErr) {
219
+ setLoadedBudgetSyncId(null);
220
+ // Prefer the CODE. Upstream attaches it via `withErrorCode` precisely so callers need not
221
+ // parse prose, and the prose is lossy: `getSyncError` collapses out-of-sync-migrations with
222
+ // out-of-sync-data, and opening-budget with loading-budget, so message-only capture
223
+ // distinguishes at most three of the five reasons.
224
+ const code = codeOf(loadErr);
225
+ log.warn('budget load diagnosis', { syncId, code: code ?? null, reason: messageOf(loadErr) });
226
+ const reason = code && KNOWN_LOAD_REASONS.has(code) ? code : scrubReason(messageOf(loadErr));
227
+ // Cache the outcome even when there is nothing reportable. A raw fs or sqlite failure has a
228
+ // `.code` outside KNOWN_LOAD_REASONS and path-shaped prose that `scrubReason` drops, so it
229
+ // yields `undefined`, and that is the ticket's own reproduced "db.sqlite is not a database"
230
+ // case: the one most likely to persist. Storing '' records "diagnosed" without reporting.
231
+ reasonCache.set(syncId, reason ?? '');
232
+ return reason;
233
+ }
234
+ }
235
+ catch (diagErr) {
236
+ // A failing diagnostic is LOGGED, never substituted for the post-condition error.
237
+ log.warn('could not diagnose why the budget did not load', { syncId, error: messageOf(diagErr) });
238
+ return undefined;
239
+ }
240
+ }
241
+ function postConditionError(syncId, reason) {
242
+ return new Error(`The budget "${syncId}" reported a successful download but no budget file is open, ` +
243
+ `so every subsequent operation would fail. ` +
244
+ (reason ? `Upstream reason: [${reason}]. ` : 'Upstream discarded the reason. ') +
245
+ `Two causes account for almost all of these: a version mismatch between @actual-app/api and ` +
246
+ `the Actual server or budget file (the server version guard logs a warning when it can detect ` +
247
+ `this), and a local budget copy that cannot be opened (check the directory configured by ` +
248
+ `MCP_BRIDGE_DATA_DIR, and note that two server processes sharing one data dir will do this).`);
249
+ }
250
+ export async function loadBudgetTracked(syncId, encryptionPassword, label = 'downloadBudget') {
251
+ const raw = encryptionPassword
252
+ ? api
253
+ .downloadBudget(syncId, { password: encryptionPassword })
254
+ : api.downloadBudget(syncId);
255
+ // Indeterminate from here until it settles. Cleared first, deliberately.
256
+ setLoadedBudgetSyncId(null);
257
+ // #396: `.then(onFulfilled).catch(onRejected)`, NOT the two-argument `.then`. A throw from the
258
+ // `onFulfilled` argument of `.then(onFulfilled, onRejected)` is NOT caught by that same
259
+ // `onRejected`, so with the old shape a probe failure would skip the fail-closed
260
+ // `setApiInitialized(false)` below: exactly the new failure path this adds.
261
+ //
262
+ // The probe lives INSIDE this chain so an ABANDONED load is verified when it lands, rather than
263
+ // recording a false success nobody checked. The network enrichment deliberately does not.
264
+ const tracked = raw
265
+ .then(async () => {
266
+ await assertBudgetOpen();
267
+ setLoadedBudgetSyncId(syncId);
268
+ clearCachedReason(syncId);
269
+ })
270
+ .catch((err) => {
271
+ // A failed load leaves the singleton in a state nobody can describe: upstream's
272
+ // download handler closes the current budget before opening the new one, so "failed"
273
+ // does not mean "unchanged". Poison it so the next operation re-inits from scratch.
274
+ setApiInitialized(false);
275
+ throw err;
276
+ });
277
+ registerBudgetLoad(tracked);
278
+ try {
279
+ await withOpTimeout(() => tracked, label);
280
+ // #393 review (P3-4): NOT clearBudgetLoad(tracked). `registerBudgetLoad` registers a
281
+ // derived promise, not this handle, so that delete could never match and read as a working
282
+ // belt-and-braces path a later change might lean on. Self-removal on settle is what
283
+ // actually deregisters a completed load, and it has already happened by the time the
284
+ // awaited promise resolves here.
285
+ }
286
+ catch (err) {
287
+ // On timeout the tracked promise is still running. Leave it REGISTERED: the next operation
288
+ // waits for it inside the lock rather than racing it.
289
+ if (isBudgetNotOpen(err)) {
290
+ log.error('download resolved but no budget is open', undefined, { syncId });
291
+ throw postConditionError(syncId, await diagnose(syncId));
292
+ }
293
+ throw err;
294
+ }
295
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.16.2",
4
+ "version": "0.16.5",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"
@@ -30,7 +30,7 @@
30
30
  "verify-tools": "npm run build && node scripts/verify-tools.js",
31
31
  "check:coverage": "node scripts/list-actual-api-methods.mjs",
32
32
  "direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
33
- "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js",
34
34
  "test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
35
35
  "test:e2e": "npx playwright test",
36
36
  "test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",