actual-mcp-server 0.16.2 → 0.16.6

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.
@@ -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, importBudgetTracked } 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) {
@@ -264,8 +347,9 @@ export async function withActualApi(rawOperation) {
264
347
  // re-introduce the auth-burst pattern #134 is fixing.
265
348
  if (_shouldDropPoolOnError(err)) {
266
349
  logger.warn(`[ADAPTER] Releasing pool connection for session ${sessionId} after infrastructure-level error`);
350
+ // #392: Locked variant. We are INSIDE withApiLock here; the wrapping one would deadlock.
267
351
  try {
268
- await connectionPool.shutdownConnection(sessionId);
352
+ await connectionPool.shutdownConnectionLocked(sessionId);
269
353
  }
270
354
  catch (_e) { /* swallow */ }
271
355
  }
@@ -308,6 +392,9 @@ export async function withActualApiWrite(operation) {
308
392
  try {
309
393
  connectionReuseCount++;
310
394
  logger.debug(`[ADAPTER] Reusing pool connection for write session ${sessionId} (reuses=${connectionReuseCount})`);
395
+ // #390: verify the singleton holds THIS session's budget before the operation runs.
396
+ // Inside the lock, so no other session can change it in between.
397
+ await ensureLoadedBudgetMatchesSession();
311
398
  const result = await withOpTimeout(operation);
312
399
  // Propagate the write to the server so other clients (and our next
313
400
  // read) see it. Pre-#134 this happened implicitly via api.shutdown().
@@ -321,8 +408,9 @@ export async function withActualApiWrite(operation) {
321
408
  // Sync failure on a write IS infrastructure-level — drop the pool
322
409
  // connection so the next call re-inits, then surface the error.
323
410
  logger.error(`[ADAPTER] api.sync() failed after write in session ${sessionId}; releasing pool connection`);
411
+ // #392: Locked variant. We are INSIDE withApiLock here; the wrapping one would deadlock.
324
412
  try {
325
- await connectionPool.shutdownConnection(sessionId);
413
+ await connectionPool.shutdownConnectionLocked(sessionId);
326
414
  }
327
415
  catch (_e) { /* swallow */ }
328
416
  throw syncErr;
@@ -335,8 +423,9 @@ export async function withActualApiWrite(operation) {
335
423
  // leave the connection fine.
336
424
  if (_shouldDropPoolOnError(err)) {
337
425
  logger.warn(`[ADAPTER] Releasing pool connection for write session ${sessionId} after infrastructure-level error`);
426
+ // #392: Locked variant. We are INSIDE withApiLock here; the wrapping one would deadlock.
338
427
  try {
339
- await connectionPool.shutdownConnection(sessionId);
428
+ await connectionPool.shutdownConnectionLocked(sessionId);
340
429
  }
341
430
  catch (_e) { /* swallow */ }
342
431
  }
@@ -423,6 +512,9 @@ async function initActualApiForOperation() {
423
512
  setApiInitialized(true);
424
513
  return;
425
514
  }
515
+ // #393: the wait that used to be here moved into withApiLock. "Every path to the api has to
516
+ // settle it" was the right conclusion and the wrong implementation: enumerating paths is what
517
+ // kept missing one.
426
518
  // If the api singleton is already live (e.g. the connection pool initialised
427
519
  // it at MCP session open), don't redundantly call api.init() again — that
428
520
  // would trigger an extra upstream login and reintroduce the auth-burst
@@ -430,6 +522,17 @@ async function initActualApiForOperation() {
430
522
  // we just join in.
431
523
  if (isApiInitialized()) {
432
524
  logger.debug('[ADAPTER] api already initialised; skipping redundant init');
525
+ // #390 round 2: skipping the init is right (that is #134's fix for the #127 auth burst),
526
+ // but it also skips the downloadBudget that would have selected THIS session's budget. The
527
+ // legacy branch therefore leaked in both directions and, unlike the pooled branch, silently:
528
+ // reproduced as session A receiving session B's account list with no warning at all. It is
529
+ // reachable in ordinary HTTP operation because httpServer deliberately keeps an MCP session
530
+ // serving after its pool entry is dropped, and shutdownActualApi's sync-only branch leaves
531
+ // the singleton live whenever any other session still holds an entry.
532
+ //
533
+ // Checking HERE covers every legacy caller in one place: both withActualApi branches,
534
+ // withActualApiWrite, and the drain's legacy branch.
535
+ await ensureLoadedBudgetMatchesSession();
433
536
  return;
434
537
  }
435
538
  try {
@@ -452,14 +555,7 @@ async function initActualApiForOperation() {
452
555
  // Bound downloadBudget (#270): the legacy stdio path re-downloads on every
453
556
  // op, and a stall here was the production hang. On timeout it rejects and
454
557
  // 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
- }
558
+ await loadBudgetTracked(budget.syncId, budget.encryptionPassword);
463
559
  setApiInitialized(true);
464
560
  logger.debug('[ADAPTER] Actual API initialized for operation');
465
561
  }
@@ -734,9 +830,26 @@ async function processWriteQueue() {
734
830
  //
735
831
  // Each op keeps its own withOpTimeout (#270) and still settles individually, so one
736
832
  // stalled or rejected operation cannot hold the api mutex or fail its siblings.
737
- for (const { operation, resolve, reject, preservesListings } of batch) {
833
+ for (const { operation, resolve, reject, preservesListings, requestStore } of batch) {
738
834
  try {
739
- const result = await withOpTimeout(operation);
835
+ // #390 round 2: run each op in the context it was ENQUEUED in.
836
+ //
837
+ // The precondition was originally checked ONCE per drain, against the ambient
838
+ // context. That was wrong twice over. A batch can span sessions, so one check for
839
+ // it is not well defined; and the ambient context here is NOT empty. The
840
+ // long-standing comment on scheduleWriteQueueDrain claims "setTimeout strips the
841
+ // ALS frame", and that is simply false: ALS propagates through timers, so the
842
+ // drain inherits the context of whichever session most recently scheduled it,
843
+ // which is the last enqueuer in the debounce window and is unrelated to this
844
+ // operation. Reproduced: with A and B on different budgets writing in one window,
845
+ // A's write landed in B's budget, and in the other ordering the precondition
846
+ // actively re-pointed the singleton at the wrong session, making a write that had
847
+ // been correct wrong. Re-entering the captured store makes each op resolve its own
848
+ // budget, which is what #158 captured a per-entry sessionId for in the first place.
849
+ const result = await requestContext.run(requestStore ?? {}, async () => {
850
+ await ensureLoadedBudgetMatchesSession();
851
+ return await withOpTimeout(operation);
852
+ });
740
853
  resolve(result);
741
854
  }
742
855
  catch (error) {
@@ -761,10 +874,12 @@ async function processWriteQueue() {
761
874
  // the batch ran concurrently and a drain was bounded by roughly one
762
875
  // ACTUAL_OP_TIMEOUT_MS. Sequential dispatch makes a drain's wall clock the SUM of
763
876
  // its ops, so a batch of slow ops can now outlive SESSION_IDLE_TIMEOUT_MINUTES
764
- // (default 5) and be swept mid-drain. The sweep's shutdownConnection calls
765
- // api.shutdown() WITHOUT holding withApiLock, which is the same hazard this file
766
- // already documents as the reason stdio is kept off the pool: it tears the
767
- // singleton down underneath an in-flight operation.
877
+ // (default 5) and be swept mid-drain. The sweep would then evict the session and
878
+ // fire its eviction listeners, closing the transport under a drain that is still
879
+ // running. Since #392 the sweep takes the api mutex and re-checks expiry, so it can
880
+ // no longer tear the singleton down mid-operation, but an unrefreshed entry is still
881
+ // MARKED expired, so the touch below is what keeps an active session out of the
882
+ // sweep's list at all.
768
883
  //
769
884
  // THIS MUST STAY INSIDE THE LOOP. A single touch at drain start does nothing for
770
885
  // a drain whose wall clock is the SUM of its ops, which is the entire reason the
@@ -799,7 +914,9 @@ async function processWriteQueue() {
799
914
  if (usePoolBranch && _shouldDropPoolOnError(syncError)) {
800
915
  logger.warn(`[WRITE QUEUE] Releasing pool connection for session ${batchSessionId} after sync failure`);
801
916
  try {
802
- await connectionPool.shutdownConnection(batchSessionId);
917
+ // #392: Locked variant; the drain holds withApiLock across this whole block, and
918
+ // the mutex is not reentrant, so the wrapping variant would deadlock here.
919
+ await connectionPool.shutdownConnectionLocked(batchSessionId);
803
920
  }
804
921
  catch (_e) {
805
922
  /* swallow */
@@ -832,7 +949,9 @@ async function processWriteQueue() {
832
949
  if (usePoolBranch) {
833
950
  if (_shouldDropPoolOnError(error)) {
834
951
  try {
835
- await connectionPool.shutdownConnection(batchSessionId);
952
+ // #392: Locked variant; the drain holds withApiLock across this whole block, and
953
+ // the mutex is not reentrant, so the wrapping variant would deadlock here.
954
+ await connectionPool.shutdownConnectionLocked(batchSessionId);
836
955
  }
837
956
  catch (_e) {
838
957
  /* swallow */
@@ -846,6 +965,31 @@ async function processWriteQueue() {
846
965
  });
847
966
  });
848
967
  }
968
+ catch (lockError) {
969
+ // #393 review: THE LOCK ITSELF CAN NOW REJECT, before `fn()` ever runs.
970
+ //
971
+ // The batch-rejection handler above lives INSIDE the lock callback, which was sound while
972
+ // withApiLock could only reject from the callback. Once acquiring the lock started settling
973
+ // an abandoned budget load, a timeout there bypasses that handler entirely, and there was
974
+ // no outer catch: every queued write then never settled (its residency timer was cleared at
975
+ // dispatch, so nothing could rescue it, which is the #278 signature), and because
976
+ // processWriteQueue is invoked unawaited from scheduleWriteQueueDrain the rejection escaped
977
+ // as an unhandledRejection that the allowlist does not cover, so src/index.ts called
978
+ // process.exit(1). A stalled upstream download would have taken the whole server down and
979
+ // every other tenant with it.
980
+ //
981
+ // Reject the batch here instead. The operations never ran and nothing was written, so this
982
+ // is the same contract queueWriteOperation's residency rejection uses.
983
+ logger.error('[WRITE QUEUE] Could not acquire the api lock for this batch:', lockError);
984
+ batch.forEach(({ reject }) => {
985
+ try {
986
+ reject(lockError);
987
+ }
988
+ catch (e) {
989
+ logger.error('[WRITE QUEUE] Error rejecting operation:', e);
990
+ }
991
+ });
992
+ }
849
993
  finally {
850
994
  isProcessingWrites = false;
851
995
  // #278: ALWAYS re-drain a non-empty queue. The old `&& writeSessionTimeout === null`
@@ -862,7 +1006,11 @@ function queueWriteOperation(operation, options) {
862
1006
  // op is never enqueued and no upstream resource is touched.
863
1007
  _enforceBudgetAcl();
864
1008
  // Capture sessionId from AsyncLocalStorage at enqueue time. The setTimeout
865
- // below strips the ALS frame, so without capturing here the pool-branch
1009
+ // below does NOT strip the ALS frame (that claim was here for a long time and is false:
1010
+ // ALS propagates through timers). What it does is inherit the context of whichever session
1011
+ // most recently SCHEDULED the drain, which is the last enqueuer in the debounce window and
1012
+ // has nothing to do with the op being run. So capturing per entry is still exactly right,
1013
+ // and #390 additionally re-enters the captured store per op. Without capturing, the pool-branch
866
1014
  // decision in processWriteQueue would always miss. See #158.
867
1015
  const sessionId = _resolveSessionId();
868
1016
  return new Promise((resolve, reject) => {
@@ -871,6 +1019,7 @@ function queueWriteOperation(operation, options) {
871
1019
  resolve,
872
1020
  reject,
873
1021
  sessionId,
1022
+ requestStore: requestContext.getStore(),
874
1023
  preservesListings: options?.preservesListings,
875
1024
  };
876
1025
  // #278: bound queue RESIDENCY, not just execution. #270's withOpTimeout bounds an
@@ -2512,14 +2661,7 @@ export async function switchBudget(name) {
2512
2661
  else {
2513
2662
  await withApiLock(async () => {
2514
2663
  // 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
- }
2664
+ await loadBudgetTracked(found.syncId, found.encryptionPassword);
2523
2665
  });
2524
2666
  }
2525
2667
  connectionPool.updateLoadedSyncId(sessionId, found.syncId);
@@ -2551,8 +2693,9 @@ export async function switchBudget(name) {
2551
2693
  // init/shutdown cycle. A pooled entry for stdio would be created here and then
2552
2694
  // NEVER refreshed, because connectionPool.touch() is called only from
2553
2695
  // httpServer.ts; it would expire after SESSION_IDLE_TIMEOUT_MINUTES and
2554
- // cleanupIdleConnections would api.shutdown() it WITHOUT holding withApiLock,
2555
- // which can tear the singleton down underneath an in-flight operation. The
2696
+ // cleanupIdleConnections would tear it down. Since #392 that teardown takes the api mutex and
2697
+ // re-checks expiry, so it is no longer a mid-operation hazard, but an entry that is never
2698
+ // refreshed still expires, and that is the reason that matters here. The
2556
2699
  // legacy path re-reads getActiveBudgetConfig() on every call, so the switch
2557
2700
  // still takes effect; it just costs an init per operation, which is what stdio
2558
2701
  // already did before this ticket.
@@ -2741,11 +2884,30 @@ export async function exportBudget() {
2741
2884
  export async function importBudget(input, opts = {}) {
2742
2885
  observability.incrementToolCall('actual.budgets.import').catch(() => { });
2743
2886
  const result = await queueWriteOperation(async () => {
2744
- return await withConcurrency(() => retry(() => rawImportBudget(input, opts), {
2745
- retries: 0,
2746
- backoffMs: 200,
2747
- isRetryable: isRetryableError,
2748
- }));
2887
+ // #394: an import re-points the process-global singleton exactly as a download does, so it
2888
+ // gets the SAME tracking discipline, through the same helper, rather than a hand-written
2889
+ // approximation of it.
2890
+ //
2891
+ // What was wrong before: nothing cleared the record before the import started, nothing
2892
+ // registered the promise, and the sentinel was written AFTER the await, so a timeout skipped
2893
+ // it entirely. A large YNAB or zip import exceeding ACTUAL_OP_TIMEOUT_MS is the expected case,
2894
+ // not a rare one. The record therefore went on naming the PRE-IMPORT budget while the
2895
+ // singleton moved to an out-of-registry, un-ACL'd imported file, and a victim session passed
2896
+ // the #390 precondition legitimately and had its reads served from the importer's file.
2897
+ //
2898
+ // #390 still holds at this call site: `importBudgetTracked` records the `imported:` sentinel
2899
+ // when the promise settles, whichever way, and the clear below states the same fact at the
2900
+ // call site where the guard in tests/unit/budget_selection_precondition.test.js can see it.
2901
+ const imported = await importBudgetTracked(() => {
2902
+ const started = withConcurrency(() => retry(() => rawImportBudget(input, opts), {
2903
+ retries: 0,
2904
+ backoffMs: 200,
2905
+ isRetryable: isRetryableError,
2906
+ }));
2907
+ setLoadedBudgetSyncId(null);
2908
+ return started;
2909
+ });
2910
+ return imported;
2749
2911
  });
2750
2912
  // #349: an import CHANGES WHICH BUDGET IS LOADED, so the pool's record of it
2751
2913
  // 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
  }