@dinoxx/dinox-cli 2.13.1 → 2.14.0
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/dist/cli.js +5 -2
- package/dist/commands/auth/index.d.ts +2 -1
- package/dist/commands/auth/index.js +18 -1
- package/dist/commands/doctor.d.ts +4 -1
- package/dist/commands/doctor.js +20 -1
- package/dist/commands/graph/index.js +2 -1
- package/dist/commands/notes/contentPatch.js +2 -1
- package/dist/commands/notes/export.js +3 -2
- package/dist/commands/runtime.d.ts +8 -0
- package/dist/commands/runtime.js +49 -1
- package/dist/commands/schema/globalOptions.d.ts +1 -0
- package/dist/commands/schema/globalOptions.js +1 -0
- package/dist/commands/schema/renderSkills.js +1 -0
- package/dist/commands/schema/spec.js +9 -1
- package/dist/commands/storage/repo/operations.js +3 -1
- package/dist/commands/sync.d.ts +2 -1
- package/dist/commands/sync.js +24 -5
- package/dist/daemon/client.js +32 -2
- package/dist/daemon/connectivity.d.ts +20 -0
- package/dist/daemon/connectivity.js +42 -0
- package/dist/daemon/ensure.js +48 -12
- package/dist/daemon/forward.d.ts +34 -0
- package/dist/daemon/forward.js +146 -0
- package/dist/daemon/fresh.d.ts +4 -0
- package/dist/daemon/fresh.js +10 -0
- package/dist/daemon/index.d.ts +11 -6
- package/dist/daemon/index.js +90 -25
- package/dist/daemon/launch.d.ts +13 -1
- package/dist/daemon/launch.js +31 -2
- package/dist/daemon/lifecycle.d.ts +3 -1
- package/dist/daemon/lifecycle.js +27 -3
- package/dist/daemon/pid.d.ts +8 -0
- package/dist/daemon/pid.js +20 -0
- package/dist/daemon/route.js +2 -1
- package/dist/daemon/server.d.ts +44 -0
- package/dist/daemon/server.js +175 -3
- package/dist/daemon/supervise.d.ts +6 -6
- package/dist/daemon/supervise.js +12 -10
- package/dist/daemon/supervisor.d.ts +21 -5
- package/dist/daemon/supervisor.js +125 -6
- package/dist/daemon/types.d.ts +16 -0
- package/dist/dinox.js +41 -64
- package/dist/gateway/mcpCommandTools.d.ts +9 -0
- package/dist/gateway/mcpCommandTools.js +40 -10
- package/dist/powersync/runtime.d.ts +2 -0
- package/dist/powersync/runtime.js +3 -1
- package/dist/requests/store.d.ts +10 -0
- package/dist/requests/store.js +190 -0
- package/dist/runCli.d.ts +2 -0
- package/dist/runCli.js +64 -0
- package/dist/utils/argValue.js +2 -1
- package/dist/utils/errorCodes.d.ts +20 -0
- package/dist/utils/errorCodes.js +5 -0
- package/dist/utils/output.d.ts +2 -0
- package/dist/utils/output.js +31 -7
- package/dist/utils/requestContext.d.ts +24 -0
- package/dist/utils/requestContext.js +13 -0
- package/package.json +2 -1
package/dist/daemon/launch.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
2
3
|
import os from 'node:os';
|
|
3
4
|
import { loadConfig } from '../config/store.js';
|
|
4
5
|
import { DINOX_TOKEN_ENV_VAR, normalizeAuthToken, readEnvAuthToken } from '../utils/authToken.js';
|
|
@@ -52,6 +53,32 @@ export async function usableDaemonService() {
|
|
|
52
53
|
return null;
|
|
53
54
|
return service;
|
|
54
55
|
}
|
|
56
|
+
function samePath(a, b) {
|
|
57
|
+
const resolve = (value) => {
|
|
58
|
+
try {
|
|
59
|
+
return realpathSync(value);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
return resolve(a) === resolve(b);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The entrypoint of another dino install (e.g. a global install next to a
|
|
69
|
+
* source checkout) that the service definition runs, or null when it runs
|
|
70
|
+
* this CLI or an install that no longer exists. Automatic paths must not
|
|
71
|
+
* rewrite or restart a service another install owns: each install would
|
|
72
|
+
* otherwise keep switching it to its own version.
|
|
73
|
+
*/
|
|
74
|
+
export function foreignServiceOwner(service, entrypoint = process.argv[1]) {
|
|
75
|
+
const command = service.installedCommand();
|
|
76
|
+
const daemonIndex = command?.indexOf('daemon') ?? -1;
|
|
77
|
+
const owner = daemonIndex > 0 ? command[daemonIndex - 1] : null;
|
|
78
|
+
if (!owner || !entrypoint || samePath(owner, entrypoint) || !existsSync(owner))
|
|
79
|
+
return null;
|
|
80
|
+
return owner;
|
|
81
|
+
}
|
|
55
82
|
function spawnDetached(port) {
|
|
56
83
|
const { program, args } = daemonCommandLine(port);
|
|
57
84
|
const log = openDaemonLogForChild();
|
|
@@ -76,14 +103,16 @@ function spawnDetached(port) {
|
|
|
76
103
|
* stale daemon. Uses the installed service when there is one (refreshing its
|
|
77
104
|
* definition if this CLI's program, arguments or environment changed, e.g.
|
|
78
105
|
* after `dino update`), otherwise a detached child with a clean environment.
|
|
106
|
+
* A service another install owns is only started, never rewritten, unless
|
|
107
|
+
* `takeover` is set by an explicit `dino daemon start/restart`.
|
|
79
108
|
*/
|
|
80
|
-
export async function launchDaemon(port) {
|
|
109
|
+
export async function launchDaemon(port, options = {}) {
|
|
81
110
|
const service = await usableDaemonService();
|
|
82
111
|
if (!service)
|
|
83
112
|
return spawnDetached(port);
|
|
84
113
|
ensureDaemonDir();
|
|
85
114
|
const spec = daemonServiceSpec(service, port);
|
|
86
|
-
if (!service.isCurrent(spec)) {
|
|
115
|
+
if (!service.isCurrent(spec) && (options.takeover || !foreignServiceOwner(service))) {
|
|
87
116
|
await service.install(spec);
|
|
88
117
|
}
|
|
89
118
|
else {
|
|
@@ -3,7 +3,7 @@ import { printHumanReadableWarning } from '../utils/output.js';
|
|
|
3
3
|
import { getPackageVersion } from '../utils/version.js';
|
|
4
4
|
import { getDaemonInfo, shutdownDaemonForExclusiveAccess } from './client.js';
|
|
5
5
|
import { startDaemonLocked } from './index.js';
|
|
6
|
-
import { acquireDaemonStartLock, readPid, type DaemonPidInfo } from './pid.js';
|
|
6
|
+
import { acquireDaemonStartLock, acquireDaemonExclusiveIntentLock, readPid, type DaemonPidInfo } from './pid.js';
|
|
7
7
|
import { type DaemonPingResult } from './types.js';
|
|
8
8
|
export type DaemonSnapshot = {
|
|
9
9
|
info: DaemonPingResult | null;
|
|
@@ -21,10 +21,12 @@ export type DaemonExclusiveAccessControl = {
|
|
|
21
21
|
};
|
|
22
22
|
type DaemonLifecycleDependencies = {
|
|
23
23
|
acquireStartLock: typeof acquireDaemonStartLock;
|
|
24
|
+
acquireIntentLock: typeof acquireDaemonExclusiveIntentLock;
|
|
24
25
|
getDaemonInfo: typeof getDaemonInfo;
|
|
25
26
|
readPid: typeof readPid;
|
|
26
27
|
shutdownDaemon: typeof shutdownDaemonForExclusiveAccess;
|
|
27
28
|
startDaemonLocked: typeof startDaemonLocked;
|
|
29
|
+
startInstalledService: () => Promise<boolean>;
|
|
28
30
|
getPackageVersion: typeof getPackageVersion;
|
|
29
31
|
printWarning: typeof printHumanReadableWarning;
|
|
30
32
|
};
|
package/dist/daemon/lifecycle.js
CHANGED
|
@@ -6,15 +6,27 @@ import { DinoxError } from '../utils/errors.js';
|
|
|
6
6
|
import { printHumanReadableWarning } from '../utils/output.js';
|
|
7
7
|
import { getPackageVersion } from '../utils/version.js';
|
|
8
8
|
import { getDaemonInfo, isDaemonFresh, shutdownDaemonForExclusiveAccess, } from './client.js';
|
|
9
|
+
import { getDaemonService } from './supervisor.js';
|
|
9
10
|
import { startDaemonLocked } from './index.js';
|
|
10
|
-
import { acquireDaemonStartLock, getDaemonSocketPath, readPid, } from './pid.js';
|
|
11
|
+
import { acquireDaemonStartLock, acquireDaemonExclusiveIntentLock, getDaemonSocketPath, readPid, } from './pid.js';
|
|
11
12
|
import { DEFAULT_DAEMON_PORT } from './types.js';
|
|
13
|
+
/** Matches the service readiness budget; a cold daemon start takes a few seconds. */
|
|
14
|
+
const DAEMON_RESTORE_TIMEOUT_MS = 20_000;
|
|
12
15
|
const daemonLifecycleDependencies = {
|
|
13
16
|
acquireStartLock: acquireDaemonStartLock,
|
|
17
|
+
acquireIntentLock: acquireDaemonExclusiveIntentLock,
|
|
14
18
|
getDaemonInfo,
|
|
15
19
|
readPid,
|
|
16
20
|
shutdownDaemon: shutdownDaemonForExclusiveAccess,
|
|
17
21
|
startDaemonLocked,
|
|
22
|
+
async startInstalledService() {
|
|
23
|
+
const service = getDaemonService();
|
|
24
|
+
if (!service?.isInstalled())
|
|
25
|
+
return false;
|
|
26
|
+
// Not restart(): the manager may already be bringing it back.
|
|
27
|
+
await service.start();
|
|
28
|
+
return true;
|
|
29
|
+
},
|
|
18
30
|
getPackageVersion,
|
|
19
31
|
printWarning: printHumanReadableWarning,
|
|
20
32
|
};
|
|
@@ -78,13 +90,17 @@ export async function suspendDaemonForExclusiveAccessLocked(config, timeoutMs, o
|
|
|
78
90
|
}
|
|
79
91
|
/** Caller must hold the daemon start lock. */
|
|
80
92
|
export async function restoreSuspendedDaemonLocked(suspended, overrides = {}, options = {}) {
|
|
81
|
-
if (!suspended.wasStopped
|
|
93
|
+
if (!suspended.wasStopped) {
|
|
82
94
|
return false;
|
|
83
95
|
}
|
|
84
96
|
const deps = dependencies(overrides);
|
|
97
|
+
if (!suspended.wasFresh) {
|
|
98
|
+
return Boolean(suspended.snapshot.info?.supervisor && await deps.startInstalledService());
|
|
99
|
+
}
|
|
85
100
|
await deps.startDaemonLocked(suspended.port, true, {
|
|
86
101
|
quiet: true,
|
|
87
|
-
|
|
102
|
+
// Restoring a background process must not consume the default five-minute sync budget.
|
|
103
|
+
timeoutMs: Math.min(options.timeoutMs ?? DAEMON_RESTORE_TIMEOUT_MS, DAEMON_RESTORE_TIMEOUT_MS),
|
|
88
104
|
});
|
|
89
105
|
return true;
|
|
90
106
|
}
|
|
@@ -94,6 +110,7 @@ export async function withDaemonExclusiveAccess(config, timeoutMs, run, override
|
|
|
94
110
|
const remainingOperationTimeoutMs = (phase, action) => requireRemainingDeadlineMs(operationDeadlineAtMs, timeoutMs, phase, action);
|
|
95
111
|
const remainingCleanupTimeoutMs = () => Math.max(1, remainingDeadlineMs(options.deadlineAtMs, timeoutMs));
|
|
96
112
|
const releaseStartLock = await deps.acquireStartLock(remainingOperationTimeoutMs('daemon-lock-acquire', 'acquiring the daemon lifecycle lock'));
|
|
113
|
+
let releaseIntent;
|
|
97
114
|
let suspended = null;
|
|
98
115
|
let restoreAllowed = true;
|
|
99
116
|
let cleanupError;
|
|
@@ -104,6 +121,7 @@ export async function withDaemonExclusiveAccess(config, timeoutMs, run, override
|
|
|
104
121
|
},
|
|
105
122
|
};
|
|
106
123
|
try {
|
|
124
|
+
releaseIntent = await deps.acquireIntentLock(remainingOperationTimeoutMs('daemon-intent-lock', 'acquiring exclusive intent'));
|
|
107
125
|
suspended = await suspendDaemonForExclusiveAccessLocked(config, remainingOperationTimeoutMs('daemon-suspend', 'suspending the background daemon'), {
|
|
108
126
|
deadlineAtMs: operationDeadlineAtMs,
|
|
109
127
|
dependencies: deps,
|
|
@@ -119,6 +137,12 @@ export async function withDaemonExclusiveAccess(config, timeoutMs, run, override
|
|
|
119
137
|
outcome = { ok: false, error };
|
|
120
138
|
}
|
|
121
139
|
finally {
|
|
140
|
+
try {
|
|
141
|
+
await releaseIntent?.();
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
cleanupError = error;
|
|
145
|
+
}
|
|
122
146
|
if (suspended && restoreAllowed) {
|
|
123
147
|
const suspendedDaemon = suspended;
|
|
124
148
|
try {
|
package/dist/daemon/pid.d.ts
CHANGED
|
@@ -21,3 +21,11 @@ export declare function removePid(expectedInstanceId?: string, options?: {
|
|
|
21
21
|
export declare function isProcessRunning(pid: number): boolean;
|
|
22
22
|
export declare function isLegacyDaemonProcess(pid: number, daemonUptimeMs: number, timeoutMs?: number): Promise<boolean>;
|
|
23
23
|
export declare function acquireDaemonStartLock(timeoutMs?: number): Promise<() => Promise<void>>;
|
|
24
|
+
/** Present while `dino daemon stop` wants the Windows supervisor to stay down. */
|
|
25
|
+
export declare function getSupervisorStopMarkerPath(): string;
|
|
26
|
+
/**
|
|
27
|
+
* Held by a command that needs the PowerSync database in-process, from before
|
|
28
|
+
* it stops the daemon until it has closed the database. A daemon the service
|
|
29
|
+
* manager respawns in between waits for it (see waitForDaemonExclusiveIntent).
|
|
30
|
+
*/
|
|
31
|
+
export declare function acquireDaemonExclusiveIntentLock(timeoutMs?: number): Promise<() => Promise<void>>;
|
package/dist/daemon/pid.js
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
6
6
|
import { getDataDir } from '../config/paths.js';
|
|
7
7
|
import { acquireExclusiveSqliteLease } from '../utils/sqliteLease.js';
|
|
8
|
+
import { SUPERVISOR_STOP_MARKER } from './supervisor.js';
|
|
8
9
|
const DAEMON_START_LOCK_TIMEOUT_MS = 10_000;
|
|
9
10
|
const LEGACY_DAEMON_UPTIME_TOLERANCE_MS = 60_000;
|
|
10
11
|
const execFileAsync = promisify(execFile);
|
|
@@ -190,3 +191,22 @@ export async function acquireDaemonStartLock(timeoutMs = DAEMON_START_LOCK_TIMEO
|
|
|
190
191
|
createBusyError: () => new Error('Timed out waiting for daemon start lock'),
|
|
191
192
|
});
|
|
192
193
|
}
|
|
194
|
+
/** Present while `dino daemon stop` wants the Windows supervisor to stay down. */
|
|
195
|
+
export function getSupervisorStopMarkerPath() {
|
|
196
|
+
return join(getDaemonDir(), SUPERVISOR_STOP_MARKER);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Held by a command that needs the PowerSync database in-process, from before
|
|
200
|
+
* it stops the daemon until it has closed the database. A daemon the service
|
|
201
|
+
* manager respawns in between waits for it (see waitForDaemonExclusiveIntent).
|
|
202
|
+
*/
|
|
203
|
+
export async function acquireDaemonExclusiveIntentLock(timeoutMs = 30_000) {
|
|
204
|
+
ensureDaemonDir();
|
|
205
|
+
return acquireExclusiveSqliteLease({
|
|
206
|
+
lockPath: join(getDaemonDir(), 'daemon.exclusive.lock'),
|
|
207
|
+
timeoutMs,
|
|
208
|
+
maxWaitMs: timeoutMs,
|
|
209
|
+
pollIntervalMs: 50,
|
|
210
|
+
createBusyError: () => Object.assign(new Error('Timed out waiting for daemon exclusive intent lock'), { code: 'DAEMON_EXCLUSIVE_BUSY' }),
|
|
211
|
+
});
|
|
212
|
+
}
|
package/dist/daemon/route.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { sendDaemonRequest } from './client.js';
|
|
2
2
|
import { ensureDaemonRunning } from './ensure.js';
|
|
3
3
|
import { DinoxError } from '../utils/errors.js';
|
|
4
|
+
import { localCommandExecution, requestContext } from '../utils/requestContext.js';
|
|
4
5
|
/**
|
|
5
6
|
* Default online reads use the long-lived daemon as the canonical DB owner. The
|
|
6
7
|
* process-local path is only for explicit modes that require opening the cache here:
|
|
@@ -17,7 +18,7 @@ export function shouldUseDaemon(globals) {
|
|
|
17
18
|
* receives a thin sender bound to the daemon; `localRun` opens its own db/sync path.
|
|
18
19
|
*/
|
|
19
20
|
export async function routeRead(globals, daemonMethod, daemonParams, localRun) {
|
|
20
|
-
if (!shouldUseDaemon(globals)) {
|
|
21
|
+
if (requestContext.getStore()?.daemon || localCommandExecution.getStore() || !shouldUseDaemon(globals)) {
|
|
21
22
|
return localRun();
|
|
22
23
|
}
|
|
23
24
|
try {
|
package/dist/daemon/server.d.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
import type { AbstractPowerSyncDatabase } from '@powersync/common';
|
|
1
2
|
import type { DaemonRequest, DaemonResponse } from './types.js';
|
|
3
|
+
import { type ConnectivityChange } from './connectivity.js';
|
|
4
|
+
import type { ResolvedDinoxConfig } from '../config/types.js';
|
|
5
|
+
import { connectPowerSync } from '../powersync/runtime.js';
|
|
6
|
+
import { acquireDaemonExclusiveIntentLock } from './pid.js';
|
|
7
|
+
/** EX_TEMPFAIL: the service manager restarts the daemon. */
|
|
8
|
+
export declare const DAEMON_RESTART_EXIT_CODE = 75;
|
|
2
9
|
export declare class DaemonServer {
|
|
10
|
+
private readonly options;
|
|
11
|
+
private config;
|
|
3
12
|
private db;
|
|
4
13
|
private server;
|
|
5
14
|
private startTime;
|
|
@@ -12,11 +21,46 @@ export declare class DaemonServer {
|
|
|
12
21
|
private ownsArtifacts;
|
|
13
22
|
private releaseOwner;
|
|
14
23
|
private stopPromise;
|
|
24
|
+
private healthTimer;
|
|
25
|
+
private connectivityTimer;
|
|
26
|
+
private reconnect;
|
|
27
|
+
private reconnecting;
|
|
28
|
+
private syncUnconfirmedSince;
|
|
15
29
|
private gateways;
|
|
30
|
+
constructor(options?: {
|
|
31
|
+
runtime?: {
|
|
32
|
+
db: AbstractPowerSyncDatabase;
|
|
33
|
+
config: ResolvedDinoxConfig;
|
|
34
|
+
};
|
|
35
|
+
acquireIntentLock?: typeof acquireDaemonExclusiveIntentLock;
|
|
36
|
+
connect?: typeof connectPowerSync;
|
|
37
|
+
onStopRequested?: (reason: string, details?: Record<string, unknown>) => void | Promise<void>;
|
|
38
|
+
});
|
|
16
39
|
initialize(): Promise<void>;
|
|
17
40
|
handleRequest(req: DaemonRequest): Promise<DaemonResponse>;
|
|
18
41
|
private isCacheStale;
|
|
19
42
|
start(port: number): Promise<void>;
|
|
20
43
|
stop(): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* PowerSync reconnects on its own; a connection that stays failed for this
|
|
46
|
+
* long usually needs a fresh process (for example a rotated credential), so
|
|
47
|
+
* exit for the service manager to restart it.
|
|
48
|
+
*/
|
|
49
|
+
private startHealthCheck;
|
|
50
|
+
private startConnectivityWatch;
|
|
51
|
+
/**
|
|
52
|
+
* After a wake from sleep or a network change the stream may be a dead
|
|
53
|
+
* connection that still reports "connected". Reconnect, and treat the cache
|
|
54
|
+
* as unconfirmed until a checkpoint completes on the new stream.
|
|
55
|
+
*/
|
|
56
|
+
handleConnectivityChange(change: ConnectivityChange): Promise<void>;
|
|
57
|
+
/** The reconnect time while no checkpoint has completed since; null once confirmed. */
|
|
58
|
+
currentSyncUnconfirmedSince(): string | null;
|
|
21
59
|
private stopInternal;
|
|
22
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Blocks until no exclusive command holds the intent lock. A respawned
|
|
63
|
+
* daemon must not take the database from the command that just stopped it;
|
|
64
|
+
* the lease is released by the OS if that command dies.
|
|
65
|
+
*/
|
|
66
|
+
export declare function waitForDaemonExclusiveIntent(acquire?: typeof acquireDaemonExclusiveIntentLock): Promise<void>;
|
package/dist/daemon/server.js
CHANGED
|
@@ -2,6 +2,8 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import { createServer } from 'http';
|
|
3
3
|
import { chmodSync, existsSync } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { CONNECTIVITY_CHECK_INTERVAL_MS, createConnectivityWatcher } from './connectivity.js';
|
|
6
|
+
import { commandExecutionTarget } from './forward.js';
|
|
5
7
|
import { DAEMON_SERVICE_ENV } from './supervisor.js';
|
|
6
8
|
import { writeDaemonLogLine } from './log.js';
|
|
7
9
|
import { loadConfig } from '../config/store.js';
|
|
@@ -12,14 +14,21 @@ import { executeNoteSearch } from '../commands/notes/repo.js';
|
|
|
12
14
|
import { getNoteDetail } from '../commands/notes/repo/read.js';
|
|
13
15
|
import { getBacklinks, getGraphStats, getOutlinks, getRelatedNotes } from '../commands/graph/repo.js';
|
|
14
16
|
import { executeTodoSearch } from '../commands/todo/repo.js';
|
|
15
|
-
import { ensureDaemonDir, getDaemonSocketPath, isProcessRunning, readPid, removePid, savePid, } from './pid.js';
|
|
17
|
+
import { acquireDaemonExclusiveIntentLock, ensureDaemonDir, getDaemonSocketPath, isProcessRunning, readPid, removePid, savePid, } from './pid.js';
|
|
16
18
|
import { getPackageVersion } from '../utils/version.js';
|
|
17
19
|
import { syncNoteTokenIndex } from '../powersync/tokenIndex.js';
|
|
18
20
|
import { buildStructuredErrorPayload, DinoxError } from '../utils/errors.js';
|
|
21
|
+
import { requestContext } from '../utils/requestContext.js';
|
|
22
|
+
import { runWithRequestId } from '../requests/store.js';
|
|
19
23
|
import { getDaemonInfo } from './client.js';
|
|
20
24
|
import { GatewayService } from '../gateway/service.js';
|
|
21
25
|
import { readConversation } from '../gateway/conversationContext.js';
|
|
22
26
|
const MAX_DAEMON_REQUEST_BYTES = 1 * 1024 * 1024;
|
|
27
|
+
const DAEMON_HEALTH_CHECK_INTERVAL_MS = 60_000;
|
|
28
|
+
const DAEMON_STUCK_SYNC_RESTART_MS = 15 * 60_000;
|
|
29
|
+
const DAEMON_INTENT_WAIT_CHUNK_MS = 30_000;
|
|
30
|
+
/** EX_TEMPFAIL: the service manager restarts the daemon. */
|
|
31
|
+
export const DAEMON_RESTART_EXIT_CODE = 75;
|
|
23
32
|
function daemonSupervisor() {
|
|
24
33
|
const value = process.env[DAEMON_SERVICE_ENV];
|
|
25
34
|
return value === 'launchd' || value === 'systemd' || value === 'schtasks' ? value : null;
|
|
@@ -66,6 +75,8 @@ function validateLocalRequest(req) {
|
|
|
66
75
|
return { ok: true };
|
|
67
76
|
}
|
|
68
77
|
export class DaemonServer {
|
|
78
|
+
options;
|
|
79
|
+
config = null;
|
|
69
80
|
db = null;
|
|
70
81
|
server = null;
|
|
71
82
|
startTime = Date.now();
|
|
@@ -78,14 +89,30 @@ export class DaemonServer {
|
|
|
78
89
|
ownsArtifacts = false;
|
|
79
90
|
releaseOwner = null;
|
|
80
91
|
stopPromise = null;
|
|
92
|
+
healthTimer = null;
|
|
93
|
+
connectivityTimer = null;
|
|
94
|
+
reconnect = null;
|
|
95
|
+
reconnecting = false;
|
|
96
|
+
syncUnconfirmedSince = null;
|
|
81
97
|
gateways = null;
|
|
98
|
+
constructor(options = {}) {
|
|
99
|
+
this.options = options;
|
|
100
|
+
if (options.runtime) {
|
|
101
|
+
this.db = options.runtime.db;
|
|
102
|
+
this.config = options.runtime.config;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
82
105
|
async initialize() {
|
|
106
|
+
// Before reading config: an exclusive command (e.g. `auth login`) may be
|
|
107
|
+
// changing the saved identity while this respawned daemon waits.
|
|
108
|
+
await waitForDaemonExclusiveIntent(this.options.acquireIntentLock);
|
|
83
109
|
const baseConfig = resolveConfig(await loadConfig());
|
|
84
110
|
const { config } = await hydrateResolvedAuthIdentity(baseConfig, { timeoutMs: baseConfig.sync.timeoutMs });
|
|
111
|
+
this.config = config;
|
|
85
112
|
this.userId = config.auth.userId ?? null;
|
|
86
113
|
this.tokenFingerprint = config.auth.tokenFingerprint ?? null;
|
|
87
114
|
this.runtimeConfigFingerprint = fingerprintDaemonRuntimeConfig(config);
|
|
88
|
-
const { db, releaseOwner } = await connectPowerSync({
|
|
115
|
+
const { db, releaseOwner, reconnect } = await (this.options.connect ?? connectPowerSync)({
|
|
89
116
|
config,
|
|
90
117
|
offline: false,
|
|
91
118
|
timeoutMs: config.sync.timeoutMs,
|
|
@@ -94,6 +121,13 @@ export class DaemonServer {
|
|
|
94
121
|
});
|
|
95
122
|
this.db = db;
|
|
96
123
|
this.releaseOwner = releaseOwner;
|
|
124
|
+
this.reconnect = reconnect ?? null;
|
|
125
|
+
if (process.env[DAEMON_SERVICE_ENV]) {
|
|
126
|
+
this.startHealthCheck(db);
|
|
127
|
+
}
|
|
128
|
+
if (this.reconnect) {
|
|
129
|
+
this.startConnectivityWatch();
|
|
130
|
+
}
|
|
97
131
|
if (config.auth.userId && config.auth.authorization) {
|
|
98
132
|
// Agent gateways share the daemon's PowerSync connection; a gateway
|
|
99
133
|
// failure must never take the daemon down.
|
|
@@ -117,6 +151,43 @@ export class DaemonServer {
|
|
|
117
151
|
}
|
|
118
152
|
const { method, params } = req;
|
|
119
153
|
switch (method) {
|
|
154
|
+
case 'command.execute': {
|
|
155
|
+
const input = params;
|
|
156
|
+
const valid = Boolean(input)
|
|
157
|
+
&& Array.isArray(input.argv)
|
|
158
|
+
&& input.argv.every((arg) => typeof arg === 'string')
|
|
159
|
+
&& typeof input.cwd === 'string'
|
|
160
|
+
&& path.isAbsolute(input.cwd)
|
|
161
|
+
&& typeof input.clientVersion === 'string'
|
|
162
|
+
&& commandExecutionTarget(input.argv) === 'daemon';
|
|
163
|
+
if (!valid) {
|
|
164
|
+
throw new DinoxError('Command cannot run inside the daemon.', { code: 'DAEMON_COMMAND_NOT_FORWARDABLE' });
|
|
165
|
+
}
|
|
166
|
+
if (!this.config)
|
|
167
|
+
throw new Error('Daemon config not initialized');
|
|
168
|
+
const context = {
|
|
169
|
+
stdout: [],
|
|
170
|
+
stderr: [],
|
|
171
|
+
format: null,
|
|
172
|
+
noticeProvider: null,
|
|
173
|
+
cwd: input.cwd,
|
|
174
|
+
daemon: {
|
|
175
|
+
db: this.db,
|
|
176
|
+
config: this.config,
|
|
177
|
+
syncUnconfirmedSince: () => this.currentSyncUnconfirmedSince(),
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
const execute = async () => {
|
|
181
|
+
// Load the command tree only when the request is not a replay.
|
|
182
|
+
const { runCli } = await import('../runCli.js');
|
|
183
|
+
const exitCode = await requestContext.run(context, () => runCli(input.argv));
|
|
184
|
+
return { stdout: context.stdout.join(''), stderr: context.stderr.join(''), exitCode };
|
|
185
|
+
};
|
|
186
|
+
const result = input.requestId === undefined
|
|
187
|
+
? await execute()
|
|
188
|
+
: await runWithRequestId(input.requestId, input.argv, execute);
|
|
189
|
+
return { id: req.id, ok: true, result };
|
|
190
|
+
}
|
|
120
191
|
case 'ping':
|
|
121
192
|
return {
|
|
122
193
|
id: req.id,
|
|
@@ -149,7 +220,9 @@ export class DaemonServer {
|
|
|
149
220
|
});
|
|
150
221
|
}
|
|
151
222
|
const timer = setTimeout(() => {
|
|
152
|
-
void
|
|
223
|
+
void Promise.resolve().then(() => this.options.onStopRequested
|
|
224
|
+
? this.options.onStopRequested('shutdown-request', { requester: shutdownParams.requester })
|
|
225
|
+
: this.stop()).catch(() => undefined);
|
|
153
226
|
}, 10);
|
|
154
227
|
timer.unref?.();
|
|
155
228
|
return { id: req.id, ok: true, result: { stopping: true, instanceId: this.instanceId } };
|
|
@@ -318,7 +391,83 @@ export class DaemonServer {
|
|
|
318
391
|
}
|
|
319
392
|
}
|
|
320
393
|
}
|
|
394
|
+
/**
|
|
395
|
+
* PowerSync reconnects on its own; a connection that stays failed for this
|
|
396
|
+
* long usually needs a fresh process (for example a rotated credential), so
|
|
397
|
+
* exit for the service manager to restart it.
|
|
398
|
+
*/
|
|
399
|
+
startHealthCheck(db) {
|
|
400
|
+
let stuckSince = null;
|
|
401
|
+
this.healthTimer = setInterval(() => {
|
|
402
|
+
const status = getStatusSnapshot(db);
|
|
403
|
+
const failing = !status.connected
|
|
404
|
+
&& Boolean(status.dataFlowStatus.downloadError || status.dataFlowStatus.uploadError);
|
|
405
|
+
if (!failing) {
|
|
406
|
+
stuckSince = null;
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
stuckSince ??= Date.now();
|
|
410
|
+
if (Date.now() - stuckSince < DAEMON_STUCK_SYNC_RESTART_MS)
|
|
411
|
+
return;
|
|
412
|
+
if (this.healthTimer)
|
|
413
|
+
clearInterval(this.healthTimer);
|
|
414
|
+
this.healthTimer = null;
|
|
415
|
+
void Promise.resolve(this.options.onStopRequested?.('powersync-stuck', { exitCode: DAEMON_RESTART_EXIT_CODE }))
|
|
416
|
+
.catch(() => undefined);
|
|
417
|
+
}, DAEMON_HEALTH_CHECK_INTERVAL_MS);
|
|
418
|
+
this.healthTimer.unref();
|
|
419
|
+
}
|
|
420
|
+
startConnectivityWatch() {
|
|
421
|
+
const watcher = createConnectivityWatcher({
|
|
422
|
+
onChange: (change) => void this.handleConnectivityChange(change),
|
|
423
|
+
});
|
|
424
|
+
this.connectivityTimer = setInterval(() => watcher.tick(), CONNECTIVITY_CHECK_INTERVAL_MS);
|
|
425
|
+
this.connectivityTimer.unref();
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* After a wake from sleep or a network change the stream may be a dead
|
|
429
|
+
* connection that still reports "connected". Reconnect, and treat the cache
|
|
430
|
+
* as unconfirmed until a checkpoint completes on the new stream.
|
|
431
|
+
*/
|
|
432
|
+
async handleConnectivityChange(change) {
|
|
433
|
+
if (!this.reconnect || this.reconnecting)
|
|
434
|
+
return;
|
|
435
|
+
this.reconnecting = true;
|
|
436
|
+
this.syncUnconfirmedSince = new Date().toISOString();
|
|
437
|
+
writeDaemonLogLine('dinox-daemon', 'reconnecting sync stream', { reason: change });
|
|
438
|
+
try {
|
|
439
|
+
await this.reconnect();
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
writeDaemonLogLine('dinox-daemon', 'reconnect failed', {
|
|
443
|
+
reason: change,
|
|
444
|
+
error: error instanceof Error ? error.message : String(error),
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
finally {
|
|
448
|
+
this.reconnecting = false;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/** The reconnect time while no checkpoint has completed since; null once confirmed. */
|
|
452
|
+
currentSyncUnconfirmedSince() {
|
|
453
|
+
const since = this.syncUnconfirmedSince;
|
|
454
|
+
if (!since || !this.db)
|
|
455
|
+
return since;
|
|
456
|
+
const status = getStatusSnapshot(this.db);
|
|
457
|
+
// PowerSync reports checkpoint times with whole-second precision.
|
|
458
|
+
const sinceMs = Math.floor(Date.parse(since) / 1_000) * 1_000;
|
|
459
|
+
if (!this.reconnecting && status.connected && status.lastSyncedAt && Date.parse(status.lastSyncedAt) >= sinceMs) {
|
|
460
|
+
this.syncUnconfirmedSince = null;
|
|
461
|
+
}
|
|
462
|
+
return this.syncUnconfirmedSince;
|
|
463
|
+
}
|
|
321
464
|
async stopInternal() {
|
|
465
|
+
if (this.healthTimer)
|
|
466
|
+
clearInterval(this.healthTimer);
|
|
467
|
+
this.healthTimer = null;
|
|
468
|
+
if (this.connectivityTimer)
|
|
469
|
+
clearInterval(this.connectivityTimer);
|
|
470
|
+
this.connectivityTimer = null;
|
|
322
471
|
const gateways = this.gateways;
|
|
323
472
|
this.gateways = null;
|
|
324
473
|
await gateways?.stop().catch(() => undefined);
|
|
@@ -353,3 +502,26 @@ export class DaemonServer {
|
|
|
353
502
|
}
|
|
354
503
|
}
|
|
355
504
|
}
|
|
505
|
+
/**
|
|
506
|
+
* Blocks until no exclusive command holds the intent lock. A respawned
|
|
507
|
+
* daemon must not take the database from the command that just stopped it;
|
|
508
|
+
* the lease is released by the OS if that command dies.
|
|
509
|
+
*/
|
|
510
|
+
export async function waitForDaemonExclusiveIntent(acquire = acquireDaemonExclusiveIntentLock) {
|
|
511
|
+
let logged = false;
|
|
512
|
+
for (;;) {
|
|
513
|
+
try {
|
|
514
|
+
const release = await acquire(DAEMON_INTENT_WAIT_CHUNK_MS);
|
|
515
|
+
await release();
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
if (error.code !== 'DAEMON_EXCLUSIVE_BUSY')
|
|
520
|
+
throw error;
|
|
521
|
+
if (!logged) {
|
|
522
|
+
writeDaemonLogLine('dinox-daemon', 'waiting for exclusive Dinox command to finish');
|
|
523
|
+
logged = true;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
@@ -10,6 +10,7 @@ export type SupervisedExit = {
|
|
|
10
10
|
};
|
|
11
11
|
export type SuperviseDependencies = {
|
|
12
12
|
startChild: () => ChildProcess;
|
|
13
|
+
isStopRequested: () => boolean;
|
|
13
14
|
sleep: (ms: number) => Promise<void>;
|
|
14
15
|
now: () => number;
|
|
15
16
|
log: (message: string, details?: Record<string, unknown>) => void;
|
|
@@ -17,12 +18,11 @@ export type SuperviseDependencies = {
|
|
|
17
18
|
onStopRequest: (handler: () => void) => void;
|
|
18
19
|
};
|
|
19
20
|
/**
|
|
20
|
-
* Keeps one daemon running
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* daemon itself (Windows Task Scheduler).
|
|
21
|
+
* Keeps one daemon running the way launchd's KeepAlive and systemd's
|
|
22
|
+
* Restart=always do: restarts it after every exit, with an exponential
|
|
23
|
+
* backoff for repeated crashes, until `dino daemon stop` leaves the stop
|
|
24
|
+
* marker or the supervisor is signalled. Used where the service manager
|
|
25
|
+
* cannot restart the daemon itself (Windows Task Scheduler).
|
|
26
26
|
*/
|
|
27
27
|
export declare function superviseDaemon(deps: SuperviseDependencies): Promise<number>;
|
|
28
28
|
/** Runs the supervisor for this CLI's daemon until it exits cleanly or is signalled. */
|
package/dist/daemon/supervise.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
2
3
|
import { appendDaemonLogLine, closeDaemonLogFd, openDaemonLogForChild } from './log.js';
|
|
4
|
+
import { getSupervisorStopMarkerPath } from './pid.js';
|
|
3
5
|
/** First restart delay after a crash; doubles per consecutive crash. */
|
|
4
6
|
export const SUPERVISE_INITIAL_BACKOFF_MS = 1_000;
|
|
5
7
|
export const SUPERVISE_MAX_BACKOFF_MS = 60_000;
|
|
@@ -20,12 +22,11 @@ function waitForExit(child) {
|
|
|
20
22
|
});
|
|
21
23
|
}
|
|
22
24
|
/**
|
|
23
|
-
* Keeps one daemon running
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* daemon itself (Windows Task Scheduler).
|
|
25
|
+
* Keeps one daemon running the way launchd's KeepAlive and systemd's
|
|
26
|
+
* Restart=always do: restarts it after every exit, with an exponential
|
|
27
|
+
* backoff for repeated crashes, until `dino daemon stop` leaves the stop
|
|
28
|
+
* marker or the supervisor is signalled. Used where the service manager
|
|
29
|
+
* cannot restart the daemon itself (Windows Task Scheduler).
|
|
29
30
|
*/
|
|
30
31
|
export async function superviseDaemon(deps) {
|
|
31
32
|
let stopping = false;
|
|
@@ -37,7 +38,7 @@ export async function superviseDaemon(deps) {
|
|
|
37
38
|
wakeSleep?.();
|
|
38
39
|
});
|
|
39
40
|
let backoffMs = SUPERVISE_INITIAL_BACKOFF_MS;
|
|
40
|
-
while (!stopping) {
|
|
41
|
+
while (!stopping && !deps.isStopRequested()) {
|
|
41
42
|
const startedAt = deps.now();
|
|
42
43
|
const child = deps.startChild();
|
|
43
44
|
current = child;
|
|
@@ -46,13 +47,13 @@ export async function superviseDaemon(deps) {
|
|
|
46
47
|
current = null;
|
|
47
48
|
if (stopping)
|
|
48
49
|
break;
|
|
49
|
-
if (
|
|
50
|
-
deps.log('
|
|
50
|
+
if (deps.isStopRequested()) {
|
|
51
|
+
deps.log('stop marker found; supervisor stopping');
|
|
51
52
|
return 0;
|
|
52
53
|
}
|
|
53
54
|
if (deps.now() - startedAt >= SUPERVISE_HEALTHY_RUN_MS)
|
|
54
55
|
backoffMs = SUPERVISE_INITIAL_BACKOFF_MS;
|
|
55
|
-
deps.log('daemon
|
|
56
|
+
deps.log('daemon exited; restarting', { code: exit.code, signal: exit.signal, delayMs: backoffMs });
|
|
56
57
|
await Promise.race([
|
|
57
58
|
deps.sleep(backoffMs),
|
|
58
59
|
new Promise((resolve) => {
|
|
@@ -73,6 +74,7 @@ export async function superviseCurrentDaemon(port) {
|
|
|
73
74
|
// Launched hidden at logon there is no console, so write to the log file directly.
|
|
74
75
|
const log = (message, details) => appendDaemonLogLine('dinox-supervisor', message, details);
|
|
75
76
|
return superviseDaemon({
|
|
77
|
+
isStopRequested: () => existsSync(getSupervisorStopMarkerPath()),
|
|
76
78
|
startChild: () => {
|
|
77
79
|
const logFd = openDaemonLogForChild();
|
|
78
80
|
try {
|