-
-
Notifications
You must be signed in to change notification settings - Fork 279
Expand file tree
/
Copy pathdaemon-entry.ts
More file actions
295 lines (267 loc) · 10.2 KB
/
daemon-entry.ts
File metadata and controls
295 lines (267 loc) · 10.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
import type { Json } from '@metamask/utils';
import type { Wallet } from '@metamask/wallet';
import { mkdirSync } from 'node:fs';
import { appendFile, chmod, readFile, rm, writeFile } from 'node:fs/promises';
import { pingDaemon } from './daemon-client';
import { getDaemonPaths } from './paths';
import { startRpcSocketServer } from './rpc-socket-server';
import type { RpcSocketServerHandle } from './rpc-socket-server';
import type { DaemonStatusInfo, RpcHandlerMap } from './types';
import { isErrorWithCode, isProcessAlive, readPidFile } from './utils';
import { createWallet } from './wallet-factory';
const startTime = Date.now();
main().catch((error: unknown) => {
process.stderr.write(`Daemon fatal: ${String(error)}\n`);
process.exitCode = 1;
});
/**
* Main daemon entry point. Starts the daemon process and keeps it running.
*/
async function main(): Promise<void> {
const dataDir = process.env.MM_DAEMON_DATA_DIR;
if (!dataDir) {
throw new Error('MM_DAEMON_DATA_DIR environment variable is required');
}
const infuraProjectId = process.env.INFURA_PROJECT_ID;
if (!infuraProjectId) {
throw new Error('INFURA_PROJECT_ID environment variable is required');
}
const password = process.env.MM_WALLET_PASSWORD;
if (!password) {
throw new Error('MM_WALLET_PASSWORD environment variable is required');
}
const srp = process.env.MM_WALLET_SRP;
if (!srp) {
throw new Error('MM_WALLET_SRP environment variable is required');
}
// 0o700: owner-only. The daemon exposes the full wallet messenger over
// the socket inside this directory, so anyone who can traverse the dir
// can also `connect()` to the socket. Restricting to the owning user is
// the only access-control boundary. We chmod after mkdir because the
// `mode` option is ignored when the directory already exists.
mkdirSync(dataDir, { recursive: true, mode: 0o700 });
await chmod(dataDir, 0o700);
const {
socketPath: defaultSocketPath,
pidPath,
logPath,
dbPath,
} = getDaemonPaths(dataDir);
const socketPath = process.env.MM_DAEMON_SOCKET_PATH ?? defaultSocketPath;
const log = makeLogger(logPath);
log('Starting daemon...');
// Pre-flight: refuse to take over if a responsive daemon already owns this
// socket. If the existing PID file is stale (or the socket is dead), clean
// it up so the exclusive PID-file write below has a chance to succeed.
await claimDaemonSlot(pidPath, socketPath, log);
const pidFileContents = `${process.pid}\n${startTime}\n`;
// Claim the slot atomically BEFORE opening the SQLite database or
// constructing the Wallet. Two concurrent `daemon start` invocations can
// both pass `claimDaemonSlot` (the gap between its preflight and the slot
// write is racy); without this ordering, both would open `wallet.db` and
// both would run first-run SRP import before one loses the wx race.
try {
await writeFile(pidPath, pidFileContents, { flag: 'wx' });
} catch (error) {
throw error instanceof Error
? Object.assign(error, {
message: `Failed to claim daemon slot at ${pidPath}: ${error.message}`,
})
: /* istanbul ignore next -- node:fs/promises always rejects with an Error */
new Error(
`Failed to claim daemon slot at ${pidPath}: ${String(error)}`,
);
}
let wallet: Wallet | undefined;
let dispose: (() => Promise<void>) | undefined;
let handle: RpcSocketServerHandle | undefined;
try {
({ wallet, dispose } = await createWallet({
databasePath: dbPath,
infuraProjectId,
password,
srp,
log,
}));
const constructedWallet = wallet;
const handlers: RpcHandlerMap = {
getStatus: async (): Promise<DaemonStatusInfo> => ({
pid: process.pid,
uptime: Math.floor((Date.now() - startTime) / 1000),
}),
// Arbitrary messenger dispatch is intentional: the CLI exposes the full
// messenger surface over a Unix socket inside the per-user oclif data
// directory. The dataDir/socket are chmodded to 0o700/0o600 below so
// only the owning user can open them, but there is no in-process
// auth check beyond that filesystem-permission barrier.
call: async (params) => {
if (!Array.isArray(params) || typeof params[0] !== 'string') {
throw new Error('Expected params to be an array with an action name');
}
const [action, ...args] = params as [string, ...Json[]];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- The messenger is strongly typed; we bypass it here to dispatch arbitrary action names from RPC.
const result = (constructedWallet.messenger as any).call(
action,
...args,
);
return (result instanceof Promise ? await result : result) as Json;
},
};
handle = await startRpcSocketServer({
socketPath,
handlers,
onShutdown: async () => shutdown('RPC shutdown'),
log,
});
// Restrict the socket to the owner. listen() emits 'listening'
// synchronously, so this runs before any client can connect.
await chmod(socketPath, 0o600);
} catch (error) {
if (dispose) {
await dispose();
}
// Only remove the PID file if it's still ours (we may have lost the race
// and the file now belongs to another daemon).
await removeOwnedPidFile(pidPath, pidFileContents).catch(
(rmError: unknown) => {
log(`Failed to remove PID file during cleanup: ${String(rmError)}`);
},
);
throw error;
}
// Capture the now-resolved bindings so the shutdown closures below have
// a stable, non-undefined reference (TS narrowing across closure escape).
const activeHandle = handle;
const activeDispose = dispose;
log(`Daemon started. Socket: ${socketPath}`);
let shutdownPromise: Promise<void> | undefined;
/**
* Shut down the daemon idempotently. Concurrent calls coalesce.
*
* @param reason - A label describing why shutdown was triggered.
* @returns A promise that resolves when shutdown completes.
*/
async function shutdown(reason: string): Promise<void> {
if (shutdownPromise === undefined) {
log(`Shutting down (${reason})...`);
shutdownPromise = (async (): Promise<void> => {
try {
await activeHandle.close();
} catch (closeError) {
log(`handle.close() failed: ${String(closeError)}`);
}
await activeDispose();
await Promise.all([
removeOwnedPidFile(pidPath, pidFileContents).catch(
(rmError: unknown) => {
log(`Failed to remove PID file: ${String(rmError)}`);
},
),
rm(socketPath, { force: true }).catch((rmError: unknown) => {
log(`Failed to remove socket file: ${String(rmError)}`);
}),
]);
})();
}
return shutdownPromise;
}
process.on('SIGTERM', () => {
/* istanbul ignore next */
shutdown('SIGTERM').catch(() => undefined);
});
process.on('SIGINT', () => {
/* istanbul ignore next */
shutdown('SIGINT').catch(() => undefined);
});
}
/**
* Refuse to start if a responsive daemon already owns the socket. Otherwise
* clear any stale PID/socket files so the exclusive PID-file write can
* proceed.
*
* @param pidPath - The PID file path.
* @param socketPath - The socket path.
* @param log - Logger for diagnostic messages.
*/
async function claimDaemonSlot(
pidPath: string,
socketPath: string,
log: (message: string) => void,
): Promise<void> {
const existingPid = await readPidFile(pidPath);
const ping = await pingDaemon(socketPath);
if (ping.status === 'responsive') {
const pidPart =
existingPid === undefined
? '(no PID file present)'
: `(pid ${existingPid})`;
throw new Error(`A daemon is already running on ${socketPath} ${pidPart}`);
}
// Refuse to clobber when the recorded PID is still alive, regardless of
// whether the socket exists. Possible scenarios:
// - `unreachable`: wedged or mid-startup sibling daemon (socket present
// but not responding to JSON-RPC).
// - `absent`: a sibling daemon that hasn't yet bound its socket, or one
// whose socket was manually removed. In either case, removing its PID
// file would orphan it from `daemon stop`.
if (existingPid !== undefined && isProcessAlive(existingPid)) {
const detail =
ping.status === 'unreachable'
? `socket at ${socketPath} is unresponsive (${ping.error.message})`
: `no socket at ${socketPath}, but pid is still alive`;
throw new Error(
`A daemon is already running (pid ${existingPid}): ${detail}. ` +
`Run \`mm daemon stop\` (or \`mm daemon purge\`) before starting a new daemon.`,
);
}
if (ping.status === 'unreachable') {
log(`Removing stale socket at ${socketPath} (${ping.error.message}).`);
}
// Always clear both files before claiming the slot. The PID file may be
// corrupt (truncated, partial write from a crashed run); without this, the
// exclusive `wx` write below would fail with EEXIST and the daemon could
// not start until a human manually deleted the file.
await Promise.all([
rm(pidPath, { force: true }),
rm(socketPath, { force: true }),
]);
}
/**
* Remove the PID file only if it still contains our exact contents. Guards
* against a racing daemon's PID file being removed by this daemon during
* cleanup.
*
* @param pidPath - Path to the PID file.
* @param expectedContents - The contents we wrote when claiming the slot.
*/
async function removeOwnedPidFile(
pidPath: string,
expectedContents: string,
): Promise<void> {
let actual: string;
try {
actual = await readFile(pidPath, 'utf-8');
} catch (error: unknown) {
if (isErrorWithCode(error, 'ENOENT')) {
return;
}
throw error;
}
if (actual === expectedContents) {
await rm(pidPath, { force: true });
}
}
/**
* Create a simple file logger.
*
* @param logPath - The log file path.
* @returns A logging function.
*/
function makeLogger(logPath: string): (message: string) => void {
return (message: string): void => {
const line = `[${new Date().toISOString()}] ${message}\n`;
appendFile(logPath, line).catch((error: unknown) => {
process.stderr.write(`[log write failed: ${String(error)}] ${message}\n`);
});
};
}