om-memory-system 5.8.0 → 5.9.1

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.
@@ -5,8 +5,8 @@
5
5
  //
6
6
  // node omms-launch.mjs [--at-least-own-version] <om-memory-system arguments>
7
7
  //
8
- // Candidates: the copy named in ~/.omms/runtime.json, the global install beside
9
- // the running Node.js, and the copy that holds this file. With
8
+ // Candidates: the global install beside the running Node.js, the copy named in
9
+ // ~/.omms/runtime.json, and the copy that holds this file. On a tie the first wins. With
10
10
  // `--at-least-own-version`, the version in the `package.json` beside this file is
11
11
  // the minimum. When no candidate reaches it, the launcher runs
12
12
  // `npx --yes om-memory-system@<that version>`.
@@ -96,7 +96,11 @@ export function globalPackageRoots(execPath, platform = process.platform) {
96
96
  return brew ? [direct, join(brew[1], "lib", "node_modules", "om-memory-system")] : [direct];
97
97
  }
98
98
 
99
- /** The record's copy, the global installs, and the own copy, in that order. Invalid ones are null. */
99
+ /**
100
+ * The global installs, the record's copy, and the own copy, in that order. Invalid
101
+ * ones are null. On a tie the global install wins, so a host's cache copy does not
102
+ * keep serving after the global install catches up.
103
+ */
100
104
  export function findCandidates({ dir, execPath, ownRoot }) {
101
105
  let recorded = null;
102
106
  try {
@@ -106,7 +110,7 @@ export function findCandidates({ dir, execPath, ownRoot }) {
106
110
  /* No record, or an unreadable one: the other candidates still work. */
107
111
  }
108
112
  const globals = globalPackageRoots(execPath).map((root) => readCopy(root));
109
- return [recorded, ...globals, ownRoot ? readCopy(ownRoot) : null];
113
+ return [...globals, recorded, ownRoot ? readCopy(ownRoot) : null];
110
114
  }
111
115
 
112
116
  /**
@@ -130,6 +130,17 @@ export async function runWebCommand(args, options = {}, online, handover = {}, u
130
130
  process.once("SIGTERM", stop);
131
131
  // The page's Stop and Restart buttons reach the same process through this callback.
132
132
  const { createPowerAction } = await import("./web-power.js");
133
+ // A restart whose copy failed serves again from this process.
134
+ // Safe to call twice: it replaces the keeper and the signal handlers instead of adding more.
135
+ const resumeServer = async () => {
136
+ clearInterval(keeper);
137
+ keeper = setInterval(() => { }, 60_000);
138
+ process.off("SIGINT", stop);
139
+ process.off("SIGTERM", stop);
140
+ process.once("SIGINT", stop);
141
+ process.once("SIGTERM", stop);
142
+ await server.start();
143
+ };
133
144
  const powerAction = createPowerAction({
134
145
  stopServer: async () => {
135
146
  clearInterval(keeper);
@@ -137,13 +148,7 @@ export async function runWebCommand(args, options = {}, online, handover = {}, u
137
148
  process.off("SIGTERM", stop);
138
149
  await server.stop();
139
150
  },
140
- // A restart whose copy failed serves again from this process.
141
- resumeServer: async () => {
142
- keeper = setInterval(() => { }, 60_000);
143
- process.once("SIGINT", stop);
144
- process.once("SIGTERM", stop);
145
- await server.start();
146
- },
151
+ resumeServer,
147
152
  baseUrl: server.getUrl(),
148
153
  loginItem: action === "--login-item",
149
154
  autostart: options,
@@ -156,12 +161,23 @@ export async function runWebCommand(args, options = {}, online, handover = {}, u
156
161
  const { existsSync } = await import("node:fs");
157
162
  // Every restart onto the new copy runs the launcher, the login item's included.
158
163
  const canRestart = () => existsSync(launcherPath(ommsDir()));
159
- server.setWebUpdate(startWebUpdate(() => powerAction("update"), canRestart));
160
- // A newer `web install` asked this web app to give up the port.
164
+ server.setWebUpdate(startWebUpdate(() => powerAction("update"), canRestart, () => server.powerActionRunning));
165
+ // A newer caller asked this web app to give up the port. If the caller's copy
166
+ // never takes it, restart onto the newest copy instead of leaving it empty.
167
+ const { leaveAfterStepAside } = await import("./web-step-aside.js");
168
+ const { log } = await import("../services/logger.js");
161
169
  server.setOnStepAside(async () => {
162
170
  clearInterval(keeper);
163
171
  await server.stop();
164
- process.exit(0);
172
+ await leaveAfterStepAside({
173
+ ownerAnswers: () => server.checkServerAvailable(),
174
+ restartNewest: () => powerAction("update"),
175
+ resume: resumeServer,
176
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
177
+ now: () => Date.now(),
178
+ exit: (code) => process.exit(code),
179
+ log,
180
+ });
165
181
  });
166
182
  return 0;
167
183
  }
@@ -0,0 +1,17 @@
1
+ import type { LogLevel } from "../services/logger.js";
2
+ /** How long the old web app waits for the caller's copy to answer. */
3
+ export declare const NEW_OWNER_WAIT_MS = 30000;
4
+ export interface StepAsideDeps {
5
+ /** True when an OMMS web app answers on the configured port. */
6
+ ownerAnswers: () => Promise<boolean>;
7
+ /** Restart onto the newest copy through the launcher. Returns a failure code when it did not. */
8
+ restartNewest: () => Promise<string | undefined>;
9
+ /** Serve again from this process. */
10
+ resume: () => Promise<void>;
11
+ sleep: (ms: number) => Promise<void>;
12
+ now: () => number;
13
+ exit: (code: number) => void;
14
+ log: (message: string, data: Record<string, unknown>, level?: LogLevel) => void;
15
+ }
16
+ /** Call after this web app stopped serving. It never leaves the port empty when it can help it. */
17
+ export declare function leaveAfterStepAside(deps: StepAsideDeps): Promise<void>;
@@ -0,0 +1,34 @@
1
+ // What a standalone web app does after it stops serving for a newer caller. The
2
+ // caller starts the newer copy, but a caller can quit first (a host that restarts
3
+ // for its own update), which left the port empty until some other host started a
4
+ // web app. So the old web app waits for a new owner, and restarts onto the newest
5
+ // copy itself when none comes.
6
+ /** How long the old web app waits for the caller's copy to answer. */
7
+ export const NEW_OWNER_WAIT_MS = 30_000;
8
+ const POLL_MS = 500;
9
+ /** Call after this web app stopped serving. It never leaves the port empty when it can help it. */
10
+ export async function leaveAfterStepAside(deps) {
11
+ const began = deps.now();
12
+ while (deps.now() - began < NEW_OWNER_WAIT_MS) {
13
+ if (await deps.ownerAnswers()) {
14
+ deps.exit(0);
15
+ return;
16
+ }
17
+ await deps.sleep(POLL_MS);
18
+ }
19
+ deps.log("Web app step-aside", { code: "no-new-owner" }, "warn");
20
+ // A successful restart exits this process.
21
+ const code = await deps.restartNewest();
22
+ if (code === undefined)
23
+ return;
24
+ // A failed restart can already serve again from this process.
25
+ if (await deps.ownerAnswers())
26
+ return;
27
+ try {
28
+ await deps.resume();
29
+ }
30
+ catch {
31
+ deps.log("Web app step-aside", { code: "resume-failed" }, "error");
32
+ deps.exit(1);
33
+ }
34
+ }
@@ -43,7 +43,7 @@ export declare class WebServer {
43
43
  private readonly startedAt;
44
44
  private onPowerActionCallback;
45
45
  private webUpdate;
46
- private powerActionRunning;
46
+ powerActionRunning: boolean;
47
47
  private stepAsideTimer;
48
48
  private holdOffTimer;
49
49
  private portsExhaustedNotified;
@@ -173,7 +173,7 @@ export class WebServer {
173
173
  startedAt = Date.now();
174
174
  onPowerActionCallback = null;
175
175
  webUpdate = null;
176
- powerActionRunning = false;
176
+ powerActionRunning = false; // public: the automatic update stays quiet while one runs
177
177
  stepAsideTimer = null;
178
178
  holdOffTimer = null;
179
179
  portsExhaustedNotified = false;
@@ -1,5 +1,5 @@
1
1
  import { WebUpdate, type InstallDeps } from "./web-update.js";
2
2
  /** The npm check and installer of a standalone web app, with the real process, npm, and clock. */
3
- export declare function startWebUpdate(restart: () => Promise<string | undefined>, canRestart: () => boolean): WebUpdate;
3
+ export declare function startWebUpdate(restart: () => Promise<string | undefined>, canRestart: () => boolean, autoBlocked?: () => boolean): WebUpdate;
4
4
  /** The real npm process, shared by the update button and `web update`. */
5
5
  export declare function nodeInstallDeps(): InstallDeps;
@@ -5,7 +5,7 @@ import { log } from "./logger.js";
5
5
  import { packageVersion } from "./package-version.js";
6
6
  import { WebUpdate } from "./web-update.js";
7
7
  /** The npm check and installer of a standalone web app, with the real process, npm, and clock. */
8
- export function startWebUpdate(restart, canRestart) {
8
+ export function startWebUpdate(restart, canRestart, autoBlocked) {
9
9
  const update = new WebUpdate({
10
10
  version: packageVersion(),
11
11
  enabled: process.env.OMMS_DISABLE_UPDATE_CHECK !== "1",
@@ -18,6 +18,7 @@ export function startWebUpdate(restart, canRestart) {
18
18
  globalVersion: () => globalCommandVersion({ find: () => null }).version,
19
19
  restart,
20
20
  canRestart,
21
+ autoBlocked,
21
22
  now: () => Date.now(),
22
23
  });
23
24
  update.start();
@@ -4,6 +4,8 @@ export declare const UPDATE_CHECK_INTERVAL_MS: number;
4
4
  export declare const STATUS_RECHECK_AFTER_MS: number;
5
5
  /** An install that takes longer is stopped, and the web app keeps serving. */
6
6
  export declare const INSTALL_TIMEOUT_MS: number;
7
+ /** An automatic start waits this long after a failed automatic install. */
8
+ export declare const AUTO_RETRY_AFTER_MS: number;
7
9
  export type UpdateState = "idle" | "installing" | "restarting" | "failed";
8
10
  /** The `update` field of `GET /api/web/status`. */
9
11
  export interface UpdateStatus {
@@ -35,10 +37,12 @@ export interface WebUpdateDeps {
35
37
  shell: boolean;
36
38
  path?: string;
37
39
  }) => InstallChild;
38
- /** The version of the global install after npm ran, or null. */
40
+ /** The version of the global install (also read before npm runs), or null. */
39
41
  globalVersion: () => string | null;
40
42
  /** False when no restart could serve the new copy, such as a missing launcher. Checked before npm runs. */
41
43
  canRestart: () => boolean;
44
+ /** True while a Stop or Restart runs; the automatic start stays quiet then. */
45
+ autoBlocked?: () => boolean;
42
46
  /** Stop npm and its children. On Windows the shell wrapper alone would leave npm running. */
43
47
  killTree: (child: InstallChild) => Promise<void>;
44
48
  /** Restart onto the newest copy. Returns a failure code when this web app keeps serving. */
@@ -68,12 +72,21 @@ export declare class WebUpdate {
68
72
  private started;
69
73
  private lastCheck;
70
74
  private checking;
75
+ private autoRetryAt;
76
+ private autoAttempt;
71
77
  constructor(deps: WebUpdateDeps);
72
78
  /** Check now, then every 10 minutes. A second call does nothing. */
73
79
  start(): void;
74
80
  /** Read npm `latest`. A failed read keeps the last result. Calls during a read share it. */
75
81
  check(): Promise<void>;
76
82
  private read;
83
+ /**
84
+ * The older of the running copy and the global install. A web app can run from a
85
+ * host's cache copy that is already current while the global install stays behind.
86
+ */
87
+ private installedVersion;
88
+ /** Start the install the Update button starts, once a check found a release. */
89
+ private maybeAutoInstall;
77
90
  status(): UpdateStatus;
78
91
  private canInstall;
79
92
  /** Start the install and the restart. A request while one runs joins it. */
@@ -1,5 +1,6 @@
1
1
  import { posix, win32 } from "node:path";
2
2
  import { availableUpdate, latestNpmVersion } from "./update-check.js";
3
+ import { compareVersions } from "./version-compare.js";
3
4
  // The web app's npm release check. It reads npm `latest` at start, every 10
4
5
  // minutes, and when an open page reads the status and the last check is over a
5
6
  // minute old. It keeps the result in memory for `GET /api/web/status`. It imports
@@ -9,6 +10,8 @@ export const UPDATE_CHECK_INTERVAL_MS = 10 * 60 * 1000;
9
10
  export const STATUS_RECHECK_AFTER_MS = 60 * 1000;
10
11
  /** An install that takes longer is stopped, and the web app keeps serving. */
11
12
  export const INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
13
+ /** An automatic start waits this long after a failed automatic install. */
14
+ export const AUTO_RETRY_AFTER_MS = 30 * 60 * 1000;
12
15
  /** Only a plain release reaches the npm arguments; the value comes from the registry. */
13
16
  const PLAIN_VERSION = /^\d+\.\d+\.\d+$/;
14
17
  /** Enough of npm's error stream to find a code; the rest is dropped. */
@@ -29,6 +32,8 @@ export class WebUpdate {
29
32
  started = false;
30
33
  lastCheck = -Infinity;
31
34
  checking = null;
35
+ autoRetryAt = -Infinity;
36
+ autoAttempt = false;
32
37
  constructor(deps) {
33
38
  this.deps = deps;
34
39
  }
@@ -54,7 +59,36 @@ export class WebUpdate {
54
59
  this.deps.log("Web app update check failed", { code: "unreachable" }, "error");
55
60
  return;
56
61
  }
57
- this.available = availableUpdate(this.deps.version, latest);
62
+ this.available = availableUpdate(this.installedVersion(), latest);
63
+ this.maybeAutoInstall();
64
+ }
65
+ /**
66
+ * The older of the running copy and the global install. A web app can run from a
67
+ * host's cache copy that is already current while the global install stays behind.
68
+ */
69
+ installedVersion() {
70
+ const global = this.deps.globalVersion();
71
+ return global !== null && compareVersions(global, this.deps.version) === -1
72
+ ? global
73
+ : this.deps.version;
74
+ }
75
+ /** Start the install the Update button starts, once a check found a release. */
76
+ maybeAutoInstall() {
77
+ if (this.state === "installing" || this.state === "restarting")
78
+ return;
79
+ if (!this.available)
80
+ return;
81
+ if (this.deps.now() < this.autoRetryAt)
82
+ return;
83
+ // A check can finish while a Stop or Restart runs; the manual route has the same gate.
84
+ if (this.deps.autoBlocked?.())
85
+ return;
86
+ if (!this.canInstall())
87
+ return;
88
+ if (this.requestInstall() !== "accepted")
89
+ return;
90
+ this.autoAttempt = true;
91
+ this.deps.log("Web app auto-update started", { from: this.deps.version, to: this.available });
58
92
  }
59
93
  status() {
60
94
  // An open page polls the status, so a release shows within about a minute.
@@ -78,6 +112,7 @@ export class WebUpdate {
78
112
  return "no-update";
79
113
  if (!this.canInstall())
80
114
  return "cannot-install";
115
+ this.autoAttempt = false;
81
116
  this.state = "installing";
82
117
  this.code = null;
83
118
  void this.install(this.available);
@@ -96,6 +131,9 @@ export class WebUpdate {
96
131
  const fail = (code, exitCode) => {
97
132
  this.state = "failed";
98
133
  this.code = code;
134
+ // Only an automatic start waits before trying again; the button always works.
135
+ if (this.autoAttempt)
136
+ this.autoRetryAt = this.deps.now() + AUTO_RETRY_AFTER_MS;
99
137
  record("failed", code, exitCode);
100
138
  };
101
139
  const { code, exitCode } = await runGlobalInstall(this.deps, target);