@mnemom/mnemom 0.17.3-next.1 → 0.17.3-next.2
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/lib/update/decide.d.ts +16 -0
- package/dist/lib/update/decide.js +27 -0
- package/dist/lib/update/run.d.ts +2 -0
- package/dist/lib/update/run.js +21 -6
- package/dist/lib/update/startup.d.ts +5 -2
- package/dist/lib/update/startup.js +19 -5
- package/dist/lib/update/worker.js +3 -2
- package/package.json +1 -1
|
@@ -64,6 +64,9 @@ export interface UpdateState {
|
|
|
64
64
|
method?: InstallMethod;
|
|
65
65
|
/** Channel the last successful update installed from; feeds defaultChannel. */
|
|
66
66
|
followChannel?: UpdateChannel;
|
|
67
|
+
/** The background worker startup last spawned, while it runs (cleared when it finishes). */
|
|
68
|
+
workerPid?: number;
|
|
69
|
+
workerSince?: number;
|
|
67
70
|
}
|
|
68
71
|
export interface UpdatePrefs {
|
|
69
72
|
channel?: UpdateChannel;
|
|
@@ -121,6 +124,19 @@ export declare function entitlementFresh(cache: EntitlementCache | undefined, no
|
|
|
121
124
|
* registry response).
|
|
122
125
|
*/
|
|
123
126
|
export declare function pickTarget(tags: DistTags, channel: UpdateChannel): string | undefined;
|
|
127
|
+
/**
|
|
128
|
+
* Longest a worker lives: it waits for its parent `mnemom` (a whole agent session)
|
|
129
|
+
* up to this long. A recorded worker older than this is treated as gone, so a
|
|
130
|
+
* recycled pid can't block updates.
|
|
131
|
+
*/
|
|
132
|
+
export declare const WORKER_MAX_AGE_MS: number;
|
|
133
|
+
/**
|
|
134
|
+
* A known update is still pending and nothing is working on it: the last worker
|
|
135
|
+
* recorded `available` but was killed before installing (e.g. while waiting out
|
|
136
|
+
* a long agent session). Startup respawns one without waiting for the next
|
|
137
|
+
* scheduled check.
|
|
138
|
+
*/
|
|
139
|
+
export declare function shouldResume(state: UpdateState, current: string, now: number, alive: (pid: number) => boolean): boolean;
|
|
124
140
|
/** Whether the background worker should poll the registry now. */
|
|
125
141
|
export declare function shouldCheck(state: UpdateState, channel: UpdateChannel, now: number): boolean;
|
|
126
142
|
export declare function backoffDelay(failures: number): number;
|
|
@@ -115,6 +115,33 @@ export function pickTarget(tags, channel) {
|
|
|
115
115
|
return next;
|
|
116
116
|
return compareVersions(next, stable) >= 0 ? next : stable;
|
|
117
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Longest a worker lives: it waits for its parent `mnemom` (a whole agent session)
|
|
120
|
+
* up to this long. A recorded worker older than this is treated as gone, so a
|
|
121
|
+
* recycled pid can't block updates.
|
|
122
|
+
*/
|
|
123
|
+
export const WORKER_MAX_AGE_MS = 12 * HOUR;
|
|
124
|
+
/**
|
|
125
|
+
* A known update is still pending and nothing is working on it: the last worker
|
|
126
|
+
* recorded `available` but was killed before installing (e.g. while waiting out
|
|
127
|
+
* a long agent session). Startup respawns one without waiting for the next
|
|
128
|
+
* scheduled check.
|
|
129
|
+
*/
|
|
130
|
+
export function shouldResume(state, current, now, alive) {
|
|
131
|
+
if (!state.available || !isValidVersion(state.available))
|
|
132
|
+
return false;
|
|
133
|
+
if (compareVersions(state.available, current) <= 0)
|
|
134
|
+
return false;
|
|
135
|
+
if (state.installBlocked || !canSelfUpdate(state.method ?? "unknown"))
|
|
136
|
+
return false;
|
|
137
|
+
if (state.backoffUntil !== undefined && now < state.backoffUntil)
|
|
138
|
+
return false;
|
|
139
|
+
const working = state.workerPid !== undefined &&
|
|
140
|
+
state.workerSince !== undefined &&
|
|
141
|
+
now - state.workerSince < WORKER_MAX_AGE_MS &&
|
|
142
|
+
alive(state.workerPid);
|
|
143
|
+
return !working;
|
|
144
|
+
}
|
|
118
145
|
/** Whether the background worker should poll the registry now. */
|
|
119
146
|
export function shouldCheck(state, channel, now) {
|
|
120
147
|
if (state.backoffUntil !== undefined && now < state.backoffUntil)
|
package/dist/lib/update/run.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface RunDeps {
|
|
|
29
29
|
* `mnemom agent` session). Resolving false skips the install this pass.
|
|
30
30
|
*/
|
|
31
31
|
beforeInstall?(): Promise<boolean>;
|
|
32
|
+
/** The background worker's own pid: it clears its `workerPid` record when done. */
|
|
33
|
+
pid?: number;
|
|
32
34
|
}
|
|
33
35
|
export interface RunResult {
|
|
34
36
|
/** The channel actually honoured (next falls back to latest when not entitled). */
|
package/dist/lib/update/run.js
CHANGED
|
@@ -37,13 +37,17 @@ function failurePatch(state, now, message) {
|
|
|
37
37
|
export async function runUpdate(deps, settings, opts = {}) {
|
|
38
38
|
const now = deps.now();
|
|
39
39
|
const state = deps.loadState();
|
|
40
|
+
// Every exit path forgets this worker's record, so startup can tell it's gone.
|
|
41
|
+
const done = deps.pid !== undefined && state.workerPid === deps.pid
|
|
42
|
+
? { workerPid: undefined, workerSince: undefined }
|
|
43
|
+
: {};
|
|
40
44
|
let tags;
|
|
41
45
|
try {
|
|
42
46
|
tags = await deps.fetchDistTags();
|
|
43
47
|
}
|
|
44
48
|
catch (err) {
|
|
45
49
|
const message = err instanceof Error ? err.message : String(err);
|
|
46
|
-
deps.saveState(failurePatch(state, now, message));
|
|
50
|
+
deps.saveState({ ...failurePatch(state, now, message), ...done });
|
|
47
51
|
return { channel: settings.channel, channelDowngraded: false, error: message };
|
|
48
52
|
}
|
|
49
53
|
const entitled = settings.channel === "next" ? await resolveEntitlement(deps, state, now) : false;
|
|
@@ -71,12 +75,18 @@ export async function runUpdate(deps, settings, opts = {}) {
|
|
|
71
75
|
};
|
|
72
76
|
const result = { channel, channelDowngraded, method, target, action };
|
|
73
77
|
if (action.kind !== "install") {
|
|
74
|
-
deps.saveState(base);
|
|
78
|
+
deps.saveState({ ...base, ...done });
|
|
75
79
|
return result;
|
|
76
80
|
}
|
|
77
|
-
|
|
78
|
-
if (
|
|
81
|
+
let proceed = true;
|
|
82
|
+
if (deps.beforeInstall) {
|
|
83
|
+
// Record what we found BEFORE the (possibly hours-long) wait: if this worker
|
|
84
|
+
// is killed meanwhile, the next launch still knows an update is pending.
|
|
79
85
|
deps.saveState(base);
|
|
86
|
+
proceed = await deps.beforeInstall();
|
|
87
|
+
}
|
|
88
|
+
if (!proceed) {
|
|
89
|
+
deps.saveState({ ...base, ...done });
|
|
80
90
|
return { ...result, action: { kind: "notify", version: action.version, reason: "deferred" } };
|
|
81
91
|
}
|
|
82
92
|
const outcome = await deps.install(action.version);
|
|
@@ -87,15 +97,20 @@ export async function runUpdate(deps, settings, opts = {}) {
|
|
|
87
97
|
installBlocked: undefined,
|
|
88
98
|
pending: { from: deps.current, to: action.version, channel },
|
|
89
99
|
followChannel: channel,
|
|
100
|
+
...done,
|
|
90
101
|
});
|
|
91
102
|
return { ...result, installed: action.version };
|
|
92
103
|
}
|
|
93
104
|
if (outcome.eacces) {
|
|
94
105
|
// Retrying can't fix permissions — switch to notify-with-instructions.
|
|
95
|
-
deps.saveState({ ...base, installBlocked: true, lastError: outcome.message });
|
|
106
|
+
deps.saveState({ ...base, installBlocked: true, lastError: outcome.message, ...done });
|
|
96
107
|
}
|
|
97
108
|
else {
|
|
98
|
-
deps.saveState({
|
|
109
|
+
deps.saveState({
|
|
110
|
+
...failurePatch(state, now, outcome.message),
|
|
111
|
+
available: base.available,
|
|
112
|
+
...done,
|
|
113
|
+
});
|
|
99
114
|
}
|
|
100
115
|
return { ...result, error: outcome.message };
|
|
101
116
|
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* The per-invocation hook, run before commander parses argv. It must cost
|
|
3
3
|
* ~nothing and never fail the user's command:
|
|
4
4
|
* 1. print a one-line stderr notice (completed update, or an available one);
|
|
5
|
-
* 2. if a check is due
|
|
5
|
+
* 2. if a check is due — or a known update was left uninstalled by a worker
|
|
6
|
+
* that died — spawn the detached worker and return immediately.
|
|
6
7
|
* All I/O is injected; `defaultStartupDeps()` wires the real process.
|
|
7
8
|
*/
|
|
8
9
|
import { type UpdateChannel, type UpdateMode, type UpdatePrefs, type UpdateState } from "./decide.js";
|
|
@@ -18,8 +19,10 @@ export interface StartupDeps {
|
|
|
18
19
|
saveState(patch: Partial<UpdateState>): void;
|
|
19
20
|
entryPath: string;
|
|
20
21
|
writeErr(line: string): void;
|
|
21
|
-
|
|
22
|
+
/** Returns the worker's pid (undefined if the spawn failed). */
|
|
23
|
+
spawnWorker(args: [entry: string, channel: UpdateChannel, mode: UpdateMode, parentPid: string]): number | undefined;
|
|
22
24
|
pid: number;
|
|
25
|
+
alive(pid: number): boolean;
|
|
23
26
|
}
|
|
24
27
|
export declare function maybeRunUpdateHook(deps: StartupDeps): void;
|
|
25
28
|
/** dist/lib/update/startup.js → dist/update-worker.js */
|
|
@@ -2,16 +2,18 @@
|
|
|
2
2
|
* The per-invocation hook, run before commander parses argv. It must cost
|
|
3
3
|
* ~nothing and never fail the user's command:
|
|
4
4
|
* 1. print a one-line stderr notice (completed update, or an available one);
|
|
5
|
-
* 2. if a check is due
|
|
5
|
+
* 2. if a check is due — or a known update was left uninstalled by a worker
|
|
6
|
+
* that died — spawn the detached worker and return immediately.
|
|
6
7
|
* All I/O is injected; `defaultStartupDeps()` wires the real process.
|
|
7
8
|
*/
|
|
8
9
|
import { spawn } from "node:child_process";
|
|
9
10
|
import { fileURLToPath } from "node:url";
|
|
10
11
|
import { CLI_VERSION } from "../../version.js";
|
|
11
|
-
import { resolveSettings, shouldCheck, startupNotice, } from "./decide.js";
|
|
12
|
+
import { resolveSettings, shouldCheck, shouldResume, startupNotice, } from "./decide.js";
|
|
12
13
|
import { classifyInstall } from "./install-method.js";
|
|
13
14
|
import { loadUpdateState, saveUpdateState } from "./state.js";
|
|
14
15
|
import { entryRealpath, loadUpdatePrefs } from "./system.js";
|
|
16
|
+
import { isAlive } from "./worker.js";
|
|
15
17
|
/** Commands that manage updates themselves, or must stay side-effect free. */
|
|
16
18
|
const SKIP_COMMANDS = new Set(["update", "config"]);
|
|
17
19
|
export function maybeRunUpdateHook(deps) {
|
|
@@ -41,11 +43,21 @@ export function maybeRunUpdateHook(deps) {
|
|
|
41
43
|
}
|
|
42
44
|
if (SKIP_COMMANDS.has(deps.argv[0] ?? ""))
|
|
43
45
|
return;
|
|
44
|
-
|
|
46
|
+
const due = shouldCheck(state, settings.channel, now);
|
|
47
|
+
const resume = !due && settings.mode === "auto" && shouldResume(state, deps.current, now, deps.alive);
|
|
48
|
+
if (!due && !resume)
|
|
45
49
|
return;
|
|
46
50
|
// Stamp first so a burst of commands doesn't spawn a burst of workers.
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
if (due)
|
|
52
|
+
deps.saveState({ lastCheck: now });
|
|
53
|
+
const pid = deps.spawnWorker([
|
|
54
|
+
deps.entryPath,
|
|
55
|
+
settings.channel,
|
|
56
|
+
settings.mode,
|
|
57
|
+
String(deps.pid),
|
|
58
|
+
]);
|
|
59
|
+
if (pid !== undefined)
|
|
60
|
+
deps.saveState({ workerPid: pid, workerSince: now });
|
|
49
61
|
}
|
|
50
62
|
catch {
|
|
51
63
|
/* the updater never breaks the user's command */
|
|
@@ -75,6 +87,8 @@ export function defaultStartupDeps(argv) {
|
|
|
75
87
|
});
|
|
76
88
|
child.on("error", () => { });
|
|
77
89
|
child.unref();
|
|
90
|
+
return child.pid;
|
|
78
91
|
},
|
|
92
|
+
alive: (pid) => isAlive(pid),
|
|
79
93
|
};
|
|
80
94
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { CLI_VERSION } from "../../version.js";
|
|
10
10
|
import { getApiUrl } from "../config.js";
|
|
11
|
-
import { isUpdateChannel, isUpdateMode } from "./decide.js";
|
|
11
|
+
import { isUpdateChannel, isUpdateMode, WORKER_MAX_AGE_MS, } from "./decide.js";
|
|
12
12
|
import { classifyInstall } from "./install-method.js";
|
|
13
13
|
import { fetchChannelEntitlement, fetchDistTags } from "./registry.js";
|
|
14
14
|
import { runUpdate } from "./run.js";
|
|
@@ -23,7 +23,7 @@ export function parseWorkerArgs(args) {
|
|
|
23
23
|
}
|
|
24
24
|
export const PARENT_POLL_MS = 5000;
|
|
25
25
|
/** Give up waiting on a very long session; the next run picks the update up. */
|
|
26
|
-
export const PARENT_WAIT_MAX_MS =
|
|
26
|
+
export const PARENT_WAIT_MAX_MS = WORKER_MAX_AGE_MS;
|
|
27
27
|
/** `kill(pid, 0)` probes liveness without signalling. EPERM still means alive. */
|
|
28
28
|
export function isAlive(pid, kill = (p, s) => void process.kill(p, s)) {
|
|
29
29
|
try {
|
|
@@ -60,6 +60,7 @@ export function realRunDeps(entry, credential) {
|
|
|
60
60
|
fetchEntitlement: async () => fetchChannelEntitlement(fetch, getApiUrl(), await credential()),
|
|
61
61
|
installMethod: async () => classifyInstall(entry, await npmGlobalRoot()),
|
|
62
62
|
install: (version) => npmInstallGlobal(version),
|
|
63
|
+
pid: process.pid,
|
|
63
64
|
};
|
|
64
65
|
}
|
|
65
66
|
/**
|