Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
675cc30e5a | ||
|
|
3511153f9f | ||
|
|
23facf75d5 | ||
|
|
384c7476ad | ||
|
|
059125a09f |
No files matched your search
+3
-1
@@ -28,7 +28,9 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
scrubbing path. Previously every versioned session was invisible: the import
|
||||
job completed with `discovered 0` while the files sat on disk.
|
||||
|
||||
- SDK: Intention dedup is now configurable per client
|
||||
## Hy Memory 2.0.12 — 2026-11-02
|
||||
|
||||
- Intention dedup is now configurable per client
|
||||
(`MemoryConfig.recall.intention_dedup_enabled`, default `true`;
|
||||
`recall.intention_dedup_threshold`, default `MEMORY_DEDUP_THRESHOLD` or
|
||||
`0.95`). Env defaults: `MEMORY_INTENTION_DEDUP_ENABLED`,
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
Tray,
|
||||
} from "electron";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { installBackgroundTray } from "./background-tray";
|
||||
import { installBackgroundTray, type RuntimeHosting } from "./background-tray";
|
||||
import type { LoginPreference } from "./login-preference";
|
||||
|
||||
async function flush() {
|
||||
@@ -27,6 +27,7 @@ function fixture(
|
||||
status: "disabled",
|
||||
};
|
||||
let changed: (() => void) | undefined;
|
||||
let hosting: RuntimeHosting | Error = "background-service";
|
||||
const languageSnapshot: LanguageSnapshot = {
|
||||
preference: "zh-Hans",
|
||||
locale: "zh-Hans",
|
||||
@@ -50,6 +51,10 @@ function fixture(
|
||||
};
|
||||
return state;
|
||||
});
|
||||
const readRuntimeHosting = vi.fn(async () => {
|
||||
if (hosting instanceof Error) throw hosting;
|
||||
return hosting;
|
||||
});
|
||||
const open = vi.fn();
|
||||
const reportError = vi.fn();
|
||||
const install = () =>
|
||||
@@ -77,6 +82,7 @@ function fixture(
|
||||
reportError,
|
||||
readLoginPreference,
|
||||
setLoginPreference,
|
||||
readRuntimeHosting,
|
||||
...(trial
|
||||
? { allowLoginStartup: false, productName: "One Memory Trial" }
|
||||
: {}),
|
||||
@@ -91,6 +97,7 @@ function fixture(
|
||||
menus,
|
||||
readLoginPreference,
|
||||
setLoginPreference,
|
||||
readRuntimeHosting,
|
||||
open,
|
||||
reportError,
|
||||
install,
|
||||
@@ -103,6 +110,9 @@ function fixture(
|
||||
setState(value: LoginPreference) {
|
||||
state = value;
|
||||
},
|
||||
setHosting(value: RuntimeHosting | Error) {
|
||||
hosting = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,6 +189,50 @@ describe("native macOS background tray", () => {
|
||||
expect(f.tray.eventNames()).toEqual([]);
|
||||
});
|
||||
|
||||
it("says quitting stops the memory service when the App hosts it", async () => {
|
||||
const f = fixture();
|
||||
f.setHosting("app-hosted");
|
||||
f.install();
|
||||
await flush();
|
||||
expect(f.menu()[3]?.label).toBe("退出 MemHub(同时停止记忆服务)");
|
||||
f.changeLanguage("es");
|
||||
await flush();
|
||||
expect(f.menu()[3]?.label).toBe(
|
||||
"Salir de MemHub (también detiene el servicio de memoria)",
|
||||
);
|
||||
});
|
||||
|
||||
it("re-reads who hosts the Runtime every time the menu opens", async () => {
|
||||
const f = fixture();
|
||||
f.install();
|
||||
await flush();
|
||||
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
|
||||
|
||||
// The user turned the background service off in Settings.
|
||||
f.setHosting("app-hosted");
|
||||
f.tray.emit("mouse-down");
|
||||
await flush();
|
||||
expect(f.menu()[3]?.label).toContain("同时停止记忆服务");
|
||||
|
||||
f.setHosting("background-service");
|
||||
f.tray.emit("right-click");
|
||||
await flush();
|
||||
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
|
||||
expect(f.readRuntimeHosting).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("never claims quit stops the service when the state is unknown", async () => {
|
||||
const f = fixture();
|
||||
f.setHosting(new Error("launchctl timed out"));
|
||||
f.install();
|
||||
await flush();
|
||||
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
|
||||
f.setHosting("unknown");
|
||||
f.tray.emit("mouse-down");
|
||||
await flush();
|
||||
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
|
||||
});
|
||||
|
||||
it("blocks login changes for trial, development and non-macOS builds", async () => {
|
||||
for (const f of [
|
||||
fixture(true, "darwin", true),
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
import type { LoginPreference } from "./login-preference";
|
||||
import type { NativeLanguageSource } from "./native-language";
|
||||
|
||||
export type RuntimeHosting = "background-service" | "app-hosted" | "unknown";
|
||||
|
||||
export const LOGIN_SERVICE_NAME = "com.tencent.onememory.runtime.plist";
|
||||
|
||||
interface TrayOptions {
|
||||
@@ -24,6 +26,9 @@ interface TrayOptions {
|
||||
reportError(message: string): void;
|
||||
readLoginPreference(): Promise<LoginPreference>;
|
||||
setLoginPreference(enabled: boolean): Promise<LoginPreference>;
|
||||
/** Who keeps the Runtime alive, re-read on every menu refresh: the
|
||||
* background service (quit leaves it running) or this App (quit stops it). */
|
||||
readRuntimeHosting?(): Promise<RuntimeHosting>;
|
||||
allowLoginStartup?: boolean;
|
||||
productName?: string;
|
||||
language?: NativeLanguageSource;
|
||||
@@ -67,6 +72,13 @@ export function installBackgroundTray(options: TrayOptions): () => void {
|
||||
} else {
|
||||
label += t("nativeDevelopmentUnavailable");
|
||||
}
|
||||
let hosting: RuntimeHosting = "unknown";
|
||||
try {
|
||||
hosting = (await options.readRuntimeHosting?.()) ?? "unknown";
|
||||
} catch {
|
||||
hosting = "unknown";
|
||||
}
|
||||
if (disposed || current !== generation) return;
|
||||
tray.setContextMenu(
|
||||
options.buildMenu([
|
||||
{ label: t("nativeOpen"), click: options.open },
|
||||
@@ -92,7 +104,10 @@ export function installBackgroundTray(options: TrayOptions): () => void {
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: t("nativeQuit"),
|
||||
// Unknown keeps the plain label: quit then stops nothing.
|
||||
label: t(
|
||||
hosting === "app-hosted" ? "nativeQuitStopsService" : "nativeQuit",
|
||||
),
|
||||
click: () => {
|
||||
if (!disposed) app.quit();
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
claimRuntimeServiceForApp,
|
||||
claimRuntimeServiceForCli,
|
||||
enableRuntimeService,
|
||||
type RuntimeServiceState,
|
||||
runtimeServiceEnableMarkerPath,
|
||||
} from "@one-memory/runtime-control";
|
||||
import { expect, it } from "vitest";
|
||||
@@ -98,3 +99,25 @@ it("rejects unsupported and unconfirmed writes", async () => {
|
||||
),
|
||||
).rejects.toThrow("login_preference_unsupported");
|
||||
});
|
||||
|
||||
it("reads a launchctl-disabled service as off and not switchable, asked fresh", async () => {
|
||||
const dataDir = await appOwned();
|
||||
let state: RuntimeServiceState = {
|
||||
state: "disabled",
|
||||
reason: "service-disabled-by-launchctl",
|
||||
jobPid: null,
|
||||
};
|
||||
const serviceState = async () => state;
|
||||
|
||||
await expect(
|
||||
readLoginPreference(dataDir, "darwin", true, serviceState),
|
||||
).resolves.toEqual({
|
||||
supported: false,
|
||||
enabled: false,
|
||||
status: "disabled-by-system",
|
||||
});
|
||||
state = { state: "enabled", reason: "service-enabled", jobPid: 4321 };
|
||||
await expect(
|
||||
readLoginPreference(dataDir, "darwin", true, serviceState),
|
||||
).resolves.toEqual({ supported: true, enabled: true, status: "enabled" });
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
disableRuntimeService,
|
||||
enableRuntimeService,
|
||||
type RuntimeServiceState,
|
||||
readRuntimeServiceOwner,
|
||||
runtimeServiceEnabled,
|
||||
setAppRuntimeStartAtLogin,
|
||||
@@ -12,10 +13,16 @@ export interface LoginPreference {
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** "Launch at login" is the background service switch: it writes the enable
|
||||
* marker the LaunchAgent's `KeepAlive` waits on. On it means the Runtime
|
||||
* outlives the App and quitting leaves it running; off means quitting stops
|
||||
* it. `serviceState`, when given, is asked fresh so a `launchctl disable`
|
||||
* override (which the switch cannot undo) reads as off, not as on. */
|
||||
export async function readLoginPreference(
|
||||
dataDir: string,
|
||||
platform: NodeJS.Platform,
|
||||
supported: boolean,
|
||||
serviceState?: () => Promise<RuntimeServiceState>,
|
||||
): Promise<LoginPreference> {
|
||||
if (platform !== "darwin" || !supported) {
|
||||
return { supported: false, enabled: false, status: "unsupported" };
|
||||
@@ -28,6 +35,12 @@ export async function readLoginPreference(
|
||||
if (owner?.owner !== "app") {
|
||||
return { supported: false, enabled: false, status: "not-installed" };
|
||||
}
|
||||
if (
|
||||
serviceState &&
|
||||
(await serviceState()).reason === "service-disabled-by-launchctl"
|
||||
) {
|
||||
return { supported: false, enabled: false, status: "disabled-by-system" };
|
||||
}
|
||||
return {
|
||||
supported: true,
|
||||
enabled: owner.start_at_login && (await runtimeServiceEnabled(dataDir)),
|
||||
@@ -46,6 +59,7 @@ export async function setLoginPreference(
|
||||
platform: NodeJS.Platform,
|
||||
input: unknown,
|
||||
supported: boolean,
|
||||
serviceState?: () => Promise<RuntimeServiceState>,
|
||||
): Promise<LoginPreference> {
|
||||
if (
|
||||
!input ||
|
||||
@@ -63,5 +77,5 @@ export async function setLoginPreference(
|
||||
await setAppRuntimeStartAtLogin(dataDir, input.openAtLogin);
|
||||
if (input.openAtLogin) await enableRuntimeService(dataDir);
|
||||
else await disableRuntimeService(dataDir);
|
||||
return readLoginPreference(dataDir, platform, supported);
|
||||
return readLoginPreference(dataDir, platform, supported, serviceState);
|
||||
}
|
||||
+130
-17
@@ -23,7 +23,9 @@ import {
|
||||
type HostPermissionClient,
|
||||
installPackagedRuntime,
|
||||
installUserService,
|
||||
queryRuntimeServiceState,
|
||||
RecallBrokerSupervisor,
|
||||
type RuntimeServiceState,
|
||||
RuntimeSupervisor,
|
||||
readInstalledRuntimeSource,
|
||||
readPublishedCorePort,
|
||||
@@ -110,6 +112,7 @@ import { showMigrationFailureDialog } from "./migration-failure-dialog";
|
||||
import { isAllowedNavigation } from "./navigation-policy";
|
||||
import { installDenyAllPermissionPolicy } from "./permissions";
|
||||
import { isHostPresent, recallHosts } from "./recall-hosts";
|
||||
import { reconcileRunningRuntime } from "./runtime-adoption";
|
||||
import { shutdownRuntime } from "./runtime-shutdown";
|
||||
import { createSourceChangeHandler } from "./source-change-handler";
|
||||
import { SourceDirectoryPicker } from "./source-directory-picker";
|
||||
@@ -140,7 +143,12 @@ let runtimeSupervisor: RuntimeSupervisor | null = null;
|
||||
// Where the Runtime keeps its receipt. Stopping it on a full quit needs this,
|
||||
// and `stop` runs outside `initialize`, where the directory is chosen.
|
||||
let runtimeDataDir: string | null = null;
|
||||
let preserveManagedRuntimeOnQuit = false;
|
||||
// Whether quitting stops the Runtime is asked of the service manager at quit
|
||||
// time (`currentRuntimeServiceState`), never remembered from startup. The one
|
||||
// startup fact kept is a Runtime whose build could not be read: this App never
|
||||
// stops one it could not compare with its own.
|
||||
let preserveRuntimeOnQuitReason: string | null = null;
|
||||
let runtimeServiceManagedByApp = false;
|
||||
let disposeTray: (() => void) | null = null;
|
||||
let disposeApplicationMenu: (() => void) | null = null;
|
||||
let languagePreferences: LanguagePreferences | null = null;
|
||||
@@ -247,6 +255,25 @@ if (trialProfile) {
|
||||
app.setPath("userData", hourlyUserDataDir);
|
||||
}
|
||||
|
||||
/** Ask the service manager, now, whether the background service for this data
|
||||
* dir is on. Never cached: quit and the tray each re-read it, so a switch made
|
||||
* in Settings, by `memhub service`, or by `launchctl` counts immediately. */
|
||||
function currentRuntimeServiceState(): Promise<RuntimeServiceState> {
|
||||
if (!runtimeDataDir) {
|
||||
return Promise.resolve({
|
||||
state: "unknown",
|
||||
reason: "service-state-query-failed",
|
||||
jobPid: null,
|
||||
});
|
||||
}
|
||||
return queryRuntimeServiceState({
|
||||
dataDir: runtimeDataDir,
|
||||
home: app.getPath("home"),
|
||||
platform: process.platform,
|
||||
uid: process.getuid?.(),
|
||||
});
|
||||
}
|
||||
|
||||
function prepareProtocol(): void {
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
@@ -498,6 +525,7 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
!preview &&
|
||||
(hostIntegrationEnabled || desktopTestIsolation !== null) &&
|
||||
installedApplication;
|
||||
runtimeServiceManagedByApp = appManagedRuntime;
|
||||
const runningPackagedHost =
|
||||
app.isPackaged && !isolatedHourly
|
||||
? await resolveRunningPackagedHostPaths({
|
||||
@@ -853,14 +881,25 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
);
|
||||
ipcMain.handle("one-memory:get-login-preference", (event) => {
|
||||
assertAllowedRenderer(event.senderFrame?.url);
|
||||
return readLoginPreference(dataDir, process.platform, appManagedRuntime);
|
||||
return readLoginPreference(
|
||||
dataDir,
|
||||
process.platform,
|
||||
appManagedRuntime,
|
||||
currentRuntimeServiceState,
|
||||
);
|
||||
});
|
||||
ipcMain.handle("one-memory:set-login-preference", (event, input: unknown) => {
|
||||
assertAllowedRenderer(event.senderFrame?.url);
|
||||
return observeDesktopAction(
|
||||
"login_preference",
|
||||
() =>
|
||||
setLoginPreference(dataDir, process.platform, input, appManagedRuntime),
|
||||
setLoginPreference(
|
||||
dataDir,
|
||||
process.platform,
|
||||
input,
|
||||
appManagedRuntime,
|
||||
currentRuntimeServiceState,
|
||||
),
|
||||
{ action: "set" },
|
||||
);
|
||||
});
|
||||
@@ -1175,7 +1214,6 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
const startAtLogin = owner?.owner === "app" ? owner.start_at_login : true;
|
||||
const previousOwner = owner;
|
||||
let runtimeChanged = false;
|
||||
let transientRuntime = false;
|
||||
// Install whoever owns the service. Owner decides who starts and stops
|
||||
// it, not which build runs: skipping the install for a CLI owner (also
|
||||
// the App's own guess when its receipt is missing) left an upgraded App
|
||||
@@ -1245,12 +1283,68 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
const frozen = await readSdkMigrationBlock(dataDir);
|
||||
if (frozen?.frozen !== undefined) throw new Error(frozen.code);
|
||||
}
|
||||
const alreadyRunning = runtimeChanged
|
||||
? false
|
||||
: await waitForRuntimeProcess({ dataDir, attempts: 4 }).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
// One Runtime per data dir. A Runtime already serving it is adopted
|
||||
// when it is this App's build or newer (silently; a newer one is never
|
||||
// stopped or downgraded at startup); an older one is replaced (through
|
||||
// launchd when it is the service's job), and one whose build cannot be
|
||||
// read is adopted but never stopped.
|
||||
const reconciled = await reconcileRunningRuntime({
|
||||
app: {
|
||||
...(packagedBuildNumber === undefined
|
||||
? {}
|
||||
: { buildNumber: packagedBuildNumber }),
|
||||
...(packagedCommit === undefined ? {} : { commit: packagedCommit }),
|
||||
},
|
||||
installedChanged: runtimeChanged,
|
||||
findRunning: () =>
|
||||
waitForRuntimeProcess({ dataDir, attempts: 4 }).then(
|
||||
(attachment) =>
|
||||
attachment.servicePid === null
|
||||
? null
|
||||
: {
|
||||
pid: attachment.servicePid,
|
||||
build: attachment.runtimeBuild,
|
||||
},
|
||||
() => null,
|
||||
),
|
||||
queryServiceState: currentRuntimeServiceState,
|
||||
stopRuntime: () => stopRuntimeProcess({ dataDir }),
|
||||
log: ({ decision, service, running }) =>
|
||||
diagnostics.log({
|
||||
level: decision.action === "adopt-unverified" ? "warn" : "info",
|
||||
component: "desktop.stable_runtime",
|
||||
event: "stable_runtime.adoption",
|
||||
outcome:
|
||||
decision.action === "adopt-unverified" ? "degraded" : "succeeded",
|
||||
details: {
|
||||
action: decision.action,
|
||||
reason:
|
||||
"reason" in decision
|
||||
? decision.reason
|
||||
: (service?.reason ?? "no-runtime-running"),
|
||||
status: service?.state ?? "not-queried",
|
||||
version:
|
||||
running === null
|
||||
? "none"
|
||||
: running.build === undefined
|
||||
? "legacy"
|
||||
: String(running.build.buildNumber ?? "unknown"),
|
||||
},
|
||||
}),
|
||||
});
|
||||
preserveRuntimeOnQuitReason = reconciled.preserveReason;
|
||||
if (reconciled.decision.action === "adopt-unverified") {
|
||||
void dialog
|
||||
.showMessageBox({
|
||||
type: "warning",
|
||||
message: "MemHub",
|
||||
detail: stableRuntimeFailureDetail(
|
||||
new Error("runtime_build_unknown"),
|
||||
),
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
const alreadyRunning = reconciled.adopted;
|
||||
if (owner?.owner !== "cli") {
|
||||
const appBundle = path.resolve(path.dirname(process.execPath), "../..");
|
||||
owner = await claimRuntimeServiceForApp({
|
||||
@@ -1264,8 +1358,13 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
// A durable `launchctl disable` override is a state to report, not a
|
||||
// failure to undo: `installUserService` never re-enables a service the
|
||||
// user or the system disabled. An explicit App launch may still own a
|
||||
// transient Runtime for this UI session. Shutdown stops that process.
|
||||
const serviceMode = await installServiceWithDisabledFallback({
|
||||
// transient Runtime for this UI session; quit stops it because the
|
||||
// service is disabled, which it decides by asking launchd again then.
|
||||
// For an enabled service, `installUserService` restarts the job with
|
||||
// `kickstart -k` (or re-bootstraps a changed plist), so an older
|
||||
// Runtime is replaced through launchd rather than killed into a
|
||||
// respawn of the old binary.
|
||||
await installServiceWithDisabledFallback({
|
||||
install: async () => {
|
||||
await installUserService(layout, {
|
||||
uid: process.getuid?.(),
|
||||
@@ -1297,12 +1396,10 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
});
|
||||
},
|
||||
});
|
||||
transientRuntime = serviceMode === "transient";
|
||||
}
|
||||
if (owner?.owner === "app" && !startAtLogin) {
|
||||
await disableRuntimeService(dataDir);
|
||||
}
|
||||
preserveManagedRuntimeOnQuit = !transientRuntime;
|
||||
} else if (standaloneRuntime) {
|
||||
if (
|
||||
!desktopBackgroundLaunch &&
|
||||
@@ -1671,7 +1768,8 @@ if (desktopVersion !== null) {
|
||||
? readLoginPreference(
|
||||
runtimeDataDir,
|
||||
process.platform,
|
||||
preserveManagedRuntimeOnQuit,
|
||||
runtimeServiceManagedByApp,
|
||||
currentRuntimeServiceState,
|
||||
)
|
||||
: Promise.resolve({
|
||||
supported: false,
|
||||
@@ -1684,9 +1782,21 @@ if (desktopVersion !== null) {
|
||||
runtimeDataDir,
|
||||
process.platform,
|
||||
{ openAtLogin: enabled, userConfirmed: true },
|
||||
preserveManagedRuntimeOnQuit,
|
||||
runtimeServiceManagedByApp,
|
||||
currentRuntimeServiceState,
|
||||
);
|
||||
},
|
||||
readRuntimeHosting: async () => {
|
||||
if (!runtimeDataDir || !app.isPackaged) return "unknown";
|
||||
// A Runtime whose build could not be read is never stopped by it.
|
||||
if (preserveRuntimeOnQuitReason) return "unknown";
|
||||
const service = await currentRuntimeServiceState();
|
||||
return service.state === "enabled"
|
||||
? "background-service"
|
||||
: service.state === "disabled"
|
||||
? "app-hosted"
|
||||
: "unknown";
|
||||
},
|
||||
...(trialProfile
|
||||
? { allowLoginStartup: false, productName: app.getName() }
|
||||
: {}),
|
||||
@@ -1718,7 +1828,10 @@ if (desktopVersion !== null) {
|
||||
// stop and says so by taking the embedded path instead.
|
||||
dataDir: runtimeDataDir ?? "",
|
||||
standalone: app.isPackaged && runtimeDataDir !== null,
|
||||
preserveManagedRuntime: preserveManagedRuntimeOnQuit,
|
||||
// Asked now, not remembered from startup: the user may have switched
|
||||
// the background service on or off since.
|
||||
queryServiceState: currentRuntimeServiceState,
|
||||
preserveReason: preserveRuntimeOnQuitReason,
|
||||
stopSupervisor: async () => {
|
||||
const results = await Promise.allSettled([
|
||||
runtimeSupervisor?.stop() ?? coreSupervisor?.stop(),
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { RuntimeServiceState } from "@one-memory/runtime-control";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
compareRuntimeBuild,
|
||||
decideRuntimeAdoption,
|
||||
type RunningRuntime,
|
||||
reconcileRunningRuntime,
|
||||
} from "./runtime-adoption";
|
||||
|
||||
const app = { buildNumber: 38, commit: "abcdef1234567" };
|
||||
const enabled = (jobPid: number | null): RuntimeServiceState => ({
|
||||
state: "enabled",
|
||||
reason: "service-enabled",
|
||||
jobPid,
|
||||
});
|
||||
const disabled: RuntimeServiceState = {
|
||||
state: "disabled",
|
||||
reason: "service-disabled-by-launchctl",
|
||||
jobPid: null,
|
||||
};
|
||||
const unknown: RuntimeServiceState = {
|
||||
state: "unknown",
|
||||
reason: "service-state-timeout",
|
||||
jobPid: null,
|
||||
};
|
||||
const running = (
|
||||
build: RunningRuntime["build"],
|
||||
pid = 4321,
|
||||
): RunningRuntime => ({ pid, build });
|
||||
|
||||
function reconcile(options: {
|
||||
running: RunningRuntime | null;
|
||||
service: RuntimeServiceState;
|
||||
installedChanged?: boolean;
|
||||
}) {
|
||||
const stopRuntime = vi.fn(async () => "stopped");
|
||||
const queryServiceState = vi.fn(async () => options.service);
|
||||
const result = reconcileRunningRuntime({
|
||||
app,
|
||||
installedChanged: options.installedChanged ?? false,
|
||||
findRunning: async () => options.running,
|
||||
queryServiceState,
|
||||
stopRuntime,
|
||||
});
|
||||
return { result, stopRuntime, queryServiceState };
|
||||
}
|
||||
|
||||
describe("comparing the running Runtime's build with the App's", () => {
|
||||
it("compares build numbers, then commits both sides know", () => {
|
||||
expect(compareRuntimeBuild(app, { buildNumber: 38, commit: null })).toBe(
|
||||
"same",
|
||||
);
|
||||
expect(
|
||||
compareRuntimeBuild(app, { buildNumber: 38, commit: "abcdef1" }),
|
||||
).toBe("same");
|
||||
expect(
|
||||
compareRuntimeBuild(app, { buildNumber: 38, commit: "0123456" }),
|
||||
).toBe("app-newer");
|
||||
expect(compareRuntimeBuild(app, { buildNumber: 37, commit: null })).toBe(
|
||||
"app-newer",
|
||||
);
|
||||
expect(compareRuntimeBuild(app, { buildNumber: 39, commit: null })).toBe(
|
||||
"runtime-newer",
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a receipt without build fields as a Runtime older than this App", () => {
|
||||
expect(compareRuntimeBuild(app, undefined)).toBe("app-newer");
|
||||
});
|
||||
|
||||
it("is unknown when either side cannot say", () => {
|
||||
expect(compareRuntimeBuild(app, { buildNumber: null, commit: null })).toBe(
|
||||
"unknown",
|
||||
);
|
||||
expect(compareRuntimeBuild({}, { buildNumber: 38, commit: null })).toBe(
|
||||
"unknown",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("App start adopts or replaces a running Runtime", () => {
|
||||
it("starts one when nothing runs, without asking the service manager", async () => {
|
||||
const { result, stopRuntime, queryServiceState } = reconcile({
|
||||
running: null,
|
||||
service: enabled(null),
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "start" },
|
||||
adopted: false,
|
||||
});
|
||||
expect(queryServiceState).not.toHaveBeenCalled();
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adopts the same build instead of starting a second Runtime", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 38, commit: null }),
|
||||
service: disabled,
|
||||
});
|
||||
await expect(result).resolves.toEqual({
|
||||
decision: { action: "adopt" },
|
||||
adopted: true,
|
||||
preserveReason: null,
|
||||
});
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adopts the service's own job when the service is enabled", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 38, commit: null }, 4321),
|
||||
service: enabled(4321),
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "adopt" },
|
||||
adopted: true,
|
||||
});
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ends with one Runtime when a stray holds the lease beside an enabled service", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 38, commit: null }, 777),
|
||||
service: enabled(null),
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "stop-then-start", reason: "stray-runtime" },
|
||||
adopted: false,
|
||||
});
|
||||
expect(stopRuntime).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not treat a Runtime as a stray when the service state is unknown", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 38, commit: null }, 777),
|
||||
service: unknown,
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "adopt" },
|
||||
});
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces an older Runtime that is the service's job through the service manager", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 37, commit: null }, 4321),
|
||||
service: enabled(4321),
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "replace-via-service" },
|
||||
adopted: false,
|
||||
preserveReason: null,
|
||||
});
|
||||
// No plain kill: launchd would answer it with a respawn of the old job.
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces a legacy service job whose receipt has no build through launchd", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running(undefined, 4321),
|
||||
service: enabled(4321),
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "replace-via-service" },
|
||||
});
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops an older transient or CLI-started Runtime, then starts the right one", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 36, commit: null }, 999),
|
||||
service: disabled,
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "stop-then-start", reason: "app-newer" },
|
||||
adopted: false,
|
||||
});
|
||||
expect(stopRuntime).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("replaces a same-build Runtime when this launch moved `current`", () => {
|
||||
expect(
|
||||
decideRuntimeAdoption({
|
||||
running: running({ buildNumber: 38, commit: null }, 4321),
|
||||
app,
|
||||
service: enabled(4321),
|
||||
installedChanged: true,
|
||||
}),
|
||||
).toEqual({ action: "replace-via-service" });
|
||||
});
|
||||
|
||||
it("adopts a newer Runtime silently and leaves quit to the service state", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 40, commit: null }, 999),
|
||||
service: disabled,
|
||||
});
|
||||
// No special quit pass: with the service disabled, quit stops it like any
|
||||
// adopted Runtime; with it enabled, quit preserves it.
|
||||
await expect(result).resolves.toEqual({
|
||||
decision: { action: "adopt-newer" },
|
||||
adopted: true,
|
||||
preserveReason: null,
|
||||
});
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never stops or downgrades a newer Runtime at startup, even beside an enabled service", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 40, commit: null }, 999),
|
||||
service: enabled(4321),
|
||||
installedChanged: true,
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "adopt-newer" },
|
||||
adopted: true,
|
||||
});
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never kills a Runtime whose build cannot be read", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: null, commit: null }, 999),
|
||||
service: disabled,
|
||||
installedChanged: true,
|
||||
});
|
||||
await expect(result).resolves.toEqual({
|
||||
decision: { action: "adopt-unverified" },
|
||||
adopted: true,
|
||||
preserveReason: "runtime-build-unknown",
|
||||
});
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import type {
|
||||
RuntimeReceiptBuild,
|
||||
RuntimeServiceState,
|
||||
} from "@one-memory/runtime-control";
|
||||
|
||||
/** The build this App carries. */
|
||||
export interface AppBuild {
|
||||
buildNumber?: number;
|
||||
commit?: string;
|
||||
}
|
||||
|
||||
/** The Runtime found serving the data dir when the App starts. */
|
||||
export interface RunningRuntime {
|
||||
pid: number;
|
||||
/** As published in its receipt; `undefined` for a receipt without the
|
||||
* fields, which only a Runtime older than this App writes. */
|
||||
build: RuntimeReceiptBuild | undefined;
|
||||
}
|
||||
|
||||
export type RuntimeVersionRelation =
|
||||
| "same"
|
||||
| "app-newer"
|
||||
| "runtime-newer"
|
||||
| "unknown";
|
||||
|
||||
export type RuntimeAdoption =
|
||||
/** Nothing is running: install or start this App's Runtime. */
|
||||
| { action: "start" }
|
||||
/** Same build: use it as it is. */
|
||||
| { action: "adopt" }
|
||||
/** Its build cannot be compared: use it, never stop it, tell the user. */
|
||||
| { action: "adopt-unverified" }
|
||||
/** Older, and it is the service manager's job: replace it through the
|
||||
* service manager, never with a plain kill launchd would answer with a
|
||||
* respawn of whatever it last loaded. */
|
||||
| { action: "replace-via-service" }
|
||||
/** Older and transient/CLI-started, or a stray beside an enabled service:
|
||||
* stop it gracefully, then install or start the right one. */
|
||||
| { action: "stop-then-start"; reason: "app-newer" | "stray-runtime" }
|
||||
/** Newer than this App: use it as it is, silently. Never stopped or
|
||||
* downgraded at startup and never joined by a second Runtime; quit treats
|
||||
* it like any adopted Runtime. */
|
||||
| { action: "adopt-newer" };
|
||||
|
||||
/** Compare by build number, then by commit when both sides know theirs. */
|
||||
export function compareRuntimeBuild(
|
||||
app: AppBuild,
|
||||
runtime: RuntimeReceiptBuild | undefined,
|
||||
): RuntimeVersionRelation {
|
||||
if (app.buildNumber === undefined) return "unknown";
|
||||
// Every Runtime that publishes its build carries the fields, even as null.
|
||||
// A receipt without them predates that contract, and so this App.
|
||||
if (runtime === undefined) return "app-newer";
|
||||
if (runtime.buildNumber === null) return "unknown";
|
||||
if (runtime.buildNumber < app.buildNumber) return "app-newer";
|
||||
if (runtime.buildNumber > app.buildNumber) return "runtime-newer";
|
||||
const appCommit = app.commit?.trim().toLowerCase();
|
||||
if (
|
||||
appCommit &&
|
||||
runtime.commit &&
|
||||
!appCommit.startsWith(runtime.commit) &&
|
||||
!runtime.commit.startsWith(appCommit)
|
||||
) {
|
||||
// Same build, different commit: the App's bundle takes over, exactly as
|
||||
// `installPackagedRuntime({ replaceSameBuild: true })` already does.
|
||||
return "app-newer";
|
||||
}
|
||||
return "same";
|
||||
}
|
||||
|
||||
export function decideRuntimeAdoption(input: {
|
||||
running: RunningRuntime | null;
|
||||
app: AppBuild;
|
||||
service: RuntimeServiceState;
|
||||
/** This launch just moved `current` to the App's bundled Runtime, so the
|
||||
* process still running was started from the install it replaced. */
|
||||
installedChanged?: boolean;
|
||||
}): RuntimeAdoption {
|
||||
const { running, service } = input;
|
||||
if (running === null) return { action: "start" };
|
||||
let relation = compareRuntimeBuild(input.app, running.build);
|
||||
if (relation === "same" && input.installedChanged) relation = "app-newer";
|
||||
if (relation === "runtime-newer") return { action: "adopt-newer" };
|
||||
if (relation === "unknown") return { action: "adopt-unverified" };
|
||||
const serviceJob = service.jobPid !== null && service.jobPid === running.pid;
|
||||
if (relation === "app-newer") {
|
||||
return serviceJob
|
||||
? { action: "replace-via-service" }
|
||||
: { action: "stop-then-start", reason: "app-newer" };
|
||||
}
|
||||
// One Runtime per data dir: with the service on, the one that stays is the
|
||||
// service's own job, not a transient or CLI process holding its lease.
|
||||
if (service.state === "enabled" && !serviceJob) {
|
||||
return { action: "stop-then-start", reason: "stray-runtime" };
|
||||
}
|
||||
return { action: "adopt" };
|
||||
}
|
||||
|
||||
export interface RuntimeReconcileDependencies {
|
||||
app: AppBuild;
|
||||
installedChanged?: boolean;
|
||||
findRunning(): Promise<RunningRuntime | null>;
|
||||
queryServiceState(): Promise<RuntimeServiceState>;
|
||||
/** Graceful stop of the one verified Runtime: in-flight writes finish. */
|
||||
stopRuntime(): Promise<unknown>;
|
||||
log?(event: {
|
||||
decision: RuntimeAdoption;
|
||||
service: RuntimeServiceState | null;
|
||||
running: RunningRuntime | null;
|
||||
}): Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface RuntimeReconcileResult {
|
||||
decision: RuntimeAdoption;
|
||||
/** The running Runtime stays and the caller must not start another. */
|
||||
adopted: boolean;
|
||||
/** Quit must leave the adopted Runtime alone whatever the service state:
|
||||
* this App could not prove it is its own build. */
|
||||
preserveReason: string | null;
|
||||
}
|
||||
|
||||
/** Decide what to do with a Runtime already serving the data dir at App start,
|
||||
* and do the part that has to happen before the normal start path runs. */
|
||||
export async function reconcileRunningRuntime(
|
||||
dependencies: RuntimeReconcileDependencies,
|
||||
): Promise<RuntimeReconcileResult> {
|
||||
const running = await dependencies.findRunning();
|
||||
const service =
|
||||
running === null ? null : await dependencies.queryServiceState();
|
||||
const decision = decideRuntimeAdoption({
|
||||
running,
|
||||
app: dependencies.app,
|
||||
service: service ?? {
|
||||
state: "unknown",
|
||||
reason: "service-state-query-failed",
|
||||
jobPid: null,
|
||||
},
|
||||
installedChanged: dependencies.installedChanged ?? false,
|
||||
});
|
||||
await dependencies.log?.({ decision, service, running });
|
||||
switch (decision.action) {
|
||||
case "start":
|
||||
case "replace-via-service":
|
||||
return { decision, adopted: false, preserveReason: null };
|
||||
case "stop-then-start":
|
||||
await dependencies.stopRuntime();
|
||||
return { decision, adopted: false, preserveReason: null };
|
||||
case "adopt":
|
||||
case "adopt-newer":
|
||||
return { decision, adopted: true, preserveReason: null };
|
||||
case "adopt-unverified":
|
||||
return {
|
||||
decision,
|
||||
adopted: true,
|
||||
preserveReason: "runtime-build-unknown",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,96 +1,162 @@
|
||||
import type {
|
||||
RuntimeServiceState,
|
||||
StopAllRuntimesResult,
|
||||
} from "@one-memory/runtime-control";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { shutdownRuntime } from "./runtime-shutdown";
|
||||
|
||||
const { stopRuntimeProcess } = vi.hoisted(() => ({
|
||||
stopRuntimeProcess: vi.fn<() => Promise<"stopped" | "not-running">>(
|
||||
async () => "stopped",
|
||||
),
|
||||
const { stopAllRuntimeProcesses } = vi.hoisted(() => ({
|
||||
stopAllRuntimeProcesses: vi.fn<
|
||||
(options: { dataDir: string }) => Promise<StopAllRuntimesResult>
|
||||
>(async () => ({ stopped: [4321], unverified: [] })),
|
||||
}));
|
||||
|
||||
vi.mock("@one-memory/runtime-control", () => ({ stopRuntimeProcess }));
|
||||
vi.mock("@one-memory/runtime-control", () => ({ stopAllRuntimeProcesses }));
|
||||
|
||||
const stopMock = vi.mocked(stopRuntimeProcess);
|
||||
const stopMock = vi.mocked(stopAllRuntimeProcesses);
|
||||
|
||||
const enabled: RuntimeServiceState = {
|
||||
state: "enabled",
|
||||
reason: "service-enabled",
|
||||
jobPid: 4321,
|
||||
};
|
||||
const disabled = (
|
||||
reason: RuntimeServiceState["reason"] = "service-enable-marker-absent",
|
||||
): RuntimeServiceState => ({ state: "disabled", reason, jobPid: null });
|
||||
const unknown: RuntimeServiceState = {
|
||||
state: "unknown",
|
||||
reason: "service-state-timeout",
|
||||
jobPid: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
stopMock.mockClear();
|
||||
stopMock.mockResolvedValue("stopped");
|
||||
stopMock.mockReset();
|
||||
stopMock.mockResolvedValue({ stopped: [4321], unverified: [] });
|
||||
});
|
||||
|
||||
describe("BUG-12: a full quit stops the standalone Runtime process", () => {
|
||||
it("detaches the UI without stopping a managed Runtime service", async () => {
|
||||
describe("App quit with the background service enabled", () => {
|
||||
it("never stops the Runtime", async () => {
|
||||
const stopSupervisor = vi.fn(async () => {});
|
||||
const events: string[] = [];
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
preserveManagedRuntime: true,
|
||||
queryServiceState: async () => enabled,
|
||||
stopSupervisor,
|
||||
onDiagnostic: (event) => {
|
||||
events.push(event.detail);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ runtime: "unchanged" });
|
||||
expect(result).toEqual({
|
||||
runtime: "preserved",
|
||||
reason: "background-service-enabled",
|
||||
});
|
||||
expect(stopSupervisor).toHaveBeenCalledOnce();
|
||||
expect(stopMock).not.toHaveBeenCalled();
|
||||
expect(events).toEqual(["managed-runtime-preserved"]);
|
||||
expect(events).toEqual(["runtime-preserved:background-service-enabled"]);
|
||||
});
|
||||
|
||||
it("stops the Runtime process in a packaged build", async () => {
|
||||
const stopSupervisor = vi.fn(async () => {});
|
||||
it("does not stop a transient Runtime this App started before the service was enabled", async () => {
|
||||
// Startup fell back to a transient child (service launchctl-disabled) and
|
||||
// the user re-enabled the service later. Nothing startup saw is an input
|
||||
// here: quit asks the service manager again and preserves the Runtime.
|
||||
const queryServiceState = vi.fn(async () => enabled);
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
stopSupervisor,
|
||||
queryServiceState,
|
||||
stopSupervisor: async () => {},
|
||||
});
|
||||
|
||||
expect(queryServiceState).toHaveBeenCalledOnce();
|
||||
expect(result.runtime).toBe("preserved");
|
||||
expect(stopMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("App quit with the background service disabled", () => {
|
||||
it("stops every Runtime of the data dir: the App's child and an external one", async () => {
|
||||
stopMock.mockResolvedValue({ stopped: [1111, 2222], unverified: [] });
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
queryServiceState: async () => disabled(),
|
||||
stopSupervisor: async () => {},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ runtime: "stopped" });
|
||||
expect(stopMock).toHaveBeenCalledWith({ dataDir: "/tmp/runtime-data" });
|
||||
expect(stopSupervisor).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not touch a separate Runtime process in an embedded build", async () => {
|
||||
const stopSupervisor = vi.fn(async () => {});
|
||||
it.each([
|
||||
"service-not-installed",
|
||||
"service-disabled-by-launchctl",
|
||||
"service-enable-marker-absent",
|
||||
] as const)("treats %s as disabled", async (reason) => {
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
queryServiceState: async () => disabled(reason),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ runtime: "stopped" });
|
||||
expect(stopMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports a live lease owner it could not verify instead of signalling it", async () => {
|
||||
stopMock.mockResolvedValue({ stopped: [], unverified: [9999] });
|
||||
const events: string[] = [];
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: false,
|
||||
stopSupervisor,
|
||||
standalone: true,
|
||||
queryServiceState: async () => disabled(),
|
||||
onDiagnostic: (event) => {
|
||||
events.push(`${event.outcome}:${event.detail}`);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ runtime: "unchanged" });
|
||||
expect(stopMock).not.toHaveBeenCalled();
|
||||
expect(stopSupervisor).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual({
|
||||
runtime: "failed",
|
||||
detail: "unverified-runtime-left-running",
|
||||
});
|
||||
expect(events).toEqual(["degraded:unverified-runtime-left-running"]);
|
||||
});
|
||||
|
||||
it("tears the supervisor down before it stops the process", async () => {
|
||||
const order: string[] = [];
|
||||
stopMock.mockImplementation(async () => {
|
||||
order.push("process");
|
||||
return "stopped";
|
||||
return { stopped: [4321], unverified: [] };
|
||||
});
|
||||
|
||||
await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
queryServiceState: async () => {
|
||||
order.push("query");
|
||||
return disabled();
|
||||
},
|
||||
stopSupervisor: async () => {
|
||||
order.push("supervisor");
|
||||
},
|
||||
});
|
||||
|
||||
expect(order).toEqual(["supervisor", "process"]);
|
||||
expect(order).toEqual(["supervisor", "query", "process"]);
|
||||
});
|
||||
|
||||
it("reports not-running instead of throwing when nothing is attached", async () => {
|
||||
stopMock.mockResolvedValue("not-running");
|
||||
it("reports not-running when nothing serves the data dir", async () => {
|
||||
stopMock.mockResolvedValue({ stopped: [], unverified: [] });
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
stopSupervisor: async () => {},
|
||||
queryServiceState: async () => disabled(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ runtime: "not-running" });
|
||||
@@ -103,7 +169,7 @@ describe("BUG-12: a full quit stops the standalone Runtime process", () => {
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
stopSupervisor: async () => {},
|
||||
queryServiceState: async () => disabled(),
|
||||
onDiagnostic: async (event) => {
|
||||
events.push(event.outcome);
|
||||
},
|
||||
@@ -117,24 +183,100 @@ describe("BUG-12: a full quit stops the standalone Runtime process", () => {
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
queryServiceState: async () => disabled(),
|
||||
stopSupervisor: async () => {
|
||||
throw new Error("supervisor exploded");
|
||||
},
|
||||
});
|
||||
|
||||
// The process is what must not be left behind; a supervisor error is not
|
||||
// an excuse to skip it.
|
||||
expect(stopMock).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual({
|
||||
runtime: "failed",
|
||||
detail: "supervisor exploded",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("App quit when the service state cannot be determined", () => {
|
||||
it("stops nothing on a timeout and says so", async () => {
|
||||
const events: string[] = [];
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
queryServiceState: async () => unknown,
|
||||
onDiagnostic: (event) => {
|
||||
events.push(`${event.outcome}:${event.detail}`);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
runtime: "preserved",
|
||||
reason: "service-state-unknown:service-state-timeout",
|
||||
});
|
||||
expect(stopMock).not.toHaveBeenCalled();
|
||||
expect(events).toEqual([
|
||||
"degraded:runtime-preserved:service-state-unknown:service-state-timeout",
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops nothing when the query itself throws", async () => {
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
queryServiceState: async () => {
|
||||
throw new Error("launchctl exploded");
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
runtime: "preserved",
|
||||
reason: "service-state-unknown:service-state-query-failed",
|
||||
});
|
||||
expect(stopMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Runtimes quit never touches", () => {
|
||||
it("leaves a Runtime whose build could not be read, even with the service disabled", async () => {
|
||||
const queryServiceState = vi.fn(async () => disabled());
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
queryServiceState,
|
||||
preserveReason: "runtime-build-unknown",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
runtime: "preserved",
|
||||
reason: "runtime-build-unknown",
|
||||
});
|
||||
expect(stopMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not touch a separate Runtime process in an embedded build", async () => {
|
||||
const stopSupervisor = vi.fn(async () => {});
|
||||
const queryServiceState = vi.fn(async () => disabled());
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: false,
|
||||
queryServiceState,
|
||||
stopSupervisor,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ runtime: "unchanged" });
|
||||
expect(stopMock).not.toHaveBeenCalled();
|
||||
expect(queryServiceState).not.toHaveBeenCalled();
|
||||
expect(stopSupervisor).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports a supervisor failure in an embedded build too", async () => {
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: false,
|
||||
queryServiceState: async () => disabled(),
|
||||
stopSupervisor: async () => {
|
||||
throw new Error("supervisor exploded");
|
||||
},
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { stopRuntimeProcess } from "@one-memory/runtime-control";
|
||||
import {
|
||||
type RuntimeServiceState,
|
||||
type StopAllRuntimesResult,
|
||||
stopAllRuntimeProcesses,
|
||||
} from "@one-memory/runtime-control";
|
||||
|
||||
export interface RuntimeShutdownOptions {
|
||||
dataDir: string;
|
||||
/** Packaged builds run the Runtime as their own process; a dev build's Core
|
||||
* is this process's child and is stopped by the supervisor. */
|
||||
standalone: boolean;
|
||||
/** A managed OS service outlives Electron; a transient App-spawned Runtime
|
||||
* is still stopped on quit. */
|
||||
preserveManagedRuntime?: boolean;
|
||||
/** Asks the service manager whether the background service is on. Called
|
||||
* at quit time, every time: what startup saw may no longer be true. */
|
||||
queryServiceState: () => Promise<RuntimeServiceState>;
|
||||
/** Set when startup adopted a Runtime whose build it could not read. This
|
||||
* App never stops such a Runtime. A newer Runtime gets no such pass: it
|
||||
* follows the service state like any adopted Runtime. */
|
||||
preserveReason?: string | null;
|
||||
stopRuntimes?: (options: {
|
||||
dataDir: string;
|
||||
}) => Promise<StopAllRuntimesResult>;
|
||||
/** Tear down this process's supervisor state (Core child, broker). */
|
||||
stopSupervisor?: () => Promise<void>;
|
||||
onDiagnostic?: (event: {
|
||||
@@ -19,16 +30,22 @@ export interface RuntimeShutdownOptions {
|
||||
export type RuntimeShutdownResult =
|
||||
| { runtime: "stopped" }
|
||||
| { runtime: "not-running" }
|
||||
| { runtime: "preserved"; reason: string }
|
||||
| { runtime: "unchanged" }
|
||||
| { runtime: "failed"; detail: string };
|
||||
|
||||
/** Stop everything a full quit has to stop.
|
||||
/** Stop everything a full quit has to stop, and nothing else.
|
||||
*
|
||||
* Quitting used to tear down this process's own handles and nothing else. In a
|
||||
* packaged build the Runtime is a separate process, so it outlived the app: the
|
||||
* window and the tray were gone and the Runtime was still there, and its
|
||||
* receipt still named a pid, so the next launch concluded it was already
|
||||
* running and attached to a process that no longer answered.
|
||||
* Exactly one Runtime serves a data dir. Whether quitting the App ends it is
|
||||
* decided here, at quit time, from the service manager's answer:
|
||||
*
|
||||
* - background service enabled: the Runtime is the user's background memory
|
||||
* service. Quit never stops it, whoever started the process, including a
|
||||
* transient Runtime this App started earlier.
|
||||
* - background service disabled: the Runtime lives only while the App does.
|
||||
* Quit stops every Runtime of this data dir: the App's own child, one
|
||||
* `memhub start` launched, a leftover from an earlier session.
|
||||
* - state unknown (query failed or timed out): stop nothing, and say so.
|
||||
*
|
||||
* A failure here is reported, never thrown. By the time this runs the app is
|
||||
* already quitting, and throwing would only turn a failure someone could see
|
||||
@@ -39,15 +56,6 @@ export async function shutdownRuntime(
|
||||
): Promise<RuntimeShutdownResult> {
|
||||
const stopSupervisor = options.stopSupervisor ?? (async () => {});
|
||||
const supervisorFailure = await settle(stopSupervisor);
|
||||
if (options.preserveManagedRuntime) {
|
||||
await options.onDiagnostic?.({
|
||||
outcome: supervisorFailure ? "degraded" : "succeeded",
|
||||
detail: supervisorFailure ?? "managed-runtime-preserved",
|
||||
});
|
||||
return supervisorFailure
|
||||
? { runtime: "failed", detail: supervisorFailure }
|
||||
: { runtime: "unchanged" };
|
||||
}
|
||||
if (!options.standalone) {
|
||||
if (supervisorFailure) {
|
||||
await options.onDiagnostic?.({
|
||||
@@ -58,16 +66,52 @@ export async function shutdownRuntime(
|
||||
}
|
||||
return { runtime: "unchanged" };
|
||||
}
|
||||
try {
|
||||
const result = await stopRuntimeProcess({ dataDir: options.dataDir });
|
||||
const preserved = async (
|
||||
reason: string,
|
||||
outcome: "succeeded" | "degraded",
|
||||
): Promise<RuntimeShutdownResult> => {
|
||||
await options.onDiagnostic?.({
|
||||
outcome: supervisorFailure ? "degraded" : "succeeded",
|
||||
detail: supervisorFailure ?? result,
|
||||
outcome: supervisorFailure ? "degraded" : outcome,
|
||||
detail: supervisorFailure ?? `runtime-preserved:${reason}`,
|
||||
});
|
||||
return supervisorFailure
|
||||
? { runtime: "failed", detail: supervisorFailure }
|
||||
: { runtime: "preserved", reason };
|
||||
};
|
||||
if (options.preserveReason) {
|
||||
return preserved(options.preserveReason, "succeeded");
|
||||
}
|
||||
const service = await options.queryServiceState().catch(
|
||||
(): RuntimeServiceState => ({
|
||||
state: "unknown",
|
||||
reason: "service-state-query-failed",
|
||||
jobPid: null,
|
||||
}),
|
||||
);
|
||||
if (service.state === "enabled") {
|
||||
return preserved("background-service-enabled", "succeeded");
|
||||
}
|
||||
if (service.state !== "disabled") {
|
||||
// Fail safe: a Runtime stopped by mistake takes the user's background
|
||||
// memory with it; one left running by mistake is only a process.
|
||||
return preserved(`service-state-unknown:${service.reason}`, "degraded");
|
||||
}
|
||||
try {
|
||||
const result = await (options.stopRuntimes ?? stopAllRuntimeProcesses)({
|
||||
dataDir: options.dataDir,
|
||||
});
|
||||
const stopped = result.stopped.length > 0 ? "stopped" : "not-running";
|
||||
const unverified =
|
||||
result.unverified.length > 0 ? "unverified-runtime-left-running" : null;
|
||||
await options.onDiagnostic?.({
|
||||
outcome: supervisorFailure || unverified ? "degraded" : "succeeded",
|
||||
detail: supervisorFailure ?? unverified ?? `${stopped}:${service.reason}`,
|
||||
});
|
||||
if (supervisorFailure) {
|
||||
return { runtime: "failed", detail: supervisorFailure };
|
||||
}
|
||||
return { runtime: result };
|
||||
if (unverified) return { runtime: "failed", detail: unverified };
|
||||
return { runtime: stopped };
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : "unknown";
|
||||
await options.onDiagnostic?.({ outcome: "degraded", detail });
|
||||
|
||||
@@ -143,6 +143,7 @@ const SAFE_STABLE_RUNTIME_ERROR_CODES = new Set([
|
||||
"sdk_migration_status_unavailable",
|
||||
"sdk_migration_incomplete",
|
||||
"runtime_service_not_ready",
|
||||
"runtime_build_unknown",
|
||||
]);
|
||||
|
||||
/** Every migration code is a stable token Core or the orchestrator chose;
|
||||
@@ -242,6 +243,8 @@ export const STABLE_RUNTIME_USER_MESSAGES: Record<string, string> = {
|
||||
"记忆数据格式升级没有完成,MemHub 暂时无法打开。请重新启动重试;如果仍然失败,请把这个提示反馈给我们,原有记忆数据不会丢失。",
|
||||
runtime_service_not_ready:
|
||||
"记忆服务仍在首次启动,请稍候后重试。你的记忆数据不会受到影响。",
|
||||
runtime_build_unknown:
|
||||
"无法确认正在运行的 MemHub 后台版本,App 已连接到它,但不会停止或替换它。如遇问题,请更新 App 或重启后台服务;你的记忆数据不会受到影响。",
|
||||
desktop_start_failed:
|
||||
"MemHub 启动失败。请重新启动;如果仍然打不开,请重新安装,你的记忆数据不会受到影响。",
|
||||
};
|
||||
|
||||
@@ -545,6 +545,7 @@ const baseEnglishMessages = {
|
||||
nativeDevelopmentUnavailable: " (unavailable in development)",
|
||||
nativeOpen: "Open {product}",
|
||||
nativeQuit: "Quit {product} (background service keeps running)",
|
||||
nativeQuitStopsService: "Quit {product} (also stops the memory service)",
|
||||
nativeLoginError: "Could not change the login startup setting. Please retry.",
|
||||
nativeMoveTitle: "Move MemHub to Applications first",
|
||||
nativeMoveDetail:
|
||||
|
||||
@@ -13,6 +13,7 @@ export const recoveryChineseMessages = {
|
||||
nativeDevelopmentUnavailable: "(开发模式不可用)",
|
||||
nativeOpen: "打开 {product}",
|
||||
nativeQuit: "退出 {product}(后台服务继续运行)",
|
||||
nativeQuitStopsService: "退出 {product}(同时停止记忆服务)",
|
||||
nativeLoginError: "无法更改登录启动设置,请重试。",
|
||||
nativeMoveTitle: "请先将 MemHub 拖到“应用程序”文件夹",
|
||||
nativeMoveDetail:
|
||||
@@ -115,6 +116,8 @@ export const recoverySpanishMessages: Record<
|
||||
nativeDevelopmentUnavailable: " (no disponible en desarrollo)",
|
||||
nativeOpen: "Abrir {product}",
|
||||
nativeQuit: "Salir de {product} (el servicio sigue en segundo plano)",
|
||||
nativeQuitStopsService:
|
||||
"Salir de {product} (también detiene el servicio de memoria)",
|
||||
nativeLoginError:
|
||||
"No se pudo cambiar el inicio de sesión. Inténtalo de nuevo.",
|
||||
nativeMoveTitle: "Arrastra MemHub a la carpeta Aplicaciones primero",
|
||||
|
||||
@@ -713,6 +713,8 @@ export const germanMessages: Record<MessageKey, string> = {
|
||||
nativeDevelopmentUnavailable: " (in Entwicklung nicht verfügbar)",
|
||||
nativeOpen: "{product} öffnen",
|
||||
nativeQuit: "{product} beenden (Hintergrunddienst läuft weiter)",
|
||||
nativeQuitStopsService:
|
||||
"{product} beenden (Gedächtnisdienst wird ebenfalls beendet)",
|
||||
nativeLoginError:
|
||||
"Starteinstellung bei Anmeldung konnte nicht geändert werden. Bitte wiederholen Sie den Vorgang.",
|
||||
nativeMoveTitle: "Verschieben Sie MemHub zuerst nach „Programme“",
|
||||
|
||||
@@ -723,6 +723,8 @@ export const frenchMessages: Record<MessageKey, string> = {
|
||||
nativeOpen: "Ouvrir {product}",
|
||||
nativeQuit:
|
||||
"Quitter {product} (le service en arrière-plan continue de fonctionner)",
|
||||
nativeQuitStopsService:
|
||||
"Quitter {product} (le service de mémoire s’arrête aussi)",
|
||||
nativeLoginError:
|
||||
"Impossible de modifier le réglage de lancement à l’ouverture de session. Veuillez réessayer.",
|
||||
nativeMoveTitle: "Déplacez d’abord MemHub dans Applications",
|
||||
|
||||
@@ -699,6 +699,8 @@ export const indonesianMessages: Record<MessageKey, string> = {
|
||||
nativeDevelopmentUnavailable: " (tidak tersedia saat pengembangan)",
|
||||
nativeOpen: "Buka {product}",
|
||||
nativeQuit: "Keluar dari {product} (layanan latar belakang tetap berjalan)",
|
||||
nativeQuitStopsService:
|
||||
"Keluar dari {product} (layanan memori juga dihentikan)",
|
||||
nativeLoginError:
|
||||
"Tidak dapat mengubah pengaturan mulai saat masuk. Silakan coba lagi.",
|
||||
nativeMoveTitle: "Pindahkan MemHub ke Aplikasi terlebih dahulu",
|
||||
|
||||
@@ -697,6 +697,7 @@ export const japaneseMessages: Record<MessageKey, string> = {
|
||||
nativeDevelopmentUnavailable: " (開発モードでは利用不可)",
|
||||
nativeOpen: "{product} を開く",
|
||||
nativeQuit: "{product} を終了(バックグラウンドサービスは引き続き実行)",
|
||||
nativeQuitStopsService: "{product} を終了(メモリサービスも停止)",
|
||||
nativeLoginError:
|
||||
"ログイン時の起動設定を変更できませんでした。もう一度お試しください。",
|
||||
nativeMoveTitle:
|
||||
|
||||
@@ -678,6 +678,7 @@ export const koreanMessages: Record<MessageKey, string> = {
|
||||
nativeDevelopmentUnavailable: " (개발 모드에서 사용 불가)",
|
||||
nativeOpen: "{product} 열기",
|
||||
nativeQuit: "{product} 종료(백그라운드 서비스는 계속 실행)",
|
||||
nativeQuitStopsService: "{product} 종료(메모리 서비스도 중지)",
|
||||
nativeLoginError:
|
||||
"로그인 시 실행 설정을 변경하지 못했습니다. 다시 시도하세요.",
|
||||
nativeMoveTitle: "먼저 MemHub를 응용 프로그램 폴더로 옮기세요",
|
||||
|
||||
@@ -718,6 +718,8 @@ export const portugueseMessages: Record<MessageKey, string> = {
|
||||
nativeOpen: "Abrir {product}",
|
||||
nativeQuit:
|
||||
"Sair do {product} (o serviço em segundo plano continua em execução)",
|
||||
nativeQuitStopsService:
|
||||
"Sair do {product} (o serviço de memória também é encerrado)",
|
||||
nativeLoginError:
|
||||
"Não foi possível alterar a configuração de abertura ao iniciar sessão. Tente novamente.",
|
||||
nativeMoveTitle: "Mova o MemHub para Aplicativos primeiro",
|
||||
|
||||
@@ -705,6 +705,7 @@ export const russianMessages: Record<MessageKey, string> = {
|
||||
nativeDevelopmentUnavailable: " (недоступно в режиме разработки)",
|
||||
nativeOpen: "Открыть {product}",
|
||||
nativeQuit: "Выйти из {product} (фоновая служба продолжит работу)",
|
||||
nativeQuitStopsService: "Выйти из {product} (служба памяти тоже остановится)",
|
||||
nativeLoginError:
|
||||
"Не удалось изменить настройку запуска при входе. Повторите попытку.",
|
||||
nativeMoveTitle: "Сначала переместите MemHub в «Программы»",
|
||||
|
||||
@@ -625,6 +625,7 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
|
||||
nativeDevelopmentUnavailable: "(開發模式不可用)",
|
||||
nativeOpen: "開啟 {product}",
|
||||
nativeQuit: "結束 {product}(背景服務會繼續執行)",
|
||||
nativeQuitStopsService: "結束 {product}(同時停止記憶服務)",
|
||||
nativeLoginError: "無法更改登入啟動設定,請重試。",
|
||||
nativeMoveTitle: "請先將 MemHub 移到「應用程式」檔案夾",
|
||||
nativeMoveDetail:
|
||||
|
||||
@@ -16,9 +16,18 @@ export interface RuntimeAttachment {
|
||||
* `degraded`, never `healthy`. Null/absent when the receipt predates the
|
||||
* field (an older Runtime), which is treated as "unknown", not "healthy". */
|
||||
sidecarState?: "running" | "failed" | "stopped" | null;
|
||||
/** Build the Runtime published in its receipt. `undefined` when the receipt
|
||||
* predates the field (a Runtime older than every build that publishes it);
|
||||
* `null` members when the Runtime does not know its own build. */
|
||||
runtimeBuild?: RuntimeReceiptBuild;
|
||||
health: RuntimeIdentity;
|
||||
}
|
||||
|
||||
export interface RuntimeReceiptBuild {
|
||||
buildNumber: number | null;
|
||||
commit: string | null;
|
||||
}
|
||||
|
||||
export interface RuntimeAttachmentOptions {
|
||||
dataDir: string;
|
||||
processIsAlive(pid: number): boolean;
|
||||
@@ -63,6 +72,7 @@ interface RuntimeServiceReceipt {
|
||||
status: "starting" | "running" | "stopping";
|
||||
corePort: number | null;
|
||||
sidecarState: "running" | "failed" | "stopped" | null;
|
||||
build?: RuntimeReceiptBuild;
|
||||
}
|
||||
|
||||
/** The Core port a *running* managed Runtime published, or null when there is
|
||||
@@ -143,6 +153,9 @@ export async function discoverRuntimeAttachment(
|
||||
port: receipt.port,
|
||||
servicePid: service?.pid ?? null,
|
||||
sidecarState: service?.sidecarState ?? null,
|
||||
...(service?.build === undefined
|
||||
? {}
|
||||
: { runtimeBuild: Object.freeze({ ...service.build }) }),
|
||||
health: Object.freeze({ ...health }),
|
||||
});
|
||||
}
|
||||
@@ -190,6 +203,7 @@ async function readRuntimeServiceReceipt(
|
||||
) {
|
||||
throw new RuntimeDiscoveryError("runtime_service_receipt_invalid");
|
||||
}
|
||||
const build = receiptBuild(item);
|
||||
return {
|
||||
instanceId: item.instance_id,
|
||||
pid: item.pid,
|
||||
@@ -199,6 +213,28 @@ async function readRuntimeServiceReceipt(
|
||||
item.sidecar === undefined
|
||||
? null
|
||||
: (item.sidecar as RuntimeServiceReceipt["sidecarState"]),
|
||||
...(build === undefined ? {} : { build }),
|
||||
};
|
||||
}
|
||||
|
||||
/** A malformed build field reads as "unknown", never as a receipt error: the
|
||||
* build only decides whether an App adopts this Runtime, and an unknown build
|
||||
* makes it leave the Runtime alone. */
|
||||
function receiptBuild(
|
||||
item: Record<string, unknown>,
|
||||
): RuntimeReceiptBuild | undefined {
|
||||
if (!("build_number" in item) && !("git_commit" in item)) return undefined;
|
||||
const build = item.build_number;
|
||||
const commit = item.git_commit;
|
||||
return {
|
||||
buildNumber:
|
||||
typeof build === "number" && Number.isSafeInteger(build) && build > 0
|
||||
? build
|
||||
: null,
|
||||
commit:
|
||||
typeof commit === "string" && /^[0-9a-f]{7,40}$/.test(commit)
|
||||
? commit
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ export {
|
||||
type RuntimeAttachmentOptions,
|
||||
RuntimeDiscoveryError,
|
||||
type RuntimeIdentity,
|
||||
type RuntimeReceiptBuild,
|
||||
readPublishedCorePort,
|
||||
} from "./discovery.js";
|
||||
export {
|
||||
@@ -144,12 +145,17 @@ export {
|
||||
type RuntimeCliConfig,
|
||||
resolveRuntimeCliConfig,
|
||||
runRuntimeCli,
|
||||
runtimeBuildIdentity,
|
||||
} from "./runtime-cli.js";
|
||||
export {
|
||||
type EnsureRuntimeOptions,
|
||||
ensureRuntimeProcess,
|
||||
type RuntimeLaunch,
|
||||
readRuntimeLeaseOwnerPid,
|
||||
type StopAllRuntimesOptions,
|
||||
type StopAllRuntimesResult,
|
||||
type StopRuntimeOptions,
|
||||
stopAllRuntimeProcesses,
|
||||
stopRuntimeProcess,
|
||||
type WaitForRuntimeOptions,
|
||||
waitForRuntimeProcess,
|
||||
@@ -160,6 +166,7 @@ export {
|
||||
} from "./runtime-maintenance.js";
|
||||
export {
|
||||
type ManagedRuntime,
|
||||
type RuntimeBuildIdentity,
|
||||
RuntimeService,
|
||||
type RuntimeServiceOptions,
|
||||
} from "./runtime-service.js";
|
||||
@@ -235,6 +242,15 @@ export {
|
||||
runtimeInstallLayout,
|
||||
stableRuntimeInstallRoot,
|
||||
} from "./service-plan.js";
|
||||
export {
|
||||
parseLaunchctlPid,
|
||||
queryRuntimeServiceState,
|
||||
type RuntimeServiceState,
|
||||
type RuntimeServiceStateName,
|
||||
type RuntimeServiceStateOptions,
|
||||
type RuntimeServiceStateReason,
|
||||
type ServiceStateProbe,
|
||||
} from "./service-state.js";
|
||||
export { createSourceChangeHandler } from "./source-change-handler.js";
|
||||
export {
|
||||
type ApplySyncHookInput,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
resolveRuntimeCliConfig,
|
||||
revalidateHostRuntime,
|
||||
runRuntimeCli,
|
||||
runtimeBuildIdentity,
|
||||
waitForAppRemoval,
|
||||
} from "./runtime-cli.js";
|
||||
import {
|
||||
@@ -1030,3 +1031,18 @@ describe("Runtime CLI service plan", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the build a Runtime publishes in its receipt", () => {
|
||||
it("is its build number and commit, and null where it does not know", () => {
|
||||
expect(
|
||||
runtimeBuildIdentity({ buildNumber: "38", commit: "ABCDEF1234" }),
|
||||
).toEqual({ buildNumber: 38, commit: "abcdef1234" });
|
||||
expect(runtimeBuildIdentity(undefined)).toEqual({
|
||||
buildNumber: null,
|
||||
commit: null,
|
||||
});
|
||||
expect(
|
||||
runtimeBuildIdentity({ buildNumber: "dev", commit: "unknown" }),
|
||||
).toEqual({ buildNumber: null, commit: null });
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,10 @@ import { HostPermissions } from "./host-permissions.js";
|
||||
import { verifyInstalledDistribution } from "./local-install.js";
|
||||
import { RecallBrokerSupervisor } from "./recall-broker-supervisor.js";
|
||||
import { recallHosts } from "./recall-hosts.js";
|
||||
import { RuntimeService } from "./runtime-service.js";
|
||||
import {
|
||||
type RuntimeBuildIdentity,
|
||||
RuntimeService,
|
||||
} from "./runtime-service.js";
|
||||
import { RuntimeSupervisor } from "./runtime-supervisor.js";
|
||||
import {
|
||||
runSdkMigration,
|
||||
@@ -653,6 +656,19 @@ function boundFingerprint(
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/** The build this Runtime publishes in its receipt. Unknown stays `null`, never
|
||||
* a guess: an App treats an unknown build as "do not touch". */
|
||||
export function runtimeBuildIdentity(
|
||||
identity: RuntimeCliConfig["buildIdentity"],
|
||||
): RuntimeBuildIdentity {
|
||||
const build = Number(identity?.buildNumber);
|
||||
const commit = identity?.commit?.trim().toLowerCase() ?? "";
|
||||
return {
|
||||
buildNumber: Number.isSafeInteger(build) && build > 0 ? build : null,
|
||||
commit: /^[0-9a-f]{7,40}$/.test(commit) ? commit : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createRuntimeService(
|
||||
config: RuntimeCliConfig,
|
||||
): Promise<RuntimeService> {
|
||||
@@ -668,6 +684,7 @@ export async function createRuntimeService(
|
||||
return new RuntimeService({
|
||||
dataDir: config.dataDir,
|
||||
diagnostics,
|
||||
buildIdentity: runtimeBuildIdentity(config.buildIdentity),
|
||||
async createSupervisor(onSidecarFault) {
|
||||
const { port, overrideApplied, ignoredReason } = resolveCorePort(
|
||||
process.env,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { ChildProcess, SpawnOptions } from "node:child_process";
|
||||
import { spawn } from "node:child_process";
|
||||
import { access } from "node:fs/promises";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { HealthResponse } from "@one-memory/contracts";
|
||||
import axios from "axios";
|
||||
import { processIsAlive } from "./core-supervisor.js";
|
||||
import {
|
||||
discoverRuntimeAttachment,
|
||||
type RuntimeAttachment,
|
||||
RuntimeDiscoveryError,
|
||||
} from "./discovery.js";
|
||||
import { readSdkMigrationBlock } from "./sdk-migration-log.js";
|
||||
@@ -109,7 +110,7 @@ export async function ensureRuntimeProcess(
|
||||
|
||||
export async function waitForRuntimeProcess(
|
||||
options: WaitForRuntimeOptions,
|
||||
): Promise<void> {
|
||||
): Promise<RuntimeAttachment> {
|
||||
const attempts = options.attempts ?? 120;
|
||||
const delayMs = options.delayMs ?? 250;
|
||||
const waitStarted = Date.now();
|
||||
@@ -121,7 +122,7 @@ export async function waitForRuntimeProcess(
|
||||
probeIdentity: options.probeIdentity ?? defaultProbeIdentity,
|
||||
requireServiceReceipt: true,
|
||||
});
|
||||
if (attachment !== null) return;
|
||||
if (attachment !== null) return attachment;
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof RuntimeDiscoveryError) ||
|
||||
@@ -161,8 +162,117 @@ export async function stopRuntimeProcess(
|
||||
if (attachment.servicePid === null) {
|
||||
throw new RuntimeDiscoveryError("runtime_service_receipt_invalid");
|
||||
}
|
||||
await terminateVerifiedRuntime(options, attachment.servicePid);
|
||||
return "stopped";
|
||||
}
|
||||
|
||||
export interface StopAllRuntimesOptions extends StopRuntimeOptions {
|
||||
/** How long a Runtime caught starting or stopping may take to become
|
||||
* verifiable before the stop gives up on it. */
|
||||
settleMs?: number;
|
||||
/** Upper bound on Runtimes stopped in one call. The lease admits one at a
|
||||
* time, so more than a few means something keeps starting new ones. */
|
||||
maxRuntimes?: number;
|
||||
}
|
||||
|
||||
export interface StopAllRuntimesResult {
|
||||
/** Verified Runtime processes this call stopped. */
|
||||
stopped: number[];
|
||||
/** A live pid the Runtime lease names that could not be verified as a
|
||||
* MemHub Runtime of this data dir. Never signalled: a recycled pid belongs
|
||||
* to someone else. */
|
||||
unverified: number[];
|
||||
}
|
||||
|
||||
/** Stop every Runtime serving `dataDir`, whoever started it.
|
||||
*
|
||||
* The Runtime lease (`runtime-service.lock`) admits one Runtime per data dir,
|
||||
* so "every" is the current leaseholder plus any successor that takes the
|
||||
* lease while this runs: the App's own transient child, a `memhub start`, a
|
||||
* leftover from an earlier session. Each is found through its receipt and
|
||||
* signalled only once the authenticated identity endpoint has proved that the
|
||||
* receipt's pid is the Runtime serving this data dir. A live lease owner that
|
||||
* cannot be proved that way is reported, never signalled.
|
||||
*/
|
||||
export async function stopAllRuntimeProcesses(
|
||||
options: StopAllRuntimesOptions,
|
||||
): Promise<StopAllRuntimesResult> {
|
||||
const alive = options.processIsAlive ?? processIsAlive;
|
||||
const stopped: number[] = [];
|
||||
const settleDeadline = Date.now() + (options.settleMs ?? 2_000);
|
||||
const maxRuntimes = options.maxRuntimes ?? 3;
|
||||
while (stopped.length < maxRuntimes) {
|
||||
let attachment: RuntimeAttachment | null;
|
||||
try {
|
||||
attachment = await discoverRuntimeAttachment({
|
||||
dataDir: options.dataDir,
|
||||
processIsAlive: alive,
|
||||
probeIdentity: options.probeIdentity ?? defaultProbeIdentity,
|
||||
requireServiceReceipt: true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (settling(error) && Date.now() < settleDeadline) {
|
||||
await delay(100);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (attachment === null) break;
|
||||
if (attachment.servicePid === null) {
|
||||
throw new RuntimeDiscoveryError("runtime_service_receipt_invalid");
|
||||
}
|
||||
await terminateVerifiedRuntime(options, attachment.servicePid);
|
||||
stopped.push(attachment.servicePid);
|
||||
}
|
||||
const leasePid = await readRuntimeLeaseOwnerPid(options.dataDir);
|
||||
const unverified =
|
||||
leasePid !== null &&
|
||||
leasePid !== process.pid &&
|
||||
!stopped.includes(leasePid) &&
|
||||
alive(leasePid)
|
||||
? [leasePid]
|
||||
: [];
|
||||
return { stopped, unverified };
|
||||
}
|
||||
|
||||
/** The pid the Runtime lease names, or null without a readable lease. */
|
||||
export async function readRuntimeLeaseOwnerPid(
|
||||
dataDir: string,
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
const owner = JSON.parse(
|
||||
await readFile(
|
||||
path.join(dataDir, "runtime-service.lock", "owner.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as { pid?: unknown };
|
||||
return typeof owner.pid === "number" &&
|
||||
Number.isInteger(owner.pid) &&
|
||||
owner.pid > 1
|
||||
? owner.pid
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A Runtime between states: starting, stopping, or swapping receipts. */
|
||||
function settling(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof RuntimeDiscoveryError &&
|
||||
(error.code === "runtime_service_not_ready" ||
|
||||
error.code === "runtime_service_receipt_invalid" ||
|
||||
error.code === "runtime_identity_unavailable")
|
||||
);
|
||||
}
|
||||
|
||||
async function terminateVerifiedRuntime(
|
||||
options: StopRuntimeOptions,
|
||||
pid: number,
|
||||
): Promise<void> {
|
||||
const alive = options.processIsAlive ?? processIsAlive;
|
||||
const signal = options.signalProcess ?? process.kill;
|
||||
signal(attachment.servicePid, "SIGTERM");
|
||||
signal(pid, "SIGTERM");
|
||||
const timeoutMs = options.timeoutMs ?? 15_000;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
// The process exiting is the authoritative stop signal. The receipt files
|
||||
@@ -171,10 +281,10 @@ export async function stopRuntimeProcess(
|
||||
const receiptGraceMs = Math.min(RECEIPT_CLEANUP_GRACE_MS, timeoutMs);
|
||||
let exitedAt: number | null = null;
|
||||
while (Date.now() < deadline) {
|
||||
if (!alive(attachment.servicePid)) {
|
||||
if (!alive(pid)) {
|
||||
exitedAt ??= Date.now();
|
||||
if (await receiptsAreGone(options.dataDir)) return "stopped";
|
||||
if (Date.now() - exitedAt >= receiptGraceMs) return "stopped";
|
||||
if (await receiptsAreGone(options.dataDir)) return;
|
||||
if (Date.now() - exitedAt >= receiptGraceMs) return;
|
||||
} else {
|
||||
exitedAt = null;
|
||||
}
|
||||
|
||||
@@ -73,6 +73,23 @@ async function lockEntries(dataDir: string): Promise<string[]> {
|
||||
}
|
||||
|
||||
describe("RuntimeService", () => {
|
||||
it("publishes its build so an App can compare it before adopting", async () => {
|
||||
const dataDir = await directory("runtime-service-build-");
|
||||
const service = new RuntimeService({
|
||||
dataDir,
|
||||
createSupervisor: () => supervisor(),
|
||||
pid: 4321,
|
||||
buildIdentity: { buildNumber: 38, commit: null },
|
||||
});
|
||||
|
||||
await service.start();
|
||||
const receipt = JSON.parse(
|
||||
await readFile(path.join(dataDir, "runtime-service.json"), "utf8"),
|
||||
);
|
||||
expect(receipt).toMatchObject({ build_number: 38, git_commit: null });
|
||||
await service.stop();
|
||||
});
|
||||
|
||||
it("publishes and removes an outer Runtime receipt without secrets", async () => {
|
||||
const dataDir = await directory("runtime-service-");
|
||||
const runtime = supervisor();
|
||||
|
||||
@@ -46,6 +46,16 @@ export interface RuntimeServiceOptions {
|
||||
* owner.json; see removeUnownedLease. Injectable for tests. */
|
||||
legacyBootTime?: () => number | null;
|
||||
diagnostics?: DiagnosticSink;
|
||||
/** Build this process runs, published in the receipt so an App can tell an
|
||||
* older or newer Runtime from its own before adopting it. `null` fields mean
|
||||
* "this Runtime does not know"; a receipt without the fields at all was
|
||||
* written by a Runtime older than this contract. */
|
||||
buildIdentity?: RuntimeBuildIdentity;
|
||||
}
|
||||
|
||||
export interface RuntimeBuildIdentity {
|
||||
buildNumber: number | null;
|
||||
commit: string | null;
|
||||
}
|
||||
|
||||
interface LeaseIdentity {
|
||||
@@ -406,6 +416,12 @@ export class RuntimeService {
|
||||
pid: this.pid(),
|
||||
core_port: Number(url.port),
|
||||
...(sidecar === undefined ? {} : { sidecar }),
|
||||
...(this.options.buildIdentity === undefined
|
||||
? {}
|
||||
: {
|
||||
build_number: this.options.buildIdentity.buildNumber,
|
||||
git_commit: this.options.buildIdentity.commit,
|
||||
}),
|
||||
started_at: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
stopAllRuntimeProcesses,
|
||||
waitForRuntimeProcess,
|
||||
} from "./runtime-launcher.js";
|
||||
|
||||
async function writeRuntime(
|
||||
dataDir: string,
|
||||
runtime: {
|
||||
instance: string;
|
||||
servicePid: number;
|
||||
corePid: number;
|
||||
build?: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(dataDir, "runtime.json"),
|
||||
JSON.stringify({
|
||||
schema_version: 1,
|
||||
service: "one-memory-core",
|
||||
instance_id: runtime.instance,
|
||||
pid: runtime.corePid,
|
||||
parent_pid: runtime.servicePid,
|
||||
host: "127.0.0.1",
|
||||
port: 8765,
|
||||
started_at: "now",
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(dataDir, "runtime-token"), "token", {
|
||||
mode: 0o600,
|
||||
});
|
||||
await writeFile(
|
||||
path.join(dataDir, "runtime-service.json"),
|
||||
JSON.stringify({
|
||||
schema_version: 1,
|
||||
service: "one-memory-runtime",
|
||||
status: "running",
|
||||
instance_id: runtime.instance,
|
||||
pid: runtime.servicePid,
|
||||
core_port: 8765,
|
||||
...runtime.build,
|
||||
started_at: "now",
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
|
||||
async function writeLease(dataDir: string, pid: number) {
|
||||
await mkdir(path.join(dataDir, "runtime-service.lock"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(dataDir, "runtime-service.lock", "owner.json"),
|
||||
JSON.stringify({ pid, created_at: "now", boot_id: null }),
|
||||
);
|
||||
}
|
||||
|
||||
async function removeRuntime(dataDir: string) {
|
||||
for (const name of ["runtime.json", "runtime-service.json", "runtime-token"])
|
||||
await rm(path.join(dataDir, name), { force: true });
|
||||
await rm(path.join(dataDir, "runtime-service.lock"), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
const identityFor = (live: Map<number, string>) =>
|
||||
async function probeIdentity() {
|
||||
// The identity endpoint answers for whichever Runtime holds the receipts.
|
||||
const instance = [...live.values()].at(-1) ?? "none";
|
||||
return {
|
||||
status: "ok" as const,
|
||||
service: "one-memory-core" as const,
|
||||
version: "1.0.0",
|
||||
instance_id: instance,
|
||||
};
|
||||
};
|
||||
|
||||
describe("stopping every Runtime of a data dir", () => {
|
||||
it("stops the App's own child and an externally started successor", async () => {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-stop-all-"));
|
||||
// pid -> instance; core pids are always alive while their service is.
|
||||
const live = new Map<number, string>([[4321, "app-child"]]);
|
||||
await writeRuntime(dataDir, {
|
||||
instance: "app-child",
|
||||
servicePid: 4321,
|
||||
corePid: 1234,
|
||||
});
|
||||
await writeLease(dataDir, 4321);
|
||||
const signalProcess = vi.fn(async (pid: number) => {
|
||||
live.delete(pid);
|
||||
await removeRuntime(dataDir);
|
||||
if (pid === 4321) {
|
||||
// `memhub start` was waiting on the lease and takes over at once.
|
||||
live.set(5555, "cli-started");
|
||||
await writeRuntime(dataDir, {
|
||||
instance: "cli-started",
|
||||
servicePid: 5555,
|
||||
corePid: 5556,
|
||||
});
|
||||
await writeLease(dataDir, 5555);
|
||||
}
|
||||
});
|
||||
|
||||
const result = await stopAllRuntimeProcesses({
|
||||
dataDir,
|
||||
timeoutMs: 2_000,
|
||||
processIsAlive: (pid) =>
|
||||
live.has(pid) ||
|
||||
(pid === 1234 && live.has(4321)) ||
|
||||
(pid === 5556 && live.has(5555)),
|
||||
signalProcess: (pid) => void signalProcess(pid),
|
||||
probeIdentity: identityFor(live),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ stopped: [4321, 5555], unverified: [] });
|
||||
expect(signalProcess.mock.calls.map(([pid]) => pid)).toEqual([4321, 5555]);
|
||||
});
|
||||
|
||||
it("never signals a live lease owner it cannot verify as a MemHub Runtime", async () => {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-stop-all-"));
|
||||
// A lease left by a crashed Runtime whose pid the OS gave to another
|
||||
// program: no receipt, no identity endpoint, just a live number.
|
||||
await writeLease(dataDir, 7777);
|
||||
const signalProcess = vi.fn();
|
||||
|
||||
const result = await stopAllRuntimeProcesses({
|
||||
dataDir,
|
||||
processIsAlive: (pid) => pid === 7777,
|
||||
signalProcess,
|
||||
probeIdentity: async () => {
|
||||
throw new Error("no endpoint");
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ stopped: [], unverified: [7777] });
|
||||
expect(signalProcess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to signal a receipt pid whose identity does not match", async () => {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-stop-all-"));
|
||||
await writeRuntime(dataDir, {
|
||||
instance: "runtime",
|
||||
servicePid: 4321,
|
||||
corePid: 1234,
|
||||
});
|
||||
const signalProcess = vi.fn();
|
||||
|
||||
await expect(
|
||||
stopAllRuntimeProcesses({
|
||||
dataDir,
|
||||
processIsAlive: () => true,
|
||||
signalProcess,
|
||||
probeIdentity: async () => ({
|
||||
status: "ok",
|
||||
service: "one-memory-core",
|
||||
version: "1.0.0",
|
||||
instance_id: "someone-else",
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow("runtime_identity_mismatch");
|
||||
expect(signalProcess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports nothing to stop for an empty data dir", async () => {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-stop-all-"));
|
||||
await expect(
|
||||
stopAllRuntimeProcesses({
|
||||
dataDir,
|
||||
processIsAlive: () => false,
|
||||
signalProcess: vi.fn(),
|
||||
}),
|
||||
).resolves.toEqual({ stopped: [], unverified: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the build a running Runtime publishes", () => {
|
||||
const probeIdentity = async () => ({
|
||||
status: "ok" as const,
|
||||
service: "one-memory-core" as const,
|
||||
version: "1.0.0",
|
||||
instance_id: "runtime",
|
||||
});
|
||||
|
||||
it("is read from its receipt", async () => {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-build-"));
|
||||
await writeRuntime(dataDir, {
|
||||
instance: "runtime",
|
||||
servicePid: 4321,
|
||||
corePid: 1234,
|
||||
build: { build_number: 38, git_commit: "abcdef1" },
|
||||
});
|
||||
|
||||
const attachment = await waitForRuntimeProcess({
|
||||
dataDir,
|
||||
attempts: 1,
|
||||
processIsAlive: () => true,
|
||||
probeIdentity,
|
||||
});
|
||||
|
||||
expect(attachment.servicePid).toBe(4321);
|
||||
expect(attachment.runtimeBuild).toEqual({
|
||||
buildNumber: 38,
|
||||
commit: "abcdef1",
|
||||
});
|
||||
});
|
||||
|
||||
it("is absent for a receipt from a Runtime older than the field", async () => {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-build-"));
|
||||
await writeRuntime(dataDir, {
|
||||
instance: "runtime",
|
||||
servicePid: 4321,
|
||||
corePid: 1234,
|
||||
});
|
||||
|
||||
const attachment = await waitForRuntimeProcess({
|
||||
dataDir,
|
||||
attempts: 1,
|
||||
processIsAlive: () => true,
|
||||
probeIdentity,
|
||||
});
|
||||
|
||||
expect(attachment.runtimeBuild).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads a malformed build as unknown, not as a broken receipt", async () => {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-build-"));
|
||||
await writeRuntime(dataDir, {
|
||||
instance: "runtime",
|
||||
servicePid: 4321,
|
||||
corePid: 1234,
|
||||
build: { build_number: "38", git_commit: "not a commit" },
|
||||
});
|
||||
|
||||
const attachment = await waitForRuntimeProcess({
|
||||
dataDir,
|
||||
attempts: 1,
|
||||
processIsAlive: () => true,
|
||||
probeIdentity,
|
||||
});
|
||||
|
||||
expect(attachment.runtimeBuild).toEqual({
|
||||
buildNumber: null,
|
||||
commit: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
parseLaunchctlPid,
|
||||
queryRuntimeServiceState,
|
||||
type ServiceStateProbe,
|
||||
} from "./service-state.js";
|
||||
|
||||
const LABEL = "com.tencent.onememory.runtime";
|
||||
|
||||
async function home() {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "service-state-"));
|
||||
const dataDir = path.join(root, ".one-memory");
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
return { root, dataDir };
|
||||
}
|
||||
|
||||
async function writeLaunchAgent(root: string, dataDir: string, label = LABEL) {
|
||||
const directory = path.join(root, "Library", "LaunchAgents");
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(directory, `${label}.plist`),
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plist version="1.0"><dict>
|
||||
<key>Label</key><string>${label}</string>
|
||||
<key>EnvironmentVariables</key><dict><key>ONE_MEMORY_DATA_DIR</key><string>${dataDir.replaceAll("&", "&")}</string></dict>
|
||||
</dict></plist>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
function launchctl(options: {
|
||||
disabled?: boolean;
|
||||
job?: { code: number; pid?: number };
|
||||
overridesCode?: number;
|
||||
}): ServiceStateProbe {
|
||||
return vi.fn(async (command: readonly string[]) => {
|
||||
if (command[1] === "print-disabled") {
|
||||
return {
|
||||
code: options.overridesCode ?? 0,
|
||||
stdout: `disabled services = {\n\t"${LABEL}" => ${options.disabled ? "disabled" : "enabled"}\n}\n`,
|
||||
};
|
||||
}
|
||||
const job = options.job ?? { code: 0, pid: 4321 };
|
||||
return {
|
||||
code: job.code,
|
||||
stdout:
|
||||
job.code === 0
|
||||
? `${LABEL} = {\n\tpath = /x.plist\n${job.pid ? `\tpid = ${job.pid}\n` : ""}\tendpoints = {\n\t\tpid = 1\n\t}\n}\n`
|
||||
: "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
describe("asking launchd whether the background service is on", () => {
|
||||
it("is enabled for an installed, loaded job with its enable marker", async () => {
|
||||
const { root, dataDir } = await home();
|
||||
await writeLaunchAgent(root, dataDir);
|
||||
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "darwin",
|
||||
uid: 501,
|
||||
probe: launchctl({}),
|
||||
markerEnabled: async () => true,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
state: "enabled",
|
||||
reason: "service-enabled",
|
||||
jobPid: 4321,
|
||||
});
|
||||
});
|
||||
|
||||
it("is disabled when no LaunchAgent serves this data dir", async () => {
|
||||
const { root, dataDir } = await home();
|
||||
const probe = launchctl({});
|
||||
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "darwin",
|
||||
uid: 501,
|
||||
probe,
|
||||
markerEnabled: async () => true,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
state: "disabled",
|
||||
reason: "service-not-installed",
|
||||
});
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a LaunchAgent that serves another data dir", async () => {
|
||||
const { root, dataDir } = await home();
|
||||
await writeLaunchAgent(root, path.join(root, "other-data"));
|
||||
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "darwin",
|
||||
uid: 501,
|
||||
probe: launchctl({}),
|
||||
markerEnabled: async () => true,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
state: "disabled",
|
||||
reason: "service-not-installed",
|
||||
});
|
||||
});
|
||||
|
||||
it("finds an isolated install root's scoped label", async () => {
|
||||
const { root, dataDir } = await home();
|
||||
await writeLaunchAgent(root, dataDir, `${LABEL}.0123456789abcdef`);
|
||||
const probe: ServiceStateProbe = async (command) =>
|
||||
command[1] === "print-disabled"
|
||||
? { code: 0, stdout: "" }
|
||||
: command[2] === `gui/501/${LABEL}.0123456789abcdef`
|
||||
? { code: 0, stdout: "\tpid = 999\n" }
|
||||
: { code: 113, stdout: "" };
|
||||
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "darwin",
|
||||
uid: 501,
|
||||
probe,
|
||||
markerEnabled: async () => true,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
state: "enabled",
|
||||
reason: "service-enabled",
|
||||
jobPid: 999,
|
||||
});
|
||||
});
|
||||
|
||||
it("is disabled under a launchctl disable override (the transient fallback)", async () => {
|
||||
const { root, dataDir } = await home();
|
||||
await writeLaunchAgent(root, dataDir);
|
||||
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "darwin",
|
||||
uid: 501,
|
||||
probe: launchctl({ disabled: true, job: { code: 113 } }),
|
||||
markerEnabled: async () => true,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
state: "disabled",
|
||||
reason: "service-disabled-by-launchctl",
|
||||
jobPid: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("is disabled when Launch at login is off, even with the job still loaded", async () => {
|
||||
const { root, dataDir } = await home();
|
||||
await writeLaunchAgent(root, dataDir);
|
||||
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "darwin",
|
||||
uid: 501,
|
||||
probe: launchctl({}),
|
||||
markerEnabled: async () => false,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
state: "disabled",
|
||||
reason: "service-enable-marker-absent",
|
||||
jobPid: 4321,
|
||||
});
|
||||
});
|
||||
|
||||
it("stays enabled for an installed job launchd has not loaded yet", async () => {
|
||||
const { root, dataDir } = await home();
|
||||
await writeLaunchAgent(root, dataDir);
|
||||
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "darwin",
|
||||
uid: 501,
|
||||
probe: launchctl({ job: { code: 113 } }),
|
||||
markerEnabled: async () => true,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
state: "enabled",
|
||||
reason: "service-enabled",
|
||||
jobPid: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the override table cannot be read", launchctl({ overridesCode: 1 })],
|
||||
["the job query fails", launchctl({ job: { code: 5 } })],
|
||||
[
|
||||
"launchctl cannot run",
|
||||
(async () => {
|
||||
throw new Error("spawn failed");
|
||||
}) as ServiceStateProbe,
|
||||
],
|
||||
])("is unknown when %s", async (_case, probe) => {
|
||||
const { root, dataDir } = await home();
|
||||
await writeLaunchAgent(root, dataDir);
|
||||
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "darwin",
|
||||
uid: 501,
|
||||
probe,
|
||||
markerEnabled: async () => true,
|
||||
}),
|
||||
).resolves.toMatchObject({ state: "unknown" });
|
||||
});
|
||||
|
||||
it("is unknown when the query outlives its budget", async () => {
|
||||
const { root, dataDir } = await home();
|
||||
await writeLaunchAgent(root, dataDir);
|
||||
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "darwin",
|
||||
uid: 501,
|
||||
timeoutMs: 20,
|
||||
probe: () => new Promise(() => {}),
|
||||
markerEnabled: async () => true,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
state: "unknown",
|
||||
reason: "service-state-timeout",
|
||||
jobPid: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("is unknown without a user id to name the launchd domain", async () => {
|
||||
const { root, dataDir } = await home();
|
||||
await writeLaunchAgent(root, dataDir);
|
||||
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "darwin",
|
||||
probe: launchctl({}),
|
||||
markerEnabled: async () => true,
|
||||
}),
|
||||
).resolves.toMatchObject({ state: "unknown" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("asking systemd whether the background service is on", () => {
|
||||
async function writeUnit(root: string, dataDir: string) {
|
||||
const directory = path.join(root, ".config", "systemd", "user");
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(directory, "one-memory.service"),
|
||||
`[Service]\nEnvironment="ONE_MEMORY_DATA_DIR=${dataDir}"\n`,
|
||||
);
|
||||
}
|
||||
const systemctl =
|
||||
(answer: string, code = 0): ServiceStateProbe =>
|
||||
async (command) =>
|
||||
command.includes("is-enabled")
|
||||
? { code, stdout: `${answer}\n` }
|
||||
: { code: 0, stdout: "2468\n" };
|
||||
|
||||
it("follows systemctl is-enabled", async () => {
|
||||
const { root, dataDir } = await home();
|
||||
await writeUnit(root, dataDir);
|
||||
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "linux",
|
||||
probe: systemctl("enabled"),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
state: "enabled",
|
||||
reason: "service-enabled",
|
||||
jobPid: 2468,
|
||||
});
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "linux",
|
||||
probe: systemctl("disabled", 1),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
state: "disabled",
|
||||
reason: "service-disabled-by-systemd",
|
||||
});
|
||||
});
|
||||
|
||||
it("is disabled without a unit and unknown without an answer", async () => {
|
||||
const { root, dataDir } = await home();
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "linux",
|
||||
probe: systemctl("enabled"),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
state: "disabled",
|
||||
reason: "service-not-installed",
|
||||
});
|
||||
await writeUnit(root, dataDir);
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "linux",
|
||||
probe: systemctl("", 1),
|
||||
}),
|
||||
).resolves.toMatchObject({ state: "unknown" });
|
||||
});
|
||||
});
|
||||
|
||||
it("reads only the job's own top-level pid", () => {
|
||||
expect(parseLaunchctlPid("x = {\n\tpid = 77\n\tendpoints = {\n}\n")).toBe(77);
|
||||
expect(parseLaunchctlPid("x = {\n\tstate = waiting\n}\n")).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { readdir, readFile, realpath } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { parseDisabledOverride } from "./service-install.js";
|
||||
import { runtimeServiceEnabled } from "./service-ownership.js";
|
||||
import { RUNTIME_SERVICE_ID } from "./service-plan.js";
|
||||
|
||||
/**
|
||||
* Whether the background service would keep a Runtime for this data dir alive
|
||||
* without the App.
|
||||
*
|
||||
* `enabled`: a LaunchAgent (or systemd user unit) declaring this data dir is
|
||||
* installed, the OS has no disable override for it, and on macOS the enable
|
||||
* marker its `KeepAlive` waits on is present. The marker is the App's "Launch
|
||||
* at login" switch and `memhub service disable`: without it launchd neither
|
||||
* starts nor restarts the job, so the service is off even while the plist is
|
||||
* still installed.
|
||||
*
|
||||
* `disabled`: none of that holds. The service was never installed or was
|
||||
* uninstalled, `launchctl disable` / `systemctl disable` turned it off (the
|
||||
* case in which the App falls back to a transient Runtime), or the enable
|
||||
* marker is gone.
|
||||
*
|
||||
* `unknown`: a query failed or timed out. Callers must not act on it.
|
||||
*/
|
||||
export type RuntimeServiceStateName = "enabled" | "disabled" | "unknown";
|
||||
|
||||
export type RuntimeServiceStateReason =
|
||||
| "service-enabled"
|
||||
| "service-not-installed"
|
||||
| "service-enable-marker-absent"
|
||||
| "service-disabled-by-launchctl"
|
||||
| "service-disabled-by-systemd"
|
||||
| "service-unsupported-platform"
|
||||
| "service-state-query-failed"
|
||||
| "service-state-timeout";
|
||||
|
||||
export interface RuntimeServiceState {
|
||||
state: RuntimeServiceStateName;
|
||||
reason: RuntimeServiceStateReason;
|
||||
/** The pid the service manager reports for the running job: null when the
|
||||
* job is not loaded, not running, or the state is unknown. */
|
||||
jobPid: number | null;
|
||||
}
|
||||
|
||||
export type ServiceStateProbe = (
|
||||
command: readonly string[],
|
||||
timeoutMs: number,
|
||||
) => Promise<{ code: number | null; stdout: string }>;
|
||||
|
||||
export interface RuntimeServiceStateOptions {
|
||||
dataDir: string;
|
||||
home: string;
|
||||
platform?: NodeJS.Platform;
|
||||
uid?: number;
|
||||
xdgConfigHome?: string;
|
||||
/** Budget for the whole query; past it the state is `unknown`. */
|
||||
timeoutMs?: number;
|
||||
probe?: ServiceStateProbe;
|
||||
markerEnabled?: (dataDir: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const LAUNCH_AGENT_FILE = new RegExp(
|
||||
`^${RUNTIME_SERVICE_ID.replaceAll(".", "\\.")}(?:\\.[0-9a-f]{16})?\\.plist$`,
|
||||
);
|
||||
|
||||
/** Ask the service manager, now, whether the background service is on.
|
||||
*
|
||||
* Never throws and never caches: every answer is a fresh `launchctl` /
|
||||
* `systemctl` read, and any failure or timeout is `unknown`.
|
||||
*/
|
||||
export async function queryRuntimeServiceState(
|
||||
options: RuntimeServiceStateOptions,
|
||||
): Promise<RuntimeServiceState> {
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<RuntimeServiceState>((resolve) => {
|
||||
timer = setTimeout(
|
||||
() => resolve(state("unknown", "service-state-timeout")),
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([
|
||||
inspect(options, timeoutMs).catch(() =>
|
||||
state("unknown", "service-state-query-failed"),
|
||||
),
|
||||
timeout,
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function inspect(
|
||||
options: RuntimeServiceStateOptions,
|
||||
timeoutMs: number,
|
||||
): Promise<RuntimeServiceState> {
|
||||
const platform = options.platform ?? process.platform;
|
||||
const probe = options.probe ?? runProbe;
|
||||
if (platform === "darwin")
|
||||
return inspectLaunchAgent(options, probe, timeoutMs);
|
||||
if (platform === "linux")
|
||||
return inspectSystemdUnit(options, probe, timeoutMs);
|
||||
return state("disabled", "service-unsupported-platform");
|
||||
}
|
||||
|
||||
async function inspectLaunchAgent(
|
||||
options: RuntimeServiceStateOptions,
|
||||
probe: ServiceStateProbe,
|
||||
timeoutMs: number,
|
||||
): Promise<RuntimeServiceState> {
|
||||
const uid = options.uid;
|
||||
if (uid === undefined || !Number.isSafeInteger(uid) || uid < 1) {
|
||||
return state("unknown", "service-state-query-failed");
|
||||
}
|
||||
const labels = await launchAgentsServing(options.home, options.dataDir);
|
||||
if (labels.length === 0) return state("disabled", "service-not-installed");
|
||||
const domain = `gui/${uid}`;
|
||||
const overrides = await probe(
|
||||
["launchctl", "print-disabled", domain],
|
||||
timeoutMs,
|
||||
);
|
||||
if (overrides.code !== 0) {
|
||||
return state("unknown", "service-state-query-failed");
|
||||
}
|
||||
const marker = await (options.markerEnabled ?? runtimeServiceEnabled)(
|
||||
options.dataDir,
|
||||
);
|
||||
let first: RuntimeServiceState | null = null;
|
||||
for (const label of labels) {
|
||||
const job = await probe(
|
||||
["launchctl", "print", `${domain}/${label}`],
|
||||
timeoutMs,
|
||||
);
|
||||
// 113: the label is not loaded. Anything else unexpected is unknown.
|
||||
if (job.code !== 0 && job.code !== 113) {
|
||||
return state("unknown", "service-state-query-failed");
|
||||
}
|
||||
const jobPid = job.code === 0 ? parseLaunchctlPid(job.stdout) : null;
|
||||
const current = parseDisabledOverride(overrides.stdout, label)
|
||||
? state("disabled", "service-disabled-by-launchctl", jobPid)
|
||||
: marker
|
||||
? state("enabled", "service-enabled", jobPid)
|
||||
: state("disabled", "service-enable-marker-absent", jobPid);
|
||||
if (current.state === "enabled") return current;
|
||||
first ??= current;
|
||||
}
|
||||
return first ?? state("disabled", "service-not-installed");
|
||||
}
|
||||
|
||||
/** Labels of the MemHub LaunchAgents whose declaration serves `dataDir`.
|
||||
* The daily install and isolated install roots use different labels, and a
|
||||
* LaunchAgent for another data dir is not this data dir's service. */
|
||||
async function launchAgentsServing(
|
||||
home: string,
|
||||
dataDir: string,
|
||||
): Promise<string[]> {
|
||||
const directory = path.join(home, "Library", "LaunchAgents");
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(directory);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
||||
throw error;
|
||||
}
|
||||
const wanted = await canonical(dataDir);
|
||||
const labels: string[] = [];
|
||||
for (const name of names
|
||||
.filter((item) => LAUNCH_AGENT_FILE.test(item))
|
||||
.sort()) {
|
||||
const label = name.slice(0, -".plist".length);
|
||||
const content = await readFile(path.join(directory, name), "utf8");
|
||||
if (!content.includes(`<key>Label</key><string>${label}</string>`))
|
||||
continue;
|
||||
const declared =
|
||||
/<key>ONE_MEMORY_DATA_DIR<\/key><string>([^<]*)<\/string>/.exec(
|
||||
content,
|
||||
)?.[1];
|
||||
if (declared === undefined) continue;
|
||||
if ((await canonical(unescapeXml(declared))) === wanted) labels.push(label);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
async function inspectSystemdUnit(
|
||||
options: RuntimeServiceStateOptions,
|
||||
probe: ServiceStateProbe,
|
||||
timeoutMs: number,
|
||||
): Promise<RuntimeServiceState> {
|
||||
const config = path.resolve(
|
||||
options.xdgConfigHome?.trim() || path.join(options.home, ".config"),
|
||||
);
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(
|
||||
path.join(config, "systemd", "user", "one-memory.service"),
|
||||
"utf8",
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return state("disabled", "service-not-installed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const declared =
|
||||
/^Environment="ONE_MEMORY_DATA_DIR=((?:[^"\\]|\\.)*)"$/m.exec(content)?.[1];
|
||||
if (
|
||||
declared === undefined ||
|
||||
(await canonical(declared.replace(/\\(.)/g, "$1"))) !==
|
||||
(await canonical(options.dataDir))
|
||||
) {
|
||||
return state("disabled", "service-not-installed");
|
||||
}
|
||||
const enabled = await probe(
|
||||
["systemctl", "--user", "is-enabled", "one-memory.service"],
|
||||
timeoutMs,
|
||||
);
|
||||
const answer = enabled.stdout.trim().split(/\r?\n/)[0] ?? "";
|
||||
if (enabled.code === null || answer === "") {
|
||||
return state("unknown", "service-state-query-failed");
|
||||
}
|
||||
const main = await probe(
|
||||
[
|
||||
"systemctl",
|
||||
"--user",
|
||||
"show",
|
||||
"one-memory.service",
|
||||
"--property=MainPID",
|
||||
"--value",
|
||||
],
|
||||
timeoutMs,
|
||||
);
|
||||
const pid = Number(main.stdout.trim());
|
||||
const jobPid =
|
||||
main.code === 0 && Number.isSafeInteger(pid) && pid > 1 ? pid : null;
|
||||
if (answer === "enabled" || answer === "enabled-runtime") {
|
||||
return state("enabled", "service-enabled", jobPid);
|
||||
}
|
||||
return state("disabled", "service-disabled-by-systemd", jobPid);
|
||||
}
|
||||
|
||||
/** The top-level `pid = N` launchctl prints for a running job. Top-level
|
||||
* properties are indented by exactly one tab; nested blocks (endpoints,
|
||||
* spawn info) carry their own `pid` lines and must not be read as the job's. */
|
||||
export function parseLaunchctlPid(stdout: string): number | null {
|
||||
for (const line of stdout.split(/\r?\n/)) {
|
||||
const match = /^\tpid = (\d+)\s*$/.exec(line);
|
||||
if (!match) continue;
|
||||
const pid = Number(match[1]);
|
||||
return Number.isSafeInteger(pid) && pid > 1 ? pid : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function state(
|
||||
name: RuntimeServiceStateName,
|
||||
reason: RuntimeServiceStateReason,
|
||||
jobPid: number | null = null,
|
||||
): RuntimeServiceState {
|
||||
return { state: name, reason, jobPid };
|
||||
}
|
||||
|
||||
async function canonical(target: string): Promise<string> {
|
||||
const resolved = path.resolve(target);
|
||||
return realpath(resolved).catch(() => resolved);
|
||||
}
|
||||
|
||||
function unescapeXml(value: string): string {
|
||||
return value
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll(""", '"')
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("&", "&");
|
||||
}
|
||||
|
||||
async function runProbe(
|
||||
command: readonly string[],
|
||||
timeoutMs: number,
|
||||
): Promise<{ code: number | null; stdout: string }> {
|
||||
const [program, ...args] = command;
|
||||
if (!program) throw new Error("runtime_service_inspection_failed");
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(program, args, {
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error("runtime_service_inspection_timeout"));
|
||||
}, timeoutMs);
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > 256 * 1024) {
|
||||
child.kill();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
child.once("error", () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("runtime_service_inspection_failed"));
|
||||
});
|
||||
child.once("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (size > 256 * 1024) {
|
||||
reject(new Error("runtime_service_inspection_failed"));
|
||||
return;
|
||||
}
|
||||
resolve({ code, stdout: Buffer.concat(chunks).toString("utf8") });
|
||||
});
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hy-memory"
|
||||
version = "2.0.11"
|
||||
version = "2.0.12"
|
||||
description = "HY Memory - Industrial-grade dual-system cognitive memory framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
Reference in new issue
Block a user