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 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.1 | **Tool Count:** 74 (verified LibreChat-compatible)
777
+ **Version:** 0.16.5 | **Tool Count:** 74 (verified LibreChat-compatible)
778
778
 
779
779
  ## Sponsor
780
780
 
@@ -2,6 +2,9 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
4
  import api from '@actual-app/api';
5
+ import { loadBudgetTracked } from './lib/budgetLoader.js';
6
+ import { withApiLock } from './lib/apiLock.js';
7
+ import { withOpTimeout } from './lib/opTimeout.js';
5
8
  import logger from './logger.js';
6
9
  import config from './config.js';
7
10
  import { connectionPool } from './lib/ActualConnectionPool.js';
@@ -24,6 +27,11 @@ export async function connectToActual() {
24
27
  return;
25
28
  }
26
29
  initializing = true;
30
+ // #396: reset before each attempt. It was set on failure and NEVER cleared, so once the
31
+ // silent-success bug is fixed and a bad load throws routinely, a single failure would poison
32
+ // every later session open for the process lifetime: a concurrent caller arriving while
33
+ // `initializing` is true rethrows whatever was left here.
34
+ initializationError = null;
27
35
  try {
28
36
  const SERVER_URL = config.ACTUAL_SERVER_URL;
29
37
  const PASSWORD = config.ACTUAL_PASSWORD;
@@ -36,20 +44,35 @@ export async function connectToActual() {
36
44
  if (!fs.existsSync(DATA_DIR))
37
45
  fs.mkdirSync(DATA_DIR, { recursive: true });
38
46
  log.info(`Initializing Actual API with dataDir=${DATA_DIR}`);
39
- await api.init({
40
- dataDir: DATA_DIR,
41
- serverURL: SERVER_URL,
42
- password: PASSWORD,
47
+ // #396: this is the SIXTH budget load site, and it is production reachable. Revision 0 of
48
+ // that ticket claimed it was `--test-actual-connection` only; it is not.
49
+ // `connectToActualForSession` below falls through to this function whenever
50
+ // USE_CONNECTION_POOL=false (a documented, supported variable), and httpServer calls it on
51
+ // every HTTP session open. It used to hand-write the download and record the loaded syncId
52
+ // UNCONDITIONALLY, so it had none of the guarantees the other five sites have.
53
+ //
54
+ // The lock is acquired HERE, around init plus load only, mirroring
55
+ // ActualConnectionPool.getConnection. Never around the `while (initializing)` spin-wait
56
+ // above: that would hold the mutex while waiting for its holder and deadlock. No caller of
57
+ // this function holds the api lock, so this acquisition is safe.
58
+ //
59
+ // This is NOT behaviour neutral, and that is intended: the download becomes bounded by
60
+ // ACTUAL_OP_TIMEOUT_MS with abandon-not-cancel semantics, and becomes a registered load that
61
+ // later lock acquisitions wait for. Both match what the pool sites already do.
62
+ await withApiLock(async () => {
63
+ // #396 review: BOUND the init. It was unbounded here before, which was survivable only
64
+ // because this call held no lock. Now that it does, an unbounded stalled login would never
65
+ // settle, `.finally(release)` would never run, and the process-global api mutex would be
66
+ // wedged permanently for every session: the exact #270 class. Both pool sites already bound
67
+ // their `api.init` this way, and this was the only `api.init` in the repo that did not.
68
+ await withOpTimeout(() => api.init({
69
+ dataDir: DATA_DIR,
70
+ serverURL: SERVER_URL,
71
+ password: PASSWORD,
72
+ }), 'connectToActual init');
73
+ log.info(`Downloading budget with sync ID: ${BUDGET_SYNC_ID}`);
74
+ await loadBudgetTracked(BUDGET_SYNC_ID, BUDGET_PASSWORD);
43
75
  });
44
- log.info(`Downloading budget with sync ID: ${BUDGET_SYNC_ID}`);
45
- // According to official docs, downloadBudget accepts optional second parameter for E2E encryption
46
- if (BUDGET_PASSWORD) {
47
- const apiWithOptions = api;
48
- await apiWithOptions.downloadBudget(BUDGET_SYNC_ID, { password: BUDGET_PASSWORD });
49
- }
50
- else {
51
- await api.downloadBudget(BUDGET_SYNC_ID);
52
- }
53
76
  if (TEST_ACTUAL_CONNECTION) {
54
77
  logger.info('Test flag detected (--test-actual-connection) — closing Actual session.');
55
78
  // Prefer the documented shutdown method only
@@ -17,6 +17,8 @@ import path from 'path';
17
17
  import os from 'os';
18
18
  import fs from 'fs';
19
19
  import { isApiInitialized, setApiInitialized } from './apiState.js';
20
+ import { withApiLock } from './apiLock.js';
21
+ import { loadBudgetTracked } from './budgetLoader.js';
20
22
  import { withOpTimeout } from './opTimeout.js';
21
23
  const DEFAULT_DATA_DIR = path.resolve(os.homedir() || '.', '.actual');
22
24
  class ActualConnectionPool {
@@ -237,27 +239,40 @@ class ActualConnectionPool {
237
239
  fs.mkdirSync(DATA_DIR, { recursive: true });
238
240
  }
239
241
  try {
240
- // Bound the session-open init/download (#270): this is the HTTP exposure.
241
- // A stalled upstream here would otherwise hang session open unbounded and,
242
- // because the api singleton is process-global, wedge other sessions too.
243
- await withOpTimeout(() => api.init({
244
- dataDir: DATA_DIR,
245
- serverURL: SERVER_URL,
246
- password: PASSWORD,
247
- }), 'pool init');
248
- logger.info(`[ConnectionPool] Downloading budget for session: ${sessionId}`);
249
- if (BUDGET_PASSWORD) {
250
- const apiWithOptions = api;
251
- await withOpTimeout(() => apiWithOptions.downloadBudget(BUDGET_SYNC_ID, { password: BUDGET_PASSWORD }), 'pool downloadBudget');
252
- }
253
- else {
254
- await withOpTimeout(() => api.downloadBudget(BUDGET_SYNC_ID), 'pool downloadBudget');
255
- }
256
- // Mark the singleton as live only AFTER the budget is downloaded (#270):
257
- // setting it before downloadBudget left a poisoning window where the
258
- // adapter's pool-cooperation branch could reuse a connection whose budget
259
- // never loaded (a stalled/failed download would leak isApiInitialized=true).
260
- setApiInitialized(true);
242
+ // #390 round 2: hold the api mutex across init + download.
243
+ //
244
+ // Recording the loaded budget and checking it inside the lock only NARROWED the race
245
+ // while this mutator stayed outside it. Reproduced: a pooled read passed the
246
+ // precondition, parked at an await still holding the lock, a new session opened here on
247
+ // a different budget, and the first session's raw call then executed against it. The
248
+ // window is not small: it is the whole duration of an operation, and since #378 made the
249
+ // drain sequential, the whole duration of a batch, with one check at the start.
250
+ //
251
+ // Safe against the non-reentrant mutex: the two callers of getConnection
252
+ // (actualConnection's session-open helper and switchBudget's slow path) are both OUTSIDE
253
+ // any withApiLock block. switchBudget's own downloadBudget takes the lock separately and
254
+ // has already released it before it reaches here.
255
+ await withApiLock(async () => {
256
+ // Bound the session-open init/download (#270): this is the HTTP exposure.
257
+ // A stalled upstream here would otherwise hang session open unbounded and,
258
+ // because the api singleton is process-global, wedge other sessions too.
259
+ await withOpTimeout(() => api.init({
260
+ dataDir: DATA_DIR,
261
+ serverURL: SERVER_URL,
262
+ password: PASSWORD,
263
+ }), 'pool init');
264
+ logger.info(`[ConnectionPool] Downloading budget for session: ${sessionId}`);
265
+ // #390: the shared tracked loader, not a hand-written download. It clears the record
266
+ // BEFORE starting and keeps an abandoned load registered, so a session-open download that
267
+ // outruns ACTUAL_OP_TIMEOUT_MS cannot silently re-point the singleton later.
268
+ await loadBudgetTracked(BUDGET_SYNC_ID, BUDGET_PASSWORD, 'pool downloadBudget');
269
+ // Mark the singleton as live only AFTER the budget is downloaded (#270):
270
+ // setting it before downloadBudget left a poisoning window where the
271
+ // adapter's pool-cooperation branch could reuse a connection whose budget
272
+ // never loaded (a stalled/failed download would leak isApiInitialized=true).
273
+ setApiInitialized(true);
274
+ }); // #390: end of the api-mutex block. Everything after this is pool bookkeeping and
275
+ // touches no singleton state, so it does not need the lock.
261
276
  conn = {
262
277
  sessionId,
263
278
  initialized: true,
@@ -276,17 +291,29 @@ class ActualConnectionPool {
276
291
  catch (err) {
277
292
  logger.error(`[ConnectionPool] Failed to initialize connection for session ${sessionId}:`, err);
278
293
  // Clean up the failed connection attempt
279
- // Try to shutdown the API to leave it in a clean state for the next attempt
280
- try {
294
+ // Try to shutdown the API to leave it in a clean state for the next attempt.
295
+ //
296
+ // #390 round 3: UNDER THE MUTEX, like the init above it. Locking the mutator and leaving
297
+ // its compensating mutator unlocked closes nothing: reproduced as this cleanup's
298
+ // api.shutdown() firing while ANOTHER session's operation was in flight inside the lock,
299
+ // so that operation observed a torn-down api. The lock is re-acquired rather than held
300
+ // across the whole try, because the init block released it on the way out and this
301
+ // function is never called from inside a lock (both callers are outside one).
302
+ // #393 review: the lock ITSELF can reject now (it settles an abandoned budget load on
303
+ // acquisition), and this is a CLEANUP path. If that rejection escaped, `setApiInitialized(false)`
304
+ // and the connections.delete below would be skipped and the caller would receive
305
+ // "abandoned budget load timed out" instead of the real session-open failure. Cleanup
306
+ // must not be able to fail louder than the thing it is cleaning up after, so the whole
307
+ // acquisition is swallowed here, not just the shutdown call.
308
+ await withApiLock(async () => {
281
309
  const maybeApi = api;
282
310
  if (typeof maybeApi.shutdown === 'function') {
283
311
  await maybeApi.shutdown();
284
312
  logger.debug(`[ConnectionPool] Cleaned up failed connection for session: ${sessionId}`);
285
313
  }
286
- }
287
- catch (cleanupErr) {
314
+ }).catch((cleanupErr) => {
288
315
  logger.debug(`[ConnectionPool] Error during cleanup (ignoring): ${cleanupErr}`);
289
- }
316
+ });
290
317
  // Singleton is back to torn-down state regardless of cleanup outcome.
291
318
  setApiInitialized(false);
292
319
  // Ensure this session is not in the connections map
@@ -312,22 +339,23 @@ class ActualConnectionPool {
312
339
  fs.mkdirSync(DATA_DIR, { recursive: true });
313
340
  }
314
341
  try {
315
- // Bound the session-open init/download (#270), same as getConnection.
316
- await withOpTimeout(() => api.init({
317
- dataDir: DATA_DIR,
318
- serverURL: SERVER_URL,
319
- password: PASSWORD,
320
- }), 'pool init');
321
- if (BUDGET_PASSWORD) {
322
- const apiWithOptions = api;
323
- await withOpTimeout(() => apiWithOptions.downloadBudget(BUDGET_SYNC_ID, { password: BUDGET_PASSWORD }), 'pool downloadBudget');
324
- }
325
- else {
326
- await withOpTimeout(() => api.downloadBudget(BUDGET_SYNC_ID), 'pool downloadBudget');
327
- }
328
- // Mark live only after a successful download (#270): avoids the poisoning
329
- // window where a stalled/failed download would leak isApiInitialized=true.
330
- setApiInitialized(true);
342
+ // #390 round 2: same reasoning as getConnection above; this mutates the same
343
+ // process-global singleton, so it takes the same mutex.
344
+ await withApiLock(async () => {
345
+ // Bound the session-open init/download (#270), same as getConnection.
346
+ await withOpTimeout(() => api.init({
347
+ dataDir: DATA_DIR,
348
+ serverURL: SERVER_URL,
349
+ password: PASSWORD,
350
+ }), 'pool init');
351
+ // #390: the shared tracked loader, not a hand-written download. It clears the record
352
+ // BEFORE starting and keeps an abandoned load registered, so a session-open download that
353
+ // outruns ACTUAL_OP_TIMEOUT_MS cannot silently re-point the singleton later.
354
+ await loadBudgetTracked(BUDGET_SYNC_ID, BUDGET_PASSWORD, 'pool downloadBudget');
355
+ // Mark live only after a successful download (#270): avoids the poisoning
356
+ // window where a stalled/failed download would leak isApiInitialized=true.
357
+ setApiInitialized(true);
358
+ }); // #390: end of the api-mutex block.
331
359
  this.sharedConnection = {
332
360
  sessionId: 'shared',
333
361
  initialized: true,