Compare commits

...
Author SHA1 Message Date
alvinfeiandClaude Opus 5.5 675cc30e5a release: hy-memory 2.0.12
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 14:30:15 +08:00
feitianxiang 3511153f9f Merge pull request #274 from Tencent-Hunyuan/fix/deepseek-harness-profiles
fix(memhub): DeepSeek Harness one switch grants every profile; import versioned sessions and v3 tool text
2026-11-02 14:17:44 +08:00
feitianxiang 23facf75d5 Merge pull request #273 from Tencent-Hunyuan/fix/single-runtime-quit
fix(memhub): one runtime; App quit never stops the background service, stops every runtime when it is disabled
2026-11-02 14:17:32 +08:00
alvinfeiandClaude Opus 5.5 384c7476ad fix(memhub): adopt a newer runtime silently and let quit follow the service state
The owner's decision for a runtime with a higher build than the App: startup
neither fails nor shows anything. The App adopts the runtime and never stops,
downgrades or duplicates it at startup. Quit gives it no special pass, so it
follows the same rules as any adopted runtime: preserved when the service is
enabled or its state is unknown, stopped when the service is disabled. The
now-unused "后台版本比 App 新" message is removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 14:07:57 +08:00
alvinfeiandClaude Opus 5.5 059125a09f fix(memhub): one runtime; App quit never stops the background service, stops every runtime when it is disabled
Quit no longer trusts what startup saw. `transientRuntime` and
`preserveManagedRuntimeOnQuit` were derived from the launch path, so an
already-running transient Runtime was treated as a managed service and
outlived the App (#237, #238, #239).

- queryRuntimeServiceState asks launchd/systemd at quit time, every time.
  Enabled: a LaunchAgent declaring this data dir is installed, there is no
  `launchctl disable` override, and the enable marker ("Launch at login")
  is present. A failed or timed-out query is `unknown`.
- Quit: enabled -> never stop. Disabled -> stopAllRuntimeProcesses stops
  every Runtime of the data dir (App child, CLI-started, leftover), each
  pid verified via the receipt and the authenticated identity endpoint; an
  unverifiable lease owner is reported, never signalled. Unknown -> stop
  nothing and log it.
- Startup reconciles a running Runtime by build: the Runtime now publishes
  build_number/git_commit in runtime-service.json. Same build is adopted;
  an older service job is replaced through launchd (installUserService
  kickstart/re-bootstrap), an older transient/CLI one is stopped gracefully
  and restarted; a newer one is never downgraded or stopped; an unknown
  build is adopted and never stopped. A stray beside an enabled service is
  stopped so exactly one Runtime remains.
- The tray and the login preference re-read the real state on each refresh.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 12:57:21 +08:00
32 changed files with 2037 additions and 86 deletions

No files matched your search

+3 -1
View File
@@ -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),
+16 -1
View File
@@ -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" });
});
+15 -1
View File
@@ -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
View File
@@ -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();
});
});
+158
View File
@@ -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",
};
}
}
+174 -32
View File
@@ -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");
},
+68 -24
View File
@@ -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("&", "&amp;")}</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("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&quot;", '"')
.replaceAll("&apos;", "'")
.replaceAll("&amp;", "&");
}
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
View File
@@ -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"