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.
- package/README.md +1 -1
- package/dist/src/actualConnection.js +36 -13
- package/dist/src/lib/ActualConnectionPool.js +147 -43
- package/dist/src/lib/actual-adapter.js +204 -42
- package/dist/src/lib/apiLock.js +60 -0
- package/dist/src/lib/apiState.js +107 -0
- package/dist/src/lib/budgetLoader.js +399 -0
- package/package.json +2 -2
|
@@ -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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
// singleton down
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
2555
|
-
//
|
|
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
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
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
|
+
}
|
package/dist/src/lib/apiState.js
CHANGED
|
@@ -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
|
}
|