actual-mcp-server 0.16.1 → 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 +1 -1
- package/dist/src/actualConnection.js +36 -13
- package/dist/src/lib/ActualConnectionPool.js +70 -42
- package/dist/src/lib/actual-adapter.js +390 -188
- package/dist/src/lib/apiLock.js +60 -0
- package/dist/src/lib/apiState.js +107 -0
- package/dist/src/lib/budgetLoader.js +295 -0
- package/package.json +2 -2
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.
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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,
|