@north-light/api 0.3.434 → 0.3.436
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.
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { InputError } from '../io.js';
|
|
2
|
+
export declare function joinLockPath(): string;
|
|
3
|
+
/** The live process holding the lock, or null when no join is running (no lock, or a holder that died). */
|
|
4
|
+
export declare function joinLockHolder(): number | null;
|
|
5
|
+
/** Whether a join is in progress. The joiner starts its own daemon through `spawnDaemon`, never through the implicit paths that ask this. */
|
|
6
|
+
export declare function joinInProgress(): boolean;
|
|
7
|
+
export declare function joinInProgressError(pid: number | null): InputError;
|
|
8
|
+
/** Resolve once no other live process holds the join lock, or after `timeoutMs`. For a start that must happen
|
|
9
|
+
* but must read the settings a join is about to commit (a daemon handover's successor): it waits the join out
|
|
10
|
+
* instead of bypassing the lock, so nothing but the join starts a daemon while the lock is held. */
|
|
11
|
+
export declare function awaitJoinLockRelease(timeoutMs: number, pollMs?: number): Promise<void>;
|
|
12
|
+
/** Run `operation` holding the join lock. Throws `join_in_progress` when a live process holds it. */
|
|
13
|
+
export declare function withJoinLock<T>(operation: () => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The "join in progress" lock. `crtr sys join` stops the daemon, rewrites local
|
|
2
|
+
// identity, and starts the daemon again. Stopping the daemon kills every broker,
|
|
3
|
+
// so every open viewer sees a cold socket and its client would autostart a new
|
|
4
|
+
// daemon within seconds, which makes the join refuse (`daemon_running`). While
|
|
5
|
+
// this lock is held by a live process, `ensureDaemon` and `spawnDaemon` (so also
|
|
6
|
+
// `sys daemon start` and a handover successor) decline to start a daemon.
|
|
7
|
+
//
|
|
8
|
+
// It is the shared token-checked directory lock (`core/exclusive-lock.ts`): the
|
|
9
|
+
// holder's pid is in its marker's name, a dead holder is reclaimed by exactly the
|
|
10
|
+
// marker it observed (so a lagging reclaimer can never strip a live successor),
|
|
11
|
+
// and only the holder's own marker releases it. PURITY: Node built-ins, path
|
|
12
|
+
// builders and the pid probe only, so `daemon/manage.ts` can import it.
|
|
13
|
+
import { mkdirSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { crtrHome } from '../canvas/paths.js';
|
|
16
|
+
import { isPidAlive } from '../canvas/pid.js';
|
|
17
|
+
import { exclusiveLockOwnerPid, withExclusiveDirectoryLockAsync } from '../exclusive-lock.js';
|
|
18
|
+
import { InputError } from '../io.js';
|
|
19
|
+
/** How long a second joiner waits for the first before it refuses. Long enough to reclaim a dead holder's lock. */
|
|
20
|
+
const ACQUIRE_WAIT_MS = 1_000;
|
|
21
|
+
export function joinLockPath() {
|
|
22
|
+
return join(crtrHome(), 'join.lock');
|
|
23
|
+
}
|
|
24
|
+
/** The live process holding the lock, or null when no join is running (no lock, or a holder that died). */
|
|
25
|
+
export function joinLockHolder() {
|
|
26
|
+
const pid = exclusiveLockOwnerPid(joinLockPath());
|
|
27
|
+
return pid !== null && isPidAlive(pid) ? pid : null;
|
|
28
|
+
}
|
|
29
|
+
/** Whether a join is in progress. The joiner starts its own daemon through `spawnDaemon`, never through the implicit paths that ask this. */
|
|
30
|
+
export function joinInProgress() {
|
|
31
|
+
return joinLockHolder() !== null;
|
|
32
|
+
}
|
|
33
|
+
export function joinInProgressError(pid) {
|
|
34
|
+
return new InputError({
|
|
35
|
+
error: 'join_in_progress',
|
|
36
|
+
message: pid === null
|
|
37
|
+
? '`crtr sys join` is running and holds the daemon down until it finishes'
|
|
38
|
+
: `\`crtr sys join\` (pid ${pid}) is running and holds the daemon down until it finishes`,
|
|
39
|
+
next: 'Wait for the join to finish; it starts the daemon again itself. If it is stuck, `crtr sys daemon start --force`.',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/** Resolve once no other live process holds the join lock, or after `timeoutMs`. For a start that must happen
|
|
43
|
+
* but must read the settings a join is about to commit (a daemon handover's successor): it waits the join out
|
|
44
|
+
* instead of bypassing the lock, so nothing but the join starts a daemon while the lock is held. */
|
|
45
|
+
export async function awaitJoinLockRelease(timeoutMs, pollMs = 100) {
|
|
46
|
+
const deadline = Date.now() + timeoutMs;
|
|
47
|
+
for (;;) {
|
|
48
|
+
const holder = joinLockHolder();
|
|
49
|
+
if (holder === null || holder === process.pid || Date.now() >= deadline)
|
|
50
|
+
return;
|
|
51
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Run `operation` holding the join lock. Throws `join_in_progress` when a live process holds it. */
|
|
55
|
+
export async function withJoinLock(operation) {
|
|
56
|
+
mkdirSync(crtrHome(), { recursive: true });
|
|
57
|
+
return withExclusiveDirectoryLockAsync(joinLockPath(), operation, {
|
|
58
|
+
timeoutMs: ACQUIRE_WAIT_MS,
|
|
59
|
+
timeoutError: () => joinInProgressError(joinLockHolder()),
|
|
60
|
+
});
|
|
61
|
+
}
|
package/dist/core/io.js
CHANGED
|
@@ -313,7 +313,7 @@ export function apiErrorToCliError(err, next) {
|
|
|
313
313
|
? d
|
|
314
314
|
: {
|
|
315
315
|
error: STATUS_TO_CLI_CODE[err.status] ?? err.code,
|
|
316
|
-
message: violations.length > 0 ? `${err.message}: ${violations.join('; ')}` : err.message,
|
|
316
|
+
message: violations.length > 0 ? `${err.message}: ${violations.join('; ')}` : err.message.trim() !== '' ? err.message : `request refused (HTTP ${err.status}, ${err.code}) with no message`,
|
|
317
317
|
next: next ??
|
|
318
318
|
(err.status >= 500
|
|
319
319
|
? 'This is a crtr bug, not a bad call. Retry; if it persists, report it (the daemon log holds the full cause).'
|
package/dist/daemon/manage.d.ts
CHANGED
|
@@ -43,6 +43,10 @@ export declare function execDaemon(): Promise<never>;
|
|
|
43
43
|
export interface SpawnDaemonOptions {
|
|
44
44
|
/** The phase that proves a daemon launched by this call is available. */
|
|
45
45
|
targetPhase?: 'ready' | 'prepared';
|
|
46
|
+
/** Start even while another process's `crtr sys join` holds the daemon down. */
|
|
47
|
+
ignoreJoinLock?: boolean;
|
|
48
|
+
/** While another process's `crtr sys join` holds the daemon down, wait for it to finish, then start. For a handover successor. */
|
|
49
|
+
waitForJoinLock?: boolean;
|
|
46
50
|
}
|
|
47
51
|
export interface SpawnDaemonResult {
|
|
48
52
|
/** True when a new daemon process was spawned. */
|
|
@@ -136,6 +140,14 @@ export declare function waitForDaemonExit(pid: number, timeoutMs?: number, deps?
|
|
|
136
140
|
*
|
|
137
141
|
* Returns how the daemon went down, for a truthful report. */
|
|
138
142
|
export declare function stopDaemonProcess(pid: number, timeoutMs?: number, deps?: DaemonWaitDeps): Promise<'terminated' | 'killed'>;
|
|
143
|
+
/** Stop the recorded daemon and sweep every stray crtrd of this canvas home.
|
|
144
|
+
* Resolves once none remains. Shared by `crtr sys daemon stop` and `crtr sys join`. */
|
|
145
|
+
export declare function stopAllDaemons(): Promise<{
|
|
146
|
+
stopped: boolean;
|
|
147
|
+
pid?: number;
|
|
148
|
+
strays_killed: number[];
|
|
149
|
+
force_killed: boolean;
|
|
150
|
+
}>;
|
|
139
151
|
/** Spawn crtrd detached. Returns immediately; the child outlives this process.
|
|
140
152
|
*
|
|
141
153
|
* If the daemon is already running, returns {started:false, existing_pid}.
|
|
@@ -169,7 +181,8 @@ export interface EnsureDaemonDeps {
|
|
|
169
181
|
* rather than at the end of the startup window. */
|
|
170
182
|
export declare function daemonAutostartStartupFailure(): string | undefined;
|
|
171
183
|
/** Start the daemon if it is not already running. No-op if already up, if
|
|
172
|
-
* autostart is suppressed for this invocation, while
|
|
184
|
+
* autostart is suppressed for this invocation, while `crtr sys join` holds
|
|
185
|
+
* the join lock (see core/directory/join-lock.ts), while an attempt is in flight,
|
|
173
186
|
* or while backing off from a recent failure.
|
|
174
187
|
* Silently swallows spawn errors (the canvas still works without the daemon;
|
|
175
188
|
* nodes just won't be auto-revived). */
|
package/dist/daemon/manage.js
CHANGED
|
@@ -19,6 +19,7 @@ import { DAEMON_STARTUP_WINDOW_MS } from './startup-policy.js';
|
|
|
19
19
|
import { exclusiveLockOwnerPid } from '../core/exclusive-lock.js';
|
|
20
20
|
import { onDiskMigrationLockPath } from '../core/canvas/paths.js';
|
|
21
21
|
import { envNoDaemonAutostart } from '../shared/env.js';
|
|
22
|
+
import { awaitJoinLockRelease, joinInProgress, joinInProgressError, joinLockHolder } from '../core/directory/join-lock.js';
|
|
22
23
|
import { waitForDaemonAvailability } from '../api/index.js';
|
|
23
24
|
import { localClient } from '../api/node-transport.js';
|
|
24
25
|
// Daemon env sanitization
|
|
@@ -194,6 +195,8 @@ export async function execDaemon() {
|
|
|
194
195
|
const launchArgs = await resolveDaemonLaunch();
|
|
195
196
|
return execve(executable, [executable, ...launchArgs, '--canvas-home', crtrHome()], sanitizedDaemonEnv());
|
|
196
197
|
}
|
|
198
|
+
/** How long a handover successor waits for a running join: a registration call (30 s) plus the join's own stop and start. */
|
|
199
|
+
const JOIN_WAIT_WINDOW_MS = 3 * 60_000;
|
|
197
200
|
// Shutdown stays short even though startup may include corpus migration.
|
|
198
201
|
const DAEMON_SHUTDOWN_WINDOW_MS = 20_000;
|
|
199
202
|
const DAEMON_VERIFY_POLL_MS = 10;
|
|
@@ -415,6 +418,16 @@ export async function stopDaemonProcess(pid, timeoutMs = DAEMON_SHUTDOWN_WINDOW_
|
|
|
415
418
|
}
|
|
416
419
|
throw new Error(`daemon ${pid} survived both SIGTERM and SIGKILL`);
|
|
417
420
|
}
|
|
421
|
+
/** Stop the recorded daemon and sweep every stray crtrd of this canvas home.
|
|
422
|
+
* Resolves once none remains. Shared by `crtr sys daemon stop` and `crtr sys join`. */
|
|
423
|
+
export async function stopAllDaemons() {
|
|
424
|
+
const pid = readPidfile();
|
|
425
|
+
const recorded = pid !== null && isPidAlive(pid);
|
|
426
|
+
const forceKilled = recorded && (await stopDaemonProcess(pid)) === 'killed';
|
|
427
|
+
// The recorded daemon is gone, so keepPid=null sweeps every remaining crtrd of this home.
|
|
428
|
+
const strays = sweepStrayDaemons(null);
|
|
429
|
+
return { stopped: recorded || strays.length > 0, ...(recorded ? { pid } : {}), strays_killed: strays, force_killed: forceKilled };
|
|
430
|
+
}
|
|
418
431
|
/** The last `maxBytes` of a log from byte `start` onward, trimmed; '' when it
|
|
419
432
|
* cannot be read. Bounded so a runaway log can never flood an error message. */
|
|
420
433
|
function readLogTailFrom(path, start, maxBytes = 2_048) {
|
|
@@ -496,6 +509,13 @@ export async function spawnDaemon(opts = {}) {
|
|
|
496
509
|
// is launchd-owned, the plist's ProgramArguments must point at the branded
|
|
497
510
|
// binary too — this path only covers a manual `crtr sys daemon start`.
|
|
498
511
|
const launchArgs = await resolveDaemonLaunch();
|
|
512
|
+
// The one spawn point every start reaches (autostart, `sys daemon start`, a handover successor): an
|
|
513
|
+
// autostart begun before a join took its lock lands here after it, and must not start a daemon.
|
|
514
|
+
if (opts.waitForJoinLock === true)
|
|
515
|
+
await awaitJoinLockRelease(JOIN_WAIT_WINDOW_MS);
|
|
516
|
+
const joiner = joinLockHolder();
|
|
517
|
+
if (joiner !== null && joiner !== process.pid && opts.ignoreJoinLock !== true)
|
|
518
|
+
throw joinInProgressError(joiner);
|
|
499
519
|
// Route stdout+stderr to an append-mode file instead of discarding them
|
|
500
520
|
// (stdio:'ignore'). This fd is RAW RESIDUE only — third-party/runtime
|
|
501
521
|
// output the daemon doesn't control, plus the narrow set of authorized raw
|
|
@@ -624,7 +644,8 @@ export function daemonAutostartStartupFailure() {
|
|
|
624
644
|
return autostartStartupFailure;
|
|
625
645
|
}
|
|
626
646
|
/** Start the daemon if it is not already running. No-op if already up, if
|
|
627
|
-
* autostart is suppressed for this invocation, while
|
|
647
|
+
* autostart is suppressed for this invocation, while `crtr sys join` holds
|
|
648
|
+
* the join lock (see core/directory/join-lock.ts), while an attempt is in flight,
|
|
628
649
|
* or while backing off from a recent failure.
|
|
629
650
|
* Silently swallows spawn errors (the canvas still works without the daemon;
|
|
630
651
|
* nodes just won't be auto-revived). */
|
|
@@ -634,6 +655,9 @@ export function ensureDaemon(deps = {}) {
|
|
|
634
655
|
const now = deps.now ?? Date.now;
|
|
635
656
|
if (autostartDisabled())
|
|
636
657
|
return;
|
|
658
|
+
// `crtr sys join` holds the daemon down on purpose; a viewer's redial must not bring it back.
|
|
659
|
+
if (joinInProgress())
|
|
660
|
+
return;
|
|
637
661
|
if (autostartInFlight)
|
|
638
662
|
return;
|
|
639
663
|
if (now() < autostartNextAttemptAt)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/api",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.436",
|
|
4
4
|
"description": "Typed crtrd /v1 API contract — DTOs, route builders, the error contract, the CrtrClient, and the command-plugin manifest format. Zero runtime dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/api/index.js",
|