actual-mcp-server 0.16.5 → 0.16.7
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 +2 -1
- package/dist/src/config.js +28 -0
- package/dist/src/lib/ActualConnectionPool.js +77 -1
- package/dist/src/lib/actual-adapter.js +97 -30
- package/dist/src/lib/apiState.js +14 -0
- package/dist/src/lib/budgetLoader.js +164 -31
- package/dist/src/lib/opTimeout.js +13 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -476,6 +476,7 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
|
|
|
476
476
|
| `ACTUAL_BUDGET_PASSWORD` | _(none)_ | No | Optional encryption password for encrypted budgets |
|
|
477
477
|
| `ALLOW_INSECURE_UPSTREAM` | `false` | No | Allow an `http://` upstream even when `ACTUAL_BUDGET_PASSWORD` is set (#161). Off by default so a plaintext upstream plus an encryption password is refused |
|
|
478
478
|
| `ACTUAL_OP_TIMEOUT_MS` | `30000` | No | Per-operation timeout (ms) for every upstream Actual API call (init, budget download, sync, and each tool operation). A stalled call rejects after this bound so it cannot hold the global API mutex forever and hang subsequent tool calls (#270). Set to `0` to disable |
|
|
479
|
+
| `ACTUAL_IMPORT_TIMEOUT_MS` | `600000` | No | Separate, larger timeout (ms) for a budget import, which is legitimately long rather than stalled. An import is a tracked load, so every other session waits on it; bounding it at the general operation timeout turned one tenant's large import into a process-wide stall (#407). Set to `0` to disable |
|
|
479
480
|
| **MCP Server Settings** ||||
|
|
480
481
|
| `MCP_BRIDGE_PORT` | `3600` | No | Port for MCP server to listen on |
|
|
481
482
|
| `MCP_BRIDGE_BIND_HOST` | `0.0.0.0` | No | Host address to bind server to (`0.0.0.0` = all interfaces) |
|
|
@@ -774,7 +775,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
774
775
|
|
|
775
776
|
---
|
|
776
777
|
|
|
777
|
-
**Version:** 0.16.
|
|
778
|
+
**Version:** 0.16.7 | **Tool Count:** 74 (verified LibreChat-compatible)
|
|
778
779
|
|
|
779
780
|
## Sponsor
|
|
780
781
|
|
package/dist/src/config.js
CHANGED
|
@@ -30,6 +30,34 @@ export const configSchema = z.object({
|
|
|
30
30
|
return 0;
|
|
31
31
|
return Math.min(Math.max(n, 250), 2147483647);
|
|
32
32
|
}),
|
|
33
|
+
/**
|
|
34
|
+
* #407: a separate, larger bound for a budget IMPORT.
|
|
35
|
+
*
|
|
36
|
+
* An import is not a stalled operation, it is a legitimately long one: a large YNAB or zip
|
|
37
|
+
* import runs for minutes. #394 made it a TRACKED load, so while it is in flight every other
|
|
38
|
+
* session's lock acquisition waits for it and then fails at ACTUAL_OP_TIMEOUT_MS. Bounding a
|
|
39
|
+
* five-minute import at the 30 second operation default therefore turned one tenant's import
|
|
40
|
+
* into a process-wide stall, with every other session failing repeatedly for the duration.
|
|
41
|
+
*
|
|
42
|
+
* Same parse-and-clamp shape as ACTUAL_OP_TIMEOUT_MS, and the same escape hatch: 0 disables the
|
|
43
|
+
* bound, which for an import means it can hold the process-global api mutex indefinitely (the
|
|
44
|
+
* #393 P0 shape), so it is for diagnosis rather than production.
|
|
45
|
+
*
|
|
46
|
+
* The clamp floor is 250ms, the same as the operation timeout. It is deliberately NOT raised to
|
|
47
|
+
* the operation timeout's configured VALUE: an earlier comment here claimed an import bound below
|
|
48
|
+
* the general one was prevented, and it was not, so the claim is removed rather than left to
|
|
49
|
+
* mislead. A smaller import bound is unusual but coherent (abandon imports sooner than ordinary
|
|
50
|
+
* reads), and enforcing an ordering between two independently configured knobs at parse time
|
|
51
|
+
* would need one to be parsed before the other.
|
|
52
|
+
*/
|
|
53
|
+
ACTUAL_IMPORT_TIMEOUT_MS: z.string().default('600000').transform(val => {
|
|
54
|
+
const n = parseInt(val, 10);
|
|
55
|
+
if (!Number.isFinite(n) || n < 0)
|
|
56
|
+
return 600000;
|
|
57
|
+
if (n === 0)
|
|
58
|
+
return 0;
|
|
59
|
+
return Math.min(Math.max(n, 250), 2147483647);
|
|
60
|
+
}),
|
|
33
61
|
// Escape hatch for #161: allow an http:// upstream even when an E2E encryption
|
|
34
62
|
// password is set (e.g. an isolated Docker network where the hop is trusted).
|
|
35
63
|
// Off by default so a plaintext upstream + encryption password is refused.
|
|
@@ -386,7 +386,65 @@ class ActualConnectionPool {
|
|
|
386
386
|
* the connection: in those cases the transport must survive.
|
|
387
387
|
*/
|
|
388
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 = {}) {
|
|
389
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
|
+
}
|
|
390
448
|
if (!conn || !conn.initialized) {
|
|
391
449
|
return;
|
|
392
450
|
}
|
|
@@ -424,6 +482,16 @@ class ActualConnectionPool {
|
|
|
424
482
|
* Shutdown the shared connection
|
|
425
483
|
*/
|
|
426
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() {
|
|
427
495
|
if (!this.sharedConnection?.initialized) {
|
|
428
496
|
return;
|
|
429
497
|
}
|
|
@@ -500,7 +568,15 @@ class ActualConnectionPool {
|
|
|
500
568
|
// in the same window (#167). This is the path that previously drifted:
|
|
501
569
|
// the pool's autonomous timer removed an entry httpServer's separate
|
|
502
570
|
// timer knew nothing about.
|
|
503
|
-
|
|
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 });
|
|
504
580
|
}
|
|
505
581
|
}
|
|
506
582
|
}
|
|
@@ -21,9 +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, getLoadedBudgetSyncId,
|
|
24
|
+
import { isApiInitialized, setApiInitialized, getLoadedBudgetSyncId, awaitAbandonedBudgetLoad } from './apiState.js';
|
|
25
25
|
import { withApiLock } from './apiLock.js';
|
|
26
|
-
import { loadBudgetTracked } from './budgetLoader.js';
|
|
26
|
+
import { loadBudgetTracked, importBudgetTracked } from './budgetLoader.js';
|
|
27
27
|
/**
|
|
28
28
|
* Budget registry — all budgets configured via ACTUAL_* and BUDGET_n_* env vars.
|
|
29
29
|
* Built once at startup; used by every withActualApi call.
|
|
@@ -347,8 +347,9 @@ export async function withActualApi(rawOperation) {
|
|
|
347
347
|
// re-introduce the auth-burst pattern #134 is fixing.
|
|
348
348
|
if (_shouldDropPoolOnError(err)) {
|
|
349
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.
|
|
350
351
|
try {
|
|
351
|
-
await connectionPool.
|
|
352
|
+
await connectionPool.shutdownConnectionLocked(sessionId);
|
|
352
353
|
}
|
|
353
354
|
catch (_e) { /* swallow */ }
|
|
354
355
|
}
|
|
@@ -407,8 +408,9 @@ export async function withActualApiWrite(operation) {
|
|
|
407
408
|
// Sync failure on a write IS infrastructure-level — drop the pool
|
|
408
409
|
// connection so the next call re-inits, then surface the error.
|
|
409
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.
|
|
410
412
|
try {
|
|
411
|
-
await connectionPool.
|
|
413
|
+
await connectionPool.shutdownConnectionLocked(sessionId);
|
|
412
414
|
}
|
|
413
415
|
catch (_e) { /* swallow */ }
|
|
414
416
|
throw syncErr;
|
|
@@ -421,8 +423,9 @@ export async function withActualApiWrite(operation) {
|
|
|
421
423
|
// leave the connection fine.
|
|
422
424
|
if (_shouldDropPoolOnError(err)) {
|
|
423
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.
|
|
424
427
|
try {
|
|
425
|
-
await connectionPool.
|
|
428
|
+
await connectionPool.shutdownConnectionLocked(sessionId);
|
|
426
429
|
}
|
|
427
430
|
catch (_e) { /* swallow */ }
|
|
428
431
|
}
|
|
@@ -827,7 +830,7 @@ async function processWriteQueue() {
|
|
|
827
830
|
//
|
|
828
831
|
// Each op keeps its own withOpTimeout (#270) and still settles individually, so one
|
|
829
832
|
// stalled or rejected operation cannot hold the api mutex or fail its siblings.
|
|
830
|
-
for (const { operation, resolve, reject, preservesListings, requestStore } of batch) {
|
|
833
|
+
for (const { operation, resolve, reject, preservesListings, requestStore, timeoutMs } of batch) {
|
|
831
834
|
try {
|
|
832
835
|
// #390 round 2: run each op in the context it was ENQUEUED in.
|
|
833
836
|
//
|
|
@@ -844,8 +847,40 @@ async function processWriteQueue() {
|
|
|
844
847
|
// been correct wrong. Re-entering the captured store makes each op resolve its own
|
|
845
848
|
// budget, which is what #158 captured a per-entry sessionId for in the first place.
|
|
846
849
|
const result = await requestContext.run(requestStore ?? {}, async () => {
|
|
850
|
+
// #406: settle any abandoned budget load PER OPERATION, not per lock acquisition.
|
|
851
|
+
//
|
|
852
|
+
// #393 made the wait part of acquiring the api lock, which makes the set of call
|
|
853
|
+
// sites stop mattering: nothing reaches the api without the lock, so nothing reaches
|
|
854
|
+
// it without the wait. That holds for every path where one acquisition serves one
|
|
855
|
+
// operation. It does NOT hold here. This drain acquires the lock ONCE and then runs
|
|
856
|
+
// N operations inside it, so once any op abandons a load, every LATER op in the same
|
|
857
|
+
// batch runs against a singleton that abandoned load will re-point, having never
|
|
858
|
+
// waited for it. Reproduced with a slow import and a following write in one debounce
|
|
859
|
+
// window; it is the #390 class with a new author.
|
|
860
|
+
//
|
|
861
|
+
// Deliberately NOT inside ensureLoadedBudgetMatchesSession: that returns early when
|
|
862
|
+
// the loaded budget already matches, and an op whose budget happens to match still
|
|
863
|
+
// needs the wait, because the abandoned load is about to move the singleton AWAY
|
|
864
|
+
// from it. The wait has to be unconditional, so it sits before the precondition.
|
|
865
|
+
//
|
|
866
|
+
// A rejection here is caught by this loop's own per-op catch, so a stuck load fails
|
|
867
|
+
// ONE operation rather than the whole batch, which is the behaviour #393 round 4
|
|
868
|
+
// established for the drain.
|
|
869
|
+
await awaitAbandonedBudgetLoad(withOpTimeout);
|
|
847
870
|
await ensureLoadedBudgetMatchesSession();
|
|
848
|
-
|
|
871
|
+
// #407: an operation may carry its OWN bound. Only a budget import does today, and
|
|
872
|
+
// it needs one: the import is legitimately long, and bounding it at the general
|
|
873
|
+
// operation timeout abandons it while every other session is already waiting on it
|
|
874
|
+
// as a tracked load. Without this the raised bound inside the loader is dead letter,
|
|
875
|
+
// because THIS wrapper would abandon the op first.
|
|
876
|
+
//
|
|
877
|
+
// A long op does NOT need a keep-alive heartbeat, and one was written and removed in
|
|
878
|
+
// review. The session cannot be swept out from under it: `cleanupIdleConnections`
|
|
879
|
+
// passes `onlyIfExpired`, so `shutdownConnectionLocked` re-checks expiry AFTER
|
|
880
|
+
// acquiring the mutex (#392), and the `finally` below touches every session in the
|
|
881
|
+
// batch before the lock is released. A sweep that queued behind this drain therefore
|
|
882
|
+
// finds the entry fresh and returns without evicting.
|
|
883
|
+
return await withOpTimeout(operation, 'operation', timeoutMs);
|
|
849
884
|
});
|
|
850
885
|
resolve(result);
|
|
851
886
|
}
|
|
@@ -871,10 +906,12 @@ async function processWriteQueue() {
|
|
|
871
906
|
// the batch ran concurrently and a drain was bounded by roughly one
|
|
872
907
|
// ACTUAL_OP_TIMEOUT_MS. Sequential dispatch makes a drain's wall clock the SUM of
|
|
873
908
|
// its ops, so a batch of slow ops can now outlive SESSION_IDLE_TIMEOUT_MINUTES
|
|
874
|
-
// (default 5) and be swept mid-drain. The sweep
|
|
875
|
-
//
|
|
876
|
-
//
|
|
877
|
-
// singleton down
|
|
909
|
+
// (default 5) and be swept mid-drain. The sweep would then evict the session and
|
|
910
|
+
// fire its eviction listeners, closing the transport under a drain that is still
|
|
911
|
+
// running. Since #392 the sweep takes the api mutex and re-checks expiry, so it can
|
|
912
|
+
// no longer tear the singleton down mid-operation, but an unrefreshed entry is still
|
|
913
|
+
// MARKED expired, so the touch below is what keeps an active session out of the
|
|
914
|
+
// sweep's list at all.
|
|
878
915
|
//
|
|
879
916
|
// THIS MUST STAY INSIDE THE LOOP. A single touch at drain start does nothing for
|
|
880
917
|
// a drain whose wall clock is the SUM of its ops, which is the entire reason the
|
|
@@ -909,7 +946,9 @@ async function processWriteQueue() {
|
|
|
909
946
|
if (usePoolBranch && _shouldDropPoolOnError(syncError)) {
|
|
910
947
|
logger.warn(`[WRITE QUEUE] Releasing pool connection for session ${batchSessionId} after sync failure`);
|
|
911
948
|
try {
|
|
912
|
-
|
|
949
|
+
// #392: Locked variant; the drain holds withApiLock across this whole block, and
|
|
950
|
+
// the mutex is not reentrant, so the wrapping variant would deadlock here.
|
|
951
|
+
await connectionPool.shutdownConnectionLocked(batchSessionId);
|
|
913
952
|
}
|
|
914
953
|
catch (_e) {
|
|
915
954
|
/* swallow */
|
|
@@ -942,7 +981,9 @@ async function processWriteQueue() {
|
|
|
942
981
|
if (usePoolBranch) {
|
|
943
982
|
if (_shouldDropPoolOnError(error)) {
|
|
944
983
|
try {
|
|
945
|
-
|
|
984
|
+
// #392: Locked variant; the drain holds withApiLock across this whole block, and
|
|
985
|
+
// the mutex is not reentrant, so the wrapping variant would deadlock here.
|
|
986
|
+
await connectionPool.shutdownConnectionLocked(batchSessionId);
|
|
946
987
|
}
|
|
947
988
|
catch (_e) {
|
|
948
989
|
/* swallow */
|
|
@@ -1012,6 +1053,7 @@ function queueWriteOperation(operation, options) {
|
|
|
1012
1053
|
sessionId,
|
|
1013
1054
|
requestStore: requestContext.getStore(),
|
|
1014
1055
|
preservesListings: options?.preservesListings,
|
|
1056
|
+
timeoutMs: options?.timeoutMs,
|
|
1015
1057
|
};
|
|
1016
1058
|
// #278: bound queue RESIDENCY, not just execution. #270's withOpTimeout bounds an
|
|
1017
1059
|
// operation that is RUNNING; it cannot bound one that never starts. Reuse the same
|
|
@@ -2684,8 +2726,9 @@ export async function switchBudget(name) {
|
|
|
2684
2726
|
// init/shutdown cycle. A pooled entry for stdio would be created here and then
|
|
2685
2727
|
// NEVER refreshed, because connectionPool.touch() is called only from
|
|
2686
2728
|
// httpServer.ts; it would expire after SESSION_IDLE_TIMEOUT_MINUTES and
|
|
2687
|
-
// cleanupIdleConnections would
|
|
2688
|
-
//
|
|
2729
|
+
// cleanupIdleConnections would tear it down. Since #392 that teardown takes the api mutex and
|
|
2730
|
+
// re-checks expiry, so it is no longer a mid-operation hazard, but an entry that is never
|
|
2731
|
+
// refreshed still expires, and that is the reason that matters here. The
|
|
2689
2732
|
// legacy path re-reads getActiveBudgetConfig() on every call, so the switch
|
|
2690
2733
|
// still takes effect; it just costs an init per operation, which is what stdio
|
|
2691
2734
|
// already did before this ticket.
|
|
@@ -2874,22 +2917,46 @@ export async function exportBudget() {
|
|
|
2874
2917
|
export async function importBudget(input, opts = {}) {
|
|
2875
2918
|
observability.incrementToolCall('actual.budgets.import').catch(() => { });
|
|
2876
2919
|
const result = await queueWriteOperation(async () => {
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
//
|
|
2883
|
-
//
|
|
2884
|
-
//
|
|
2885
|
-
//
|
|
2886
|
-
//
|
|
2887
|
-
//
|
|
2888
|
-
//
|
|
2889
|
-
//
|
|
2890
|
-
|
|
2920
|
+
// #394: an import re-points the process-global singleton exactly as a download does, so it
|
|
2921
|
+
// gets the SAME tracking discipline, through the same helper, rather than a hand-written
|
|
2922
|
+
// approximation of it.
|
|
2923
|
+
//
|
|
2924
|
+
// What was wrong before: nothing cleared the record before the import started, nothing
|
|
2925
|
+
// registered the promise, and the sentinel was written AFTER the await, so a timeout skipped
|
|
2926
|
+
// it entirely. A large YNAB or zip import exceeding ACTUAL_OP_TIMEOUT_MS is the expected case,
|
|
2927
|
+
// not a rare one. The record therefore went on naming the PRE-IMPORT budget while the
|
|
2928
|
+
// singleton moved to an out-of-registry, un-ACL'd imported file, and a victim session passed
|
|
2929
|
+
// the #390 precondition legitimately and had its reads served from the importer's file.
|
|
2930
|
+
//
|
|
2931
|
+
// #390 still holds at this call site: `importBudgetTracked` records the `imported:` sentinel
|
|
2932
|
+
// when the promise settles, whichever way, and the clear below states the same fact at the
|
|
2933
|
+
// call site where the guard in tests/unit/budget_selection_precondition.test.js can see it.
|
|
2934
|
+
// #408: invalidate BEFORE the import starts, not after it resolves.
|
|
2935
|
+
//
|
|
2936
|
+
// The invalidation below used to run only after `queueWriteOperation` resolved, so on the
|
|
2937
|
+
// TIMEOUT path it never ran at all, and a large import exceeding ACTUAL_OP_TIMEOUT_MS is the
|
|
2938
|
+
// expected case rather than a rare one. Every pool entry then kept naming the pre-import
|
|
2939
|
+
// syncId while the singleton moved to the imported file, and switchBudget's #172 fast path
|
|
2940
|
+
// no-opped on a switch back: the reported-success-with-no-effect class #349 exists to prevent.
|
|
2941
|
+
//
|
|
2942
|
+
// Moving it here follows the same clear-before-you-start discipline #394 established for the
|
|
2943
|
+
// record itself: a mutation that may be abandoned must leave state INDETERMINATE, never
|
|
2944
|
+
// confidently wrong. It is idempotent and costs nothing on the success path, where the call
|
|
2945
|
+
// after the write simply re-applies the same sentinel.
|
|
2946
|
+
const preInvalidated = connectionPool.invalidateAllLoadedSyncIds('imported:pending');
|
|
2947
|
+
if (preInvalidated > 0) {
|
|
2948
|
+
logger.debug(`[ADAPTER] importBudget: invalidated the pooled syncId on ${preInvalidated} session(s) before starting`);
|
|
2949
|
+
}
|
|
2950
|
+
const imported = await importBudgetTracked(() => {
|
|
2951
|
+
const started = withConcurrency(() => retry(() => rawImportBudget(input, opts), {
|
|
2952
|
+
retries: 0,
|
|
2953
|
+
backoffMs: 200,
|
|
2954
|
+
isRetryable: isRetryableError,
|
|
2955
|
+
}));
|
|
2956
|
+
return started;
|
|
2957
|
+
});
|
|
2891
2958
|
return imported;
|
|
2892
|
-
});
|
|
2959
|
+
}, { timeoutMs: config.ACTUAL_IMPORT_TIMEOUT_MS });
|
|
2893
2960
|
// #349: an import CHANGES WHICH BUDGET IS LOADED, so the pool's record of it
|
|
2894
2961
|
// must stop naming the old one.
|
|
2895
2962
|
//
|
package/dist/src/lib/apiState.js
CHANGED
|
@@ -131,3 +131,17 @@ export async function awaitAbandonedBudgetLoad(bound) {
|
|
|
131
131
|
export function _hasPendingBudgetLoadForTests() {
|
|
132
132
|
return _pendingBudgetLoads.size > 0;
|
|
133
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Test hook: drop every registration.
|
|
136
|
+
*
|
|
137
|
+
* There is deliberately no production unregister: an entry leaves this set only when its promise
|
|
138
|
+
* settles, which is what makes "is a load outstanding" mean anything (#393). But a test that
|
|
139
|
+
* registers a NEVER-settling load has no way back, and leaving one pending poisons every later
|
|
140
|
+
* lock acquisition in that file, so a later case silently exercises a poisoned lock while claiming
|
|
141
|
+
* to test something else. Both of this repo's abandoned-load test files hit exactly that.
|
|
142
|
+
*
|
|
143
|
+
* Test-only, and not exported through the package surface.
|
|
144
|
+
*/
|
|
145
|
+
export function _clearPendingBudgetLoadsForTests() {
|
|
146
|
+
_pendingBudgetLoads.clear();
|
|
147
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import api from '@actual-app/api';
|
|
2
2
|
import { withOpTimeout } from './opTimeout.js';
|
|
3
|
+
import config from '../config.js';
|
|
3
4
|
import { setApiInitialized, setLoadedBudgetSyncId, registerBudgetLoad } from './apiState.js';
|
|
4
5
|
import { createModuleLogger } from './loggerFactory.js';
|
|
5
6
|
const log = createModuleLogger('BUDGET-LOADER');
|
|
@@ -154,6 +155,23 @@ function scrubReason(prose) {
|
|
|
154
155
|
* so putting two network calls in there would extend the window in which unrelated sessions' lock
|
|
155
156
|
* acquisitions throw, to build a message an abandoned load has no caller to read.
|
|
156
157
|
*/
|
|
158
|
+
/**
|
|
159
|
+
* #404: the cached reason is re-derived every REDIAGNOSE_AFTER_USES failures.
|
|
160
|
+
*
|
|
161
|
+
* The cache exists because `diagnose()` runs INSIDE the process-global api mutex and makes two
|
|
162
|
+
* bounded network calls, so re-running it per failed tool call stalls unrelated sessions. But an
|
|
163
|
+
* entry was previously dropped only by a genuinely successful load or a process restart, so if a
|
|
164
|
+
* budget's failure MODE changed with no success in between, the message reported the first reason
|
|
165
|
+
* indefinitely.
|
|
166
|
+
*
|
|
167
|
+
* Bounding by USES rather than by wall-clock is deliberate: the cost this cache avoids is paid per
|
|
168
|
+
* CALL, so capping in calls bounds staleness in the same unit, needs no clock, and is
|
|
169
|
+
* deterministic under test. At 20, a persistent failure pays the diagnosis roughly 5 percent of
|
|
170
|
+
* the time (the saving the cache exists for is kept) while no operator can see a stale reason for
|
|
171
|
+
* more than 20 failed calls. The fail-closed DECISION is still never cached: every call
|
|
172
|
+
* re-attempts the load and still fails.
|
|
173
|
+
*/
|
|
174
|
+
const REDIAGNOSE_AFTER_USES = 20;
|
|
157
175
|
const reasonCache = new Map();
|
|
158
176
|
/** A load that genuinely succeeded clears any cached reason for that budget. */
|
|
159
177
|
function clearCachedReason(syncId) {
|
|
@@ -164,6 +182,9 @@ export function _clearReasonCacheForTests() {
|
|
|
164
182
|
reasonCache.clear();
|
|
165
183
|
}
|
|
166
184
|
async function diagnose(syncId) {
|
|
185
|
+
// #409: what the cache held before a re-diagnosis, so a failed re-diagnosis can fall back to it
|
|
186
|
+
// rather than reporting nothing.
|
|
187
|
+
let staleReason;
|
|
167
188
|
// #396 review: memoise the reason STRING, never the fail-closed decision. Without this, every
|
|
168
189
|
// tool call while the condition persists pays two bounded network calls WHILE HOLDING the
|
|
169
190
|
// process-global api mutex, so a single unloadable budget stalls unrelated sessions for up to
|
|
@@ -177,8 +198,25 @@ async function diagnose(syncId) {
|
|
|
177
198
|
// precisely the cost this memo exists to remove.
|
|
178
199
|
const cached = reasonCache.get(syncId);
|
|
179
200
|
if (cached !== undefined) {
|
|
180
|
-
|
|
181
|
-
|
|
201
|
+
cached.uses += 1;
|
|
202
|
+
if (cached.uses <= REDIAGNOSE_AFTER_USES) {
|
|
203
|
+
log.debug('reusing the cached load-failure reason', { syncId, uses: cached.uses });
|
|
204
|
+
return cached.reason || undefined;
|
|
205
|
+
}
|
|
206
|
+
// #404: the entry has served its budget of calls, so fall through to a fresh diagnosis and
|
|
207
|
+
// pick up a failure mode that changed without an intervening success.
|
|
208
|
+
//
|
|
209
|
+
// #409: do NOT delete it here. Deleting first meant a re-diagnosis that then failed
|
|
210
|
+
// transiently (a `getBudgets` blip) left the caller with "upstream discarded the reason"
|
|
211
|
+
// despite a perfectly good cached one having existed a moment earlier, and the next call paid
|
|
212
|
+
// two more bounded network calls inside the api mutex to rediscover it. The stale entry is
|
|
213
|
+
// kept as a fallback and is only overwritten when a new answer is actually produced.
|
|
214
|
+
staleReason = cached.reason;
|
|
215
|
+
cached.uses = 0;
|
|
216
|
+
log.debug('re-diagnosing the load failure after the cached reason served its budget', {
|
|
217
|
+
syncId,
|
|
218
|
+
afterUses: REDIAGNOSE_AFTER_USES,
|
|
219
|
+
});
|
|
182
220
|
}
|
|
183
221
|
try {
|
|
184
222
|
const budgets = (await withOpTimeout(() => api.getBudgets(), 'diagnose getBudgets'));
|
|
@@ -190,7 +228,7 @@ async function diagnose(syncId) {
|
|
|
190
228
|
if (!local) {
|
|
191
229
|
// Deliberately does NOT list what IS available: that would enumerate every budget on the
|
|
192
230
|
// server to a caller the ACL scopes to one.
|
|
193
|
-
reasonCache.set(syncId, NO_LOCAL_COPY);
|
|
231
|
+
reasonCache.set(syncId, { reason: NO_LOCAL_COPY, uses: 0 });
|
|
194
232
|
return NO_LOCAL_COPY;
|
|
195
233
|
}
|
|
196
234
|
const localId = local.id;
|
|
@@ -208,6 +246,13 @@ async function diagnose(syncId) {
|
|
|
208
246
|
// stay in THIS brace-balanced block: the guard's window ends at the first `}` that takes
|
|
209
247
|
// depth below zero, so a setter in a sibling `catch` or `finally` arm would be outside it.
|
|
210
248
|
setLoadedBudgetSyncId(null);
|
|
249
|
+
// #407 review (I3): DROP any cached reason here. #409 stopped deleting the entry before a
|
|
250
|
+
// re-diagnosis, which is right for the failed-diagnosis path, but on THIS path the old
|
|
251
|
+
// reason has just been proven wrong: the load worked. Leaving it cached with uses reset
|
|
252
|
+
// meant the next 20 calls reported a permanent-sounding migration code for a condition the
|
|
253
|
+
// diagnosis had just shown to be transient, which is exactly the reading the comment below
|
|
254
|
+
// says to avoid.
|
|
255
|
+
reasonCache.delete(syncId);
|
|
211
256
|
// #396 review: the retry SUCCEEDED, so this was not a deterministic unloadable budget. Say
|
|
212
257
|
// so rather than reporting "upstream discarded the reason", which reads as permanent. The
|
|
213
258
|
// operation still fails (the record is indeterminate by design, which forces a re-select),
|
|
@@ -228,13 +273,20 @@ async function diagnose(syncId) {
|
|
|
228
273
|
// `.code` outside KNOWN_LOAD_REASONS and path-shaped prose that `scrubReason` drops, so it
|
|
229
274
|
// yields `undefined`, and that is the ticket's own reproduced "db.sqlite is not a database"
|
|
230
275
|
// case: the one most likely to persist. Storing '' records "diagnosed" without reporting.
|
|
231
|
-
reasonCache.set(syncId, reason ?? '');
|
|
276
|
+
reasonCache.set(syncId, { reason: reason ?? '', uses: 0 });
|
|
232
277
|
return reason;
|
|
233
278
|
}
|
|
234
279
|
}
|
|
235
280
|
catch (diagErr) {
|
|
236
281
|
// A failing diagnostic is LOGGED, never substituted for the post-condition error.
|
|
237
282
|
log.warn('could not diagnose why the budget did not load', { syncId, error: messageOf(diagErr) });
|
|
283
|
+
// #409: fall back to the previous answer rather than reporting nothing. It described this same
|
|
284
|
+
// budget's failure moments ago and is far more useful than silence; the use counter was reset,
|
|
285
|
+
// so the next call re-attempts the diagnosis anyway.
|
|
286
|
+
if (staleReason !== undefined) {
|
|
287
|
+
reasonCache.set(syncId, { reason: staleReason, uses: 0 });
|
|
288
|
+
return staleReason || undefined;
|
|
289
|
+
}
|
|
238
290
|
return undefined;
|
|
239
291
|
}
|
|
240
292
|
}
|
|
@@ -247,45 +299,126 @@ function postConditionError(syncId, reason) {
|
|
|
247
299
|
`this), and a local budget copy that cannot be opened (check the directory configured by ` +
|
|
248
300
|
`MCP_BRIDGE_DATA_DIR, and note that two server processes sharing one data dir will do this).`);
|
|
249
301
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
302
|
+
/**
|
|
303
|
+
* The tracking discipline EVERY singleton-mutating load must follow (#390, #393, #394).
|
|
304
|
+
*
|
|
305
|
+
* Extracted so the two callers cannot drift. `downloadBudget` and `importBudget` are different
|
|
306
|
+
* upstream calls with different failure shapes, but the discipline is identical and getting it
|
|
307
|
+
* subtly different at one site is exactly how #390 stayed reachable for three rounds:
|
|
308
|
+
*
|
|
309
|
+
* 1. Clear the record BEFORE starting, so an abandonment can only leave it INDETERMINATE.
|
|
310
|
+
* That is the safe direction: it forces a re-select. Recording only on success leaves the
|
|
311
|
+
* record naming the PREVIOUS budget while the singleton moves to a new one, which is the one
|
|
312
|
+
* direction that makes the #390 precondition silently pass.
|
|
313
|
+
* 2. Register the promise, so a late landing is WAITED FOR by the next lock acquisition rather
|
|
314
|
+
* than discovered mid-operation. `withOpTimeout` races; it does not cancel.
|
|
315
|
+
* 3. Record the true outcome when it settles, whether or not anyone is still listening, and
|
|
316
|
+
* poison the singleton on failure, because upstream closes the current budget before opening
|
|
317
|
+
* the new one, so "failed" does not mean "unchanged".
|
|
318
|
+
*
|
|
319
|
+
* `verify` is #396's post-condition, and it runs BEFORE the record is written, so the record can
|
|
320
|
+
* only ever describe a mutation that actually took effect.
|
|
321
|
+
*/
|
|
322
|
+
async function trackBudgetMutation(start, describeLoaded, label, verify, onRecorded, timeoutMs) {
|
|
323
|
+
// Indeterminate from here until it settles. Cleared BEFORE the load is even started, so the
|
|
324
|
+
// record can never name the outgoing budget while the singleton is moving away from it: that is
|
|
325
|
+
// the one direction that makes #390's precondition silently pass. It used to be cleared on the
|
|
326
|
+
// statement AFTER `start()`, which was safe in practice (both run in one synchronous turn, so
|
|
327
|
+
// nothing could observe the gap) but made the invariant untestable and the comment's own word
|
|
328
|
+
// "first" untrue.
|
|
256
329
|
setLoadedBudgetSyncId(null);
|
|
257
|
-
|
|
258
|
-
// `onFulfilled` argument of `.then(onFulfilled, onRejected)` is NOT caught by that same
|
|
259
|
-
// `onRejected`, so with the old shape a probe failure would skip the fail-closed
|
|
260
|
-
// `setApiInitialized(false)` below: exactly the new failure path this adds.
|
|
261
|
-
//
|
|
262
|
-
// The probe lives INSIDE this chain so an ABANDONED load is verified when it lands, rather than
|
|
263
|
-
// recording a false success nobody checked. The network enrichment deliberately does not.
|
|
330
|
+
const raw = start();
|
|
264
331
|
const tracked = raw
|
|
265
|
-
.then(async () => {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
332
|
+
.then(async (result) => {
|
|
333
|
+
if (verify)
|
|
334
|
+
await verify();
|
|
335
|
+
setLoadedBudgetSyncId(describeLoaded(result));
|
|
336
|
+
// #392 review (finding 5): this runs INSIDE the chain, not after the awaited call. On the
|
|
337
|
+
// abandoned-then-eventually-successful path the caller has already thrown, so a hook placed
|
|
338
|
+
// after the await would never run and the cached failure reason would outlive the load that
|
|
339
|
+
// actually succeeded.
|
|
340
|
+
// Wrapped: a hook cannot be allowed to change the recorded outcome. Unwrapped, a throwing
|
|
341
|
+
// hook would be caught by the trailing .catch, reported as a load failure, and would leave
|
|
342
|
+
// the record written while setApiInitialized(false) says the singleton is dead: an
|
|
343
|
+
// incoherent pair rather than a fail-safe one.
|
|
344
|
+
try {
|
|
345
|
+
onRecorded?.();
|
|
346
|
+
}
|
|
347
|
+
catch (hookErr) {
|
|
348
|
+
log.warn('post-record hook threw; the recorded outcome stands', { error: String(hookErr) });
|
|
349
|
+
}
|
|
350
|
+
return result;
|
|
269
351
|
})
|
|
270
352
|
.catch((err) => {
|
|
271
353
|
// A failed load leaves the singleton in a state nobody can describe: upstream's
|
|
272
|
-
// download
|
|
273
|
-
// does not mean "unchanged". Poison it so the next operation re-inits
|
|
354
|
+
// download and import handlers both close the current budget before opening the new one,
|
|
355
|
+
// so "failed" does not mean "unchanged". Poison it so the next operation re-inits.
|
|
274
356
|
setApiInitialized(false);
|
|
275
357
|
throw err;
|
|
276
358
|
});
|
|
277
359
|
registerBudgetLoad(tracked);
|
|
360
|
+
// On timeout the tracked promise is still running. Leave it REGISTERED: the next operation
|
|
361
|
+
// waits for it inside the lock rather than racing it.
|
|
362
|
+
return await withOpTimeout(() => tracked, label, timeoutMs);
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* #394: the same discipline for an IMPORT.
|
|
366
|
+
*
|
|
367
|
+
* Upstream's `importBudget` LOADS the imported budget, so it re-points the process-global
|
|
368
|
+
* singleton exactly as a download does, and a large YNAB or zip import exceeding
|
|
369
|
+
* ACTUAL_OP_TIMEOUT_MS is the expected case rather than a rare one. Before this, nothing cleared
|
|
370
|
+
* the record before it started, nothing registered the promise, and the sentinel was written
|
|
371
|
+
* AFTER the await, so a timeout skipped it entirely and the record went on naming the PRE-IMPORT
|
|
372
|
+
* budget while the singleton moved to an out-of-registry, un-ACL'd file. A victim session then
|
|
373
|
+
* passed the #390 precondition legitimately and had its reads served from the importer's file.
|
|
374
|
+
*
|
|
375
|
+
* ACCEPTED COST, recorded because nothing else states it. Registering the import means every
|
|
376
|
+
* subsequent `withApiLock` acquisition waits for it, bounded, and throws on timeout. An import is
|
|
377
|
+
* not "stuck", it is legitimately long: a large YNAB or zip import can run for minutes. So while
|
|
378
|
+
* one runs past ACTUAL_OP_TIMEOUT_MS, other sessions stall for that bound and then fail with
|
|
379
|
+
* "abandoned budget load timed out", and write drains reject their batch. That is a strictly
|
|
380
|
+
* better failure than the silent cross-tenant read it replaces (a victim reading the importer's
|
|
381
|
+
* un-ACL'd file), and it is the same trade #393 accepted for downloads. It is NOT free, and the
|
|
382
|
+
* operator-facing consequence is a process-wide stall for the duration of one tenant's import.
|
|
383
|
+
* Giving imports their own larger bound is tracked separately rather than bolted on here.
|
|
384
|
+
*
|
|
385
|
+
* No `verify` here, deliberately: unlike `download-budget`, upstream's `importBudget` already
|
|
386
|
+
* asserts its own post-condition. It checks `result.error` and then `!result.id`, throwing
|
|
387
|
+
* "no budget was loaded", so it cannot resolve without having loaded something. Verified in the
|
|
388
|
+
* shipped upstream source. Adding a probe would be duplicate work on the write path.
|
|
389
|
+
*/
|
|
390
|
+
export async function importBudgetTracked(start, label = 'importBudget') {
|
|
391
|
+
// A sentinel rather than a real syncId: an imported file is outside the budget registry and
|
|
392
|
+
// outside the ACL, so no session can ever legitimately match it and every session's next
|
|
393
|
+
// operation re-selects its own budget. Configured sync ids are UUIDs, so an `imported:` prefix
|
|
394
|
+
// can never compare equal to one.
|
|
395
|
+
// #407: the import's OWN bound. See ACTUAL_IMPORT_TIMEOUT_MS in config.ts for why a long
|
|
396
|
+
// import must not be abandoned at the ordinary operation timeout: it is tracked, so every other
|
|
397
|
+
// session waits on it, and abandoning it early turns one tenant's import into a process-wide
|
|
398
|
+
// stall without making the import itself any faster.
|
|
399
|
+
return trackBudgetMutation(start, (result) => `imported:${result.id}`, label, undefined, undefined, config.ACTUAL_IMPORT_TIMEOUT_MS);
|
|
400
|
+
}
|
|
401
|
+
export async function loadBudgetTracked(syncId, encryptionPassword, label = 'downloadBudget') {
|
|
278
402
|
try {
|
|
279
|
-
await
|
|
280
|
-
// #
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
|
|
403
|
+
await trackBudgetMutation(
|
|
404
|
+
// #410: no redundant clear here any more. `trackBudgetMutation` owns the clear-before, and
|
|
405
|
+
// the guard in tests/unit/budget_selection_precondition.test.js now asserts the invariant
|
|
406
|
+
// that actually holds (a raw load must be inside the tracked helper) rather than looking for
|
|
407
|
+
// a setter in the same block, which had stopped discriminating.
|
|
408
|
+
() => encryptionPassword
|
|
409
|
+
? api
|
|
410
|
+
.downloadBudget(syncId, { password: encryptionPassword })
|
|
411
|
+
: api.downloadBudget(syncId), () => syncId, label,
|
|
412
|
+
// #396: the post-condition runs INSIDE the tracked chain, before the record is written, so
|
|
413
|
+
// an ABANDONED load is verified when it lands rather than recording a success nobody
|
|
414
|
+
// checked. The network enrichment below deliberately does NOT run there.
|
|
415
|
+
assertBudgetOpen, () => clearCachedReason(syncId));
|
|
285
416
|
}
|
|
286
417
|
catch (err) {
|
|
287
|
-
//
|
|
288
|
-
//
|
|
418
|
+
// Enrich HERE, where a caller is actually waiting. Every `withApiLock` acquisition settles
|
|
419
|
+
// all registrations under ONE bound and fails closed on timeout, so two network calls inside
|
|
420
|
+
// the tracked chain would extend the window in which unrelated sessions' lock acquisitions
|
|
421
|
+
// throw, to build a message an abandoned load has no caller to read.
|
|
289
422
|
if (isBudgetNotOpen(err)) {
|
|
290
423
|
log.error('download resolved but no budget is open', undefined, { syncId });
|
|
291
424
|
throw postConditionError(syncId, await diagnose(syncId));
|
|
@@ -28,15 +28,25 @@ import config from '../config.js';
|
|
|
28
28
|
* before `Promise.race` is built (which would orphan the armed timer and later
|
|
29
29
|
* fire an unhandledRejection).
|
|
30
30
|
*/
|
|
31
|
-
export function withOpTimeout(fn, label = 'operation') {
|
|
32
|
-
|
|
31
|
+
export function withOpTimeout(fn, label = 'operation', overrideMs, overrideKnob = 'ACTUAL_IMPORT_TIMEOUT_MS') {
|
|
32
|
+
// #407: an explicit override, for an operation that is legitimately long rather than stalled.
|
|
33
|
+
// A budget import is the only one today. Everything else keeps the general bound, because the
|
|
34
|
+
// whole point of #270 is that one stalled call must not hold the process-global mutex.
|
|
35
|
+
const timeoutMs = overrideMs === undefined ? config.ACTUAL_OP_TIMEOUT_MS : overrideMs;
|
|
36
|
+
// #407 review (I4): name the knob that ACTUALLY applies. Hardcoding ACTUAL_OP_TIMEOUT_MS sent an
|
|
37
|
+
// operator whose import died at the import bound to raise a variable with no effect on imports.
|
|
38
|
+
//
|
|
39
|
+
// The name is a PARAMETER rather than inferred from "an override exists" (round 2): inferring it
|
|
40
|
+
// is correct only while exactly one call site passes an override, and the second one would
|
|
41
|
+
// silently report the import knob for a different bound, which is the same misdirection.
|
|
42
|
+
const knob = overrideMs === undefined ? 'ACTUAL_OP_TIMEOUT_MS' : overrideKnob;
|
|
33
43
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
34
44
|
return fn();
|
|
35
45
|
}
|
|
36
46
|
let timer;
|
|
37
47
|
const timeout = new Promise((_, reject) => {
|
|
38
48
|
timer = setTimeout(() => {
|
|
39
|
-
reject(new Error(`Actual API ${label} timed out after ${timeoutMs}ms (
|
|
49
|
+
reject(new Error(`Actual API ${label} timed out after ${timeoutMs}ms (${knob}). ` +
|
|
40
50
|
'The upstream Actual server may be unreachable or stalled; the operation was ' +
|
|
41
51
|
'aborted so the session can recover. Please retry the request.'));
|
|
42
52
|
}, timeoutMs);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "actual-mcp-server",
|
|
3
3
|
"displayName": "Actual MCP Server",
|
|
4
|
-
"version": "0.16.
|
|
4
|
+
"version": "0.16.7",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.0.0",
|
|
7
7
|
"npm": ">=10.0.0"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"verify-tools": "npm run build && node scripts/verify-tools.js",
|
|
31
31
|
"check:coverage": "node scripts/list-actual-api-methods.mjs",
|
|
32
32
|
"direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
|
|
33
|
-
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js",
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js",
|
|
34
34
|
"test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
|
|
35
35
|
"test:e2e": "npx playwright test",
|
|
36
36
|
"test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
|