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
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.6 | **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,
|
|
@@ -358,7 +386,65 @@ class ActualConnectionPool {
|
|
|
358
386
|
* the connection: in those cases the transport must survive.
|
|
359
387
|
*/
|
|
360
388
|
async shutdownConnection(sessionId, opts = {}) {
|
|
389
|
+
// #392: acquire the api mutex, because this calls api.shutdown() on the PROCESS-GLOBAL
|
|
390
|
+
// singleton. Without it, the idle sweep or an explicit session close can tear the api down
|
|
391
|
+
// while ANOTHER session's operation is in flight inside the lock, and that operation then
|
|
392
|
+
// observes a torn-down api. Reproduced during #390's security review for the sibling cleanup
|
|
393
|
+
// site, which #390 fixed; these were the paths it left behind.
|
|
394
|
+
//
|
|
395
|
+
// NOT reentrant, so this variant is for callers that do NOT hold the lock. Callers that DO
|
|
396
|
+
// must call shutdownConnectionLocked directly; wrapping there would deadlock, and the symptom
|
|
397
|
+
// is a ~30s stall ended by #270's timeout rather than an obvious hang. The audit of which is
|
|
398
|
+
// which is recorded on shutdownConnectionLocked.
|
|
399
|
+
// #392 review (BLOCKING): the acquisition itself can REJECT. Since #393, taking the api lock
|
|
400
|
+
// settles any abandoned budget load and THROWS on timeout, so this wrapper gained a rejection
|
|
401
|
+
// path the old body never had (it was try/catch/finally end to end). Its callers are cleanup
|
|
402
|
+
// paths that do not expect one: `cleanupIdleConnections` is invoked from `setInterval`
|
|
403
|
+
// unawaited, and "abandoned budget load timed out" is not in `rejection-allowlist.ts`, so the
|
|
404
|
+
// escape reached `src/index.ts`'s unhandledRejection handler and called process.exit(1). That
|
|
405
|
+
// is the exact defect #393 fixed one level up in the write drain, reintroduced at a new
|
|
406
|
+
// acquisition site.
|
|
407
|
+
//
|
|
408
|
+
// So the acquisition is swallowed, matching the established pattern in the session-open
|
|
409
|
+
// cleanup above: cleanup must not fail louder than the thing it is cleaning up after.
|
|
410
|
+
// The entry is still torn down on that path, unless the caller asked for expiry to be
|
|
411
|
+
// re-checked and the session came back, which is the sweep's own contract. Tearing down
|
|
412
|
+
// matters because a stuck load must not make idle sessions un-evictable: the pool would fill
|
|
413
|
+
// to MAX_CONCURRENT_SESSIONS and start refusing new sessions with nothing in the log saying
|
|
414
|
+
// why. The unlocked shutdown in this handler is acceptable for a specific reason worth
|
|
415
|
+
// stating: while a registration is stuck, EVERY lock acquisition throws, so no operation can
|
|
416
|
+
// be running inside the lock at the moment this path fires.
|
|
417
|
+
await withApiLock(() => this.shutdownConnectionLocked(sessionId, opts)).catch(async (err) => {
|
|
418
|
+
logger.error(`[ConnectionPool] Could not acquire the api lock to shut down ${sessionId}; ` +
|
|
419
|
+
`tearing the entry down without it: ${err instanceof Error ? err.message : String(err)}`);
|
|
420
|
+
await this.shutdownConnectionLocked(sessionId, opts).catch(() => { });
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* The body of shutdownConnection, for callers that ALREADY hold the api mutex.
|
|
425
|
+
*
|
|
426
|
+
* #392 call-site audit, done explicitly rather than by inspection because the mutex is not
|
|
427
|
+
* reentrant and getting it wrong produces a stall that reads as a slow upstream server:
|
|
428
|
+
*
|
|
429
|
+
* HOLDS the lock, so calls this variant:
|
|
430
|
+
* - actual-adapter withActualApi pooled branch, infra-error drop
|
|
431
|
+
* - actual-adapter withActualApiWrite, sync failure and infra-error drop
|
|
432
|
+
* - actual-adapter processWriteQueue drain, sync failure and batch error
|
|
433
|
+
* Does NOT hold it, so calls the wrapping shutdownConnection:
|
|
434
|
+
* - actual-adapter switchBudget slow path (its own withApiLock block has closed by then)
|
|
435
|
+
* - actualConnection.shutdownActualForSession (session close)
|
|
436
|
+
* - this.cleanupIdleConnections (the idle sweep)
|
|
437
|
+
* - this.shutdownAll
|
|
438
|
+
*/
|
|
439
|
+
async shutdownConnectionLocked(sessionId, opts = {}) {
|
|
361
440
|
const conn = this.connections.get(sessionId);
|
|
441
|
+
// #392 review: the idle sweep decides who is expired BEFORE waiting for the mutex, and that
|
|
442
|
+
// wait is no longer negligible. Re-checking here means a session that was touched while we
|
|
443
|
+
// queued keeps its connection instead of being closed mid-conversation.
|
|
444
|
+
if (opts.onlyIfExpired && conn && !this.isExpired(conn)) {
|
|
445
|
+
logger.debug(`[ConnectionPool] ${sessionId} was touched while waiting for the api lock; not evicting`);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
362
448
|
if (!conn || !conn.initialized) {
|
|
363
449
|
return;
|
|
364
450
|
}
|
|
@@ -396,6 +482,16 @@ class ActualConnectionPool {
|
|
|
396
482
|
* Shutdown the shared connection
|
|
397
483
|
*/
|
|
398
484
|
async shutdownSharedConnection() {
|
|
485
|
+
// #392: same reasoning as shutdownConnection. Its only caller is shutdownAll, which does not
|
|
486
|
+
// hold the mutex.
|
|
487
|
+
await withApiLock(() => this.shutdownSharedConnectionLocked()).catch(async (err) => {
|
|
488
|
+
logger.error(`[ConnectionPool] Could not acquire the api lock to shut down the shared connection; ` +
|
|
489
|
+
`tearing it down without it: ${err instanceof Error ? err.message : String(err)}`);
|
|
490
|
+
await this.shutdownSharedConnectionLocked().catch(() => { });
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
/** The body of shutdownSharedConnection, for callers that already hold the api mutex. */
|
|
494
|
+
async shutdownSharedConnectionLocked() {
|
|
399
495
|
if (!this.sharedConnection?.initialized) {
|
|
400
496
|
return;
|
|
401
497
|
}
|
|
@@ -472,7 +568,15 @@ class ActualConnectionPool {
|
|
|
472
568
|
// in the same window (#167). This is the path that previously drifted:
|
|
473
569
|
// the pool's autonomous timer removed an entry httpServer's separate
|
|
474
570
|
// timer knew nothing about.
|
|
475
|
-
|
|
571
|
+
//
|
|
572
|
+
// #392 review: RE-CHECK expiry, because the decision above was taken before the lock and
|
|
573
|
+
// the eviction now waits for it. Before #392 that wait was effectively zero; since #378
|
|
574
|
+
// made drains sequential, one lock hold is the SUM of a batch's operations, so it is
|
|
575
|
+
// bounded only by batch length times ACTUAL_OP_TIMEOUT_MS. A session that was idle when
|
|
576
|
+
// marked can be active again by the time the lock arrives, and evicting it then closes
|
|
577
|
+
// its transport mid-conversation. Deciding and acting on the same side of the mutex is
|
|
578
|
+
// what makes the decision mean anything.
|
|
579
|
+
await this.shutdownConnection(sessionId, { evict: true, onlyIfExpired: true });
|
|
476
580
|
}
|
|
477
581
|
}
|
|
478
582
|
}
|