@anhlp/alp-client 0.0.0-beta.2 → 0.0.1-beta.3

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/index.d.ts CHANGED
@@ -68,17 +68,32 @@ export function connect(socketPath: string, client?: {
68
68
  name: string;
69
69
  version: string;
70
70
  }, options?: import("./rpc/client.js").AlpRpcClientOptions & import("./ipc.js").SocketTransportOptions): Promise<AlpClient>;
71
+ export function readDaemonLifecycle(home: any): Promise<{
72
+ token: string;
73
+ pid: number;
74
+ bootTime: number;
75
+ action: string;
76
+ } | undefined>;
77
+ /** Serializes requested starts and stops, including the time before alpd has a lock.
78
+ * Rename publishes a fully populated directory: an existing nonempty directory cannot
79
+ * be replaced. Reaping/release unlinks only its unique owner token, then rmdir; even
80
+ * racing stale reapers cannot remove a successor's nonempty directory.
81
+ */
82
+ export function withDaemonLifecycle(home: any, action: any, run: any, { joinStart }?: {
83
+ joinStart?: boolean | undefined;
84
+ }): Promise<any>;
71
85
  /**
72
86
  * Returns the socket of a ready daemon, starting one when none runs.
73
87
  * `entry` is the daemon script; it is started through its own detaching launcher so the
74
88
  * daemon outlives the client that started it.
75
89
  */
76
- export function ensureDaemon({ home, entry, env, execPath, timeoutMs }?: {
90
+ export function ensureDaemon({ home, entry, env, execPath, timeoutMs, lifecycleLocked }?: {
77
91
  home?: string | undefined;
78
92
  entry?: string | undefined;
79
93
  env?: NodeJS.ProcessEnv | undefined;
80
94
  execPath?: string | undefined;
81
95
  timeoutMs?: number | undefined;
96
+ lifecycleLocked?: boolean | undefined;
82
97
  }): Promise<any>;
83
98
  export function discoverIntegration(name?: string, options?: {}): Promise<string | undefined>;
84
99
  export function startIntegration(nameOrOptions?: string): Promise<any>;
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // packages/client/src/index.js
2
2
  import { spawn } from "node:child_process";
3
- import { createHash } from "node:crypto";
4
- import { open, readFile, realpath, stat, lstat, mkdir, readlink, unlink } from "node:fs/promises";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { open, readFile, writeFile, realpath, stat, lstat, mkdir, readlink, unlink, readdir, rename, rmdir, link } from "node:fs/promises";
5
5
  import net from "node:net";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
@@ -197,10 +197,86 @@ async function tail(file, lines = 30) {
197
197
  return "";
198
198
  }
199
199
  }
200
+ async function lifecycleOwners(home) {
201
+ const directory = path.join(home, "state", "alpd.starting");
202
+ const names = await readdir(directory).catch((error) => {
203
+ if (error.code === "ENOENT") return [];
204
+ throw error;
205
+ });
206
+ return names.map((token) => {
207
+ const match = /^owner-(\d+)-(\d+)-(start|stop)-[a-f0-9-]+$/.exec(token);
208
+ if (!match) throw new Error("Invalid alpd startup admission owner");
209
+ return { token, pid: Number(match[1]), bootTime: Number(match[2]), action: match[3] };
210
+ });
211
+ }
212
+ async function readDaemonLifecycle(home) {
213
+ return (await lifecycleOwners(home)).find((owner) => lockAlive(owner));
214
+ }
215
+ async function withDaemonLifecycle(home, action, run, { joinStart = false } = {}) {
216
+ try {
217
+ await assertPrivateDirectory(home, { create: true });
218
+ } catch (cause) {
219
+ throw Object.assign(new Error(`ALP_HOME_UNUSABLE, alpd failed to start while preparing its home: ${cause.message}`, { cause }), { code: "ALP_HOME_UNUSABLE" });
220
+ }
221
+ const state = path.join(home, "state");
222
+ await assertPrivateDirectory(state, { create: true });
223
+ const directory = path.join(state, "alpd.starting");
224
+ const token = `owner-${process.pid}-${bootTime()}-${action}-${randomUUID()}`;
225
+ const candidate = path.join(state, `alpd.starting-${token}`);
226
+ await mkdir(candidate, { mode: 448 });
227
+ await writeFile(path.join(candidate, token), JSON.stringify({ pid: process.pid, bootTime: bootTime(), action }), { mode: 384 });
228
+ try {
229
+ let joined = false;
230
+ if (joinStart) {
231
+ const owner = await readDaemonLifecycle(home);
232
+ if (owner?.action === "start") {
233
+ try {
234
+ await link(path.join(directory, owner.token), path.join(directory, token));
235
+ joined = true;
236
+ } catch (error) {
237
+ if (error.code !== "ENOENT") throw error;
238
+ }
239
+ }
240
+ }
241
+ while (!joined) {
242
+ try {
243
+ await rename(candidate, directory);
244
+ break;
245
+ } catch (error) {
246
+ if (!["EEXIST", "ENOTEMPTY", "EPERM", "EACCES"].includes(error.code)) throw error;
247
+ for (const owner of await lifecycleOwners(home)) {
248
+ if (!lockAlive(owner)) await unlink(path.join(directory, owner.token)).catch((error2) => {
249
+ if (error2.code !== "ENOENT") throw error2;
250
+ });
251
+ }
252
+ await rmdir(directory).catch((error2) => {
253
+ if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error2.code)) throw error2;
254
+ });
255
+ await new Promise((resolve) => setTimeout(resolve, 25));
256
+ }
257
+ }
258
+ try {
259
+ return await run();
260
+ } finally {
261
+ await unlink(path.join(directory, token));
262
+ await rmdir(directory).catch((error) => {
263
+ if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code)) throw error;
264
+ });
265
+ }
266
+ } finally {
267
+ await unlink(path.join(candidate, token)).catch((error) => {
268
+ if (error.code !== "ENOENT") throw error;
269
+ });
270
+ await rmdir(candidate).catch((error) => {
271
+ if (error.code !== "ENOENT") throw error;
272
+ });
273
+ }
274
+ }
200
275
  async function ensureDaemon({ home = alpHome(), entry = (
201
276
  /** @type {string | undefined} */
202
277
  void 0
203
- ), env = process.env, execPath = process.execPath, timeoutMs = 15e3 } = {}) {
278
+ ), env = process.env, execPath = process.execPath, timeoutMs = 15e3, lifecycleLocked = false } = {}) {
279
+ if (!lifecycleLocked) return withDaemonLifecycle(home, "start", () => ensureDaemon({ home, entry, env, execPath, timeoutMs, lifecycleLocked: true }));
204
280
  const ready = async () => {
205
281
  const lock = await readLock(home);
206
282
  return lockAlive(lock) && lock.ready ? lock.socket : void 0;
@@ -213,7 +289,7 @@ async function ensureDaemon({ home = alpHome(), entry = (
213
289
  entry ??= await findDaemonEntry({ home, env });
214
290
  if (!entry) throw new Error("ALP daemon is not running and alpd.js was not found. Install the ALP CLI and run `alp daemon start`, or set ALP_DAEMON_ENTRY to alpd.js");
215
291
  await new Promise((resolve, reject) => {
216
- const launcher = spawn(execPath, [entry, "--detach"], {
292
+ const launcher = spawn(execPath, [entry, "--detach", `--startup-timeout=${timeoutMs}`], {
217
293
  env: { ...env, ALP_HOME: home, ELECTRON_RUN_AS_NODE: "1" },
218
294
  stdio: ["ignore", "ignore", "pipe"],
219
295
  windowsHide: true
@@ -415,8 +491,10 @@ export {
415
491
  ensureDaemon,
416
492
  findDaemonEntry,
417
493
  lockAlive,
494
+ readDaemonLifecycle,
418
495
  readLock,
419
496
  redactUrl,
420
497
  releaseDaemonLock,
421
- startIntegration
498
+ startIntegration,
499
+ withDaemonLifecycle
422
500
  };
package/dist/supervise.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // packages/client/src/supervise.js
2
2
  import { mkdir, unlink, writeFile, stat } from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { ensureDaemon, lockAlive, readLock } from "./index.js";
4
+ import { ensureDaemon, lockAlive, readLock, withDaemonLifecycle } from "./index.js";
5
5
  import { installedProgram, serviceFor, startService } from "./service.js";
6
6
  var holdFile = (home) => path.join(home, "state", "alpd.held");
7
7
  async function holdDaemon(home, by = "alp daemon stop") {
@@ -24,16 +24,20 @@ async function whenReady(home, timeoutMs) {
24
24
  }
25
25
  throw new Error(`alpd did not become ready within ${timeoutMs / 1e3} s; see ${path.join(home, "logs")}`);
26
26
  }
27
- async function startDaemon({ home, entry, env = process.env, timeoutMs = 15e3 }) {
28
- await releaseHold(home);
29
- const lock = await readLock(home);
30
- if (lockAlive(lock)) return lock.ready ? lock.socket : whenReady(home, timeoutMs);
31
- const service = serviceFor({ home, env });
32
- if (service && await installedProgram(service)) {
33
- await startService(service);
34
- return whenReady(home, timeoutMs);
35
- }
36
- return ensureDaemon({ home, entry, env, timeoutMs });
27
+ async function startDaemon({ home, entry, env = process.env, timeoutMs = 15e3, automatic = false }) {
28
+ return withDaemonLifecycle(home, "start", async () => {
29
+ if (automatic) {
30
+ if (await daemonHeld(home)) return;
31
+ } else await releaseHold(home);
32
+ const lock = await readLock(home);
33
+ if (lockAlive(lock)) return lock.ready ? lock.socket : whenReady(home, timeoutMs);
34
+ const service = serviceFor({ home, env });
35
+ if (service && await installedProgram(service)) {
36
+ await startService(service);
37
+ return whenReady(home, timeoutMs);
38
+ }
39
+ return ensureDaemon({ home, entry, env, timeoutMs, lifecycleLocked: true });
40
+ });
37
41
  }
38
42
  function superviseDaemon({ home, entry, env = process.env, intervalMs = 5e3, misses = 2, log = () => {
39
43
  } }) {
@@ -41,7 +45,9 @@ function superviseDaemon({ home, entry, env = process.env, intervalMs = 5e3, mis
41
45
  let missed = 0;
42
46
  let starting;
43
47
  const start = (reason) => {
44
- starting ??= startDaemon({ home, entry: typeof entry === "function" ? entry() : entry, env }).then(() => log(`alpd ${reason}`), (error) => log(`could not start alpd: ${error?.message ?? error}`)).finally(() => {
48
+ starting ??= startDaemon({ home, entry: typeof entry === "function" ? entry() : entry, env, automatic: reason !== "started with Paseo" }).then((socket) => {
49
+ if (socket) log(`alpd ${reason}`);
50
+ }, (error) => log(`could not start alpd: ${error?.message ?? error}`)).finally(() => {
45
51
  starting = void 0;
46
52
  missed = 0;
47
53
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anhlp/alp-client",
3
- "version": "0.0.0-beta.2",
3
+ "version": "0.0.1-beta.3",
4
4
  "type": "module",
5
5
  "description": "ALP client: portable JSON-RPC SDK, WebSocket and Node IPC transports, daemon discovery and setup",
6
6
  "files": [
@@ -142,8 +142,8 @@
142
142
  "node": ">=22.12"
143
143
  },
144
144
  "dependencies": {
145
- "@anhlp/alp-core": "0.0.0-beta.2",
146
- "@anhlp/alp-protocol": "0.0.0-beta.2"
145
+ "@anhlp/alp-core": "0.0.1-beta.3",
146
+ "@anhlp/alp-protocol": "0.0.1-beta.3"
147
147
  },
148
148
  "scripts": {
149
149
  "build": "node ../../scripts/build-workspace.mjs client",
package/src/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { createHash } from 'node:crypto';
3
- import { open, readFile, realpath, stat, lstat, mkdir, readlink, unlink } from 'node:fs/promises';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { open, readFile, writeFile, realpath, stat, lstat, mkdir, readlink, unlink, readdir, rename, rmdir, link } from 'node:fs/promises';
4
4
  import net from 'node:net';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
@@ -242,12 +242,83 @@ async function tail(file, lines = 30) {
242
242
  try { return scrubText((await readFile(file, 'utf8')).split('\n').slice(-lines).join('\n'), { max: 8000 }); } catch { return ''; }
243
243
  }
244
244
 
245
+ /** Immutable admission identities live in filenames, including for service hard links. */
246
+ async function lifecycleOwners(home) {
247
+ const directory = path.join(home, 'state', 'alpd.starting');
248
+ const names = await readdir(directory).catch(error => { if (error.code === 'ENOENT') return []; throw error; });
249
+ return names.map(token => {
250
+ const match = /^owner-(\d+)-(\d+)-(start|stop)-[a-f0-9-]+$/.exec(token);
251
+ if (!match) throw new Error('Invalid alpd startup admission owner');
252
+ return { token, pid: Number(match[1]), bootTime: Number(match[2]), action: match[3] };
253
+ });
254
+ }
255
+
256
+ export async function readDaemonLifecycle(home) {
257
+ return (await lifecycleOwners(home)).find(owner => lockAlive(owner));
258
+ }
259
+
260
+ /** Serializes requested starts and stops, including the time before alpd has a lock.
261
+ * Rename publishes a fully populated directory: an existing nonempty directory cannot
262
+ * be replaced. Reaping/release unlinks only its unique owner token, then rmdir; even
263
+ * racing stale reapers cannot remove a successor's nonempty directory.
264
+ */
265
+ export async function withDaemonLifecycle(home, action, run, { joinStart = false } = {}) {
266
+ try {
267
+ await assertPrivateDirectory(home, { create: true });
268
+ } catch (cause) {
269
+ // Admission now prepares the home before the launcher can report this failure.
270
+ // Preserve the operator-facing startup code at this earlier boundary.
271
+ throw Object.assign(new Error(`ALP_HOME_UNUSABLE, alpd failed to start while preparing its home: ${cause.message}`, { cause }), { code: 'ALP_HOME_UNUSABLE' });
272
+ }
273
+ const state = path.join(home, 'state');
274
+ await assertPrivateDirectory(state, { create: true });
275
+ const directory = path.join(state, 'alpd.starting');
276
+ const token = `owner-${process.pid}-${bootTime()}-${action}-${randomUUID()}`;
277
+ const candidate = path.join(state, `alpd.starting-${token}`);
278
+ await mkdir(candidate, { mode: 0o700 });
279
+ await writeFile(path.join(candidate, token), JSON.stringify({ pid: process.pid, bootTime: bootTime(), action }), { mode: 0o600 });
280
+ try {
281
+ let joined = false;
282
+ if (joinStart) {
283
+ const owner = await readDaemonLifecycle(home);
284
+ if (owner?.action === 'start') {
285
+ try {
286
+ // Linking the unique source token both validates this generation and
287
+ // pins its directory until this service has finished starting.
288
+ await link(path.join(directory, owner.token), path.join(directory, token));
289
+ joined = true;
290
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
291
+ }
292
+ }
293
+ while (!joined) {
294
+ try { await rename(candidate, directory); break; }
295
+ catch (error) {
296
+ if (!['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(error.code)) throw error;
297
+ for (const owner of await lifecycleOwners(home)) {
298
+ if (!lockAlive(owner)) await unlink(path.join(directory, owner.token)).catch(error => { if (error.code !== 'ENOENT') throw error; });
299
+ }
300
+ await rmdir(directory).catch(error => { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error; });
301
+ await new Promise(resolve => setTimeout(resolve, 25));
302
+ }
303
+ }
304
+ try { return await run(); }
305
+ finally {
306
+ await unlink(path.join(directory, token));
307
+ await rmdir(directory).catch(error => { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error; });
308
+ }
309
+ } finally {
310
+ await unlink(path.join(candidate, token)).catch(error => { if (error.code !== 'ENOENT') throw error; });
311
+ await rmdir(candidate).catch(error => { if (error.code !== 'ENOENT') throw error; });
312
+ }
313
+ }
314
+
245
315
  /**
246
316
  * Returns the socket of a ready daemon, starting one when none runs.
247
317
  * `entry` is the daemon script; it is started through its own detaching launcher so the
248
318
  * daemon outlives the client that started it.
249
319
  */
250
- export async function ensureDaemon({ home = alpHome(), entry = /** @type {string | undefined} */ (undefined), env = process.env, execPath = process.execPath, timeoutMs = 15_000 } = {}) {
320
+ export async function ensureDaemon({ home = alpHome(), entry = /** @type {string | undefined} */ (undefined), env = process.env, execPath = process.execPath, timeoutMs = 15_000, lifecycleLocked = false } = {}) {
321
+ if (!lifecycleLocked) return withDaemonLifecycle(home, 'start', () => ensureDaemon({ home, entry, env, execPath, timeoutMs, lifecycleLocked: true }));
251
322
  const ready = async () => {
252
323
  const lock = await readLock(home);
253
324
  return lockAlive(lock) && lock.ready ? lock.socket : undefined;
@@ -260,7 +331,7 @@ export async function ensureDaemon({ home = alpHome(), entry = /** @type {string
260
331
  entry ??= await findDaemonEntry({ home, env });
261
332
  if (!entry) throw new Error('ALP daemon is not running and alpd.js was not found. Install the ALP CLI and run `alp daemon start`, or set ALP_DAEMON_ENTRY to alpd.js');
262
333
  await new Promise((resolve, reject) => {
263
- const launcher = spawn(execPath, [entry, '--detach'], {
334
+ const launcher = spawn(execPath, [entry, '--detach', `--startup-timeout=${timeoutMs}`], {
264
335
  env: { ...env, ALP_HOME: home, ELECTRON_RUN_AS_NODE: '1' },
265
336
  stdio: ['ignore', 'ignore', 'pipe'],
266
337
  windowsHide: true,
package/src/supervise.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { mkdir, unlink, writeFile, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { ensureDaemon, lockAlive, readLock } from './index.js';
3
+ import { ensureDaemon, lockAlive, readLock, withDaemonLifecycle } from './index.js';
4
4
  import { installedProgram, serviceFor, startService } from './service.js';
5
5
 
6
6
  /**
@@ -41,16 +41,20 @@ async function whenReady(home, timeoutMs) {
41
41
  * Starts alpd for `home` unless it runs, and lifts a hold: someone asked for it.
42
42
  * Returns its socket.
43
43
  */
44
- export async function startDaemon({ home, entry, env = process.env, timeoutMs = 15_000 }) {
45
- await releaseHold(home);
46
- const lock = await readLock(home);
47
- if (lockAlive(lock)) return lock.ready ? lock.socket : whenReady(home, timeoutMs);
48
- const service = serviceFor({ home, env });
49
- if (service && await installedProgram(service)) {
50
- await startService(service);
51
- return whenReady(home, timeoutMs);
52
- }
53
- return ensureDaemon({ home, entry, env, timeoutMs });
44
+ export async function startDaemon({ home, entry, env = process.env, timeoutMs = 15_000, automatic = false }) {
45
+ return withDaemonLifecycle(home, 'start', async () => {
46
+ if (automatic) {
47
+ if (await daemonHeld(home)) return;
48
+ } else await releaseHold(home);
49
+ const lock = await readLock(home);
50
+ if (lockAlive(lock)) return lock.ready ? lock.socket : whenReady(home, timeoutMs);
51
+ const service = serviceFor({ home, env });
52
+ if (service && await installedProgram(service)) {
53
+ await startService(service);
54
+ return whenReady(home, timeoutMs);
55
+ }
56
+ return ensureDaemon({ home, entry, env, timeoutMs, lifecycleLocked: true });
57
+ });
54
58
  }
55
59
 
56
60
  /**
@@ -64,8 +68,8 @@ export function superviseDaemon({ home, entry, env = process.env, intervalMs = 5
64
68
  let missed = 0;
65
69
  let starting;
66
70
  const start = reason => {
67
- starting ??= startDaemon({ home, entry: typeof entry === 'function' ? entry() : entry, env })
68
- .then(() => log(`alpd ${reason}`), error => log(`could not start alpd: ${error?.message ?? error}`))
71
+ starting ??= startDaemon({ home, entry: typeof entry === 'function' ? entry() : entry, env, automatic: reason !== 'started with Paseo' })
72
+ .then(socket => { if (socket) log(`alpd ${reason}`); }, error => log(`could not start alpd: ${error?.message ?? error}`))
69
73
  .finally(() => { starting = undefined; missed = 0; });
70
74
  return starting;
71
75
  };