Compare commits

..
Author SHA1 Message Date
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
44 changed files with 2120 additions and 238 deletions

No files matched your search

@@ -30,7 +30,8 @@ vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
}));
function languageState(uiLanguage: string | null, extra = {}) {
const sdk = uiLanguage === null ? "auto" : uiLanguage;
const sdk =
uiLanguage === null || uiLanguage === "zh-Hans" ? "auto" : uiLanguage;
return {
ui_language: uiLanguage,
sdk_language: sdk,
@@ -94,7 +95,7 @@ describe("memhub settings language", () => {
});
expect(transport.put).not.toHaveBeenCalled();
const text = (await capturedRun(["settings", "language"])).stdout;
expect(text).toContain("界面语言:未设置(SDK 自动识别)");
expect(text).toContain("界面语言:跟随系统(未选择)");
expect(text).toContain("SDK 提示语言:auto");
});
+6 -7
View File
@@ -2,11 +2,10 @@
* `memhub settings language [<code>|system]`.
*
* The display language the App reports to the Runtime (`ui_language`), and
* the SDK prompt language it implies. Every code, zh-Hans and en included,
* makes extraction and the other SDK prompts use that language; "system"
* (no language set) passes none, so the SDK keeps detecting Chinese or
* English as before. The App sends the language it shows, so it is only
* unset on an install without the App. A change applies at once: a write in flight
* the SDK prompt language it implies. zh-Hans and "system" (no explicit
* choice) pass no language, so the SDK keeps detecting Chinese or English as
* before; every other code, en included, makes extraction and the other SDK
* prompts use that language. A change applies at once: a write in flight
* finishes in the old language, the next ones use the new one. The App sends
* its own choice again whenever it changes there.
*/
@@ -163,14 +162,14 @@ export function renderLanguageText(document: LanguageDocument): string {
const lines = [
`界面语言:${
state.ui_language === null
? "未设置(SDK 自动识别)"
? "跟随系统(未选择)"
: `${state.ui_language}(${NAMES[state.ui_language]})`
}`,
`SDK 提示语言:${sdkText(state.sdk_language)}`,
];
if (document.state === "ok") {
lines.push(
`可选:${UI_LANGUAGES.join("、")}、system。任一语言(包括 zh-Hans、en)都会让 SDK 使用该语言;system 不指定语言,SDK 自动识别中文或英文。`,
`可选:${UI_LANGUAGES.join("、")}、system。简体中文和 system 不指定语言,其余语言(包括 en)会让 SDK 使用该语言。`,
);
}
if (document.state === "unchanged") lines.push("没有需要改动的设置。");
+6 -6
View File
@@ -766,7 +766,7 @@ class PipelineStatus(BaseModel):
#: default; empty while every feature is at its default.
features: dict[MemoryFeatureName, bool] = Field(default_factory=dict)
#: The display language the App last reported (`ui_language`); null =
#: none set (the App sends the language it shows, even following the system).
#: never chosen or "follow the system".
ui_language: UiLanguage | None = None
#: The SDK prompt language: `auto` (the SDK detects zh/en) or a code.
sdk_language: str = SDK_LANGUAGE_AUTO
@@ -777,9 +777,9 @@ class UiLanguageState(BaseModel):
model_config = ConfigDict(extra="forbid")
#: What the App reported; null = none set (CLI/API-only, or `system`).
#: What the App reported; null = never chosen or "follow the system".
ui_language: UiLanguage | None
#: What SDK clients are built with: `auto` (no language set: the SDK
#: What SDK clients are built with: `auto` (zh-Hans or no choice: the SDK
#: detects zh/en, as before) or the code passed as `MemoryConfig.language`.
sdk_language: str
languages: tuple[UiLanguage, ...] = UI_LANGUAGES
@@ -4616,9 +4616,9 @@ def create_app(
async def put_ui_language(request: UiLanguageUpdate) -> UiLanguageChange:
"""Record the App's display language; one global setting, not per user.
Every code (zh-Hans and en included) is passed as
`MemoryConfig.language`; only null passes no language, so the SDK
keeps detecting zh/en as before. A change of that SDK language rebuilds the
zh-Hans and null pass no language to the SDK, which keeps detecting
zh/en as before; every other code (en included) is passed as
`MemoryConfig.language`. A change of that SDK language rebuilds the
SDK clients like a feature change: a write in flight finishes in the
old language. Nothing stored is migrated and no collection is renamed.
"""
@@ -367,8 +367,8 @@ def language_kwargs(language: str | None) -> LanguageKwargs:
def apply_sdk_language(config: MemoryConfig, language: str | None) -> MemoryConfig:
"""Set the SDK prompt language (`MemoryConfig.language`), when there is one.
None (no language set) sets nothing: the SDK detects zh/en as it always
did. Every display language, zh-Hans included, is set explicitly. Like a feature, the language never touches `vector_store`, so
None (zh-Hans, or no choice) sets nothing: the SDK detects zh/en as it
always did. Like a feature, the language never touches `vector_store`, so
the collection identity never depends on it.
"""
if language is not None:
@@ -74,9 +74,9 @@ class ConfigDocument(BaseModel):
#: SDK retrieval and extraction features a person set; an unset one keeps
#: the SDK default. See `one_memory.memory_features`.
memory_features: MemoryFeatures = Field(default_factory=MemoryFeatures)
#: The display language the App shows (its choice, or the system language
#: it follows); None = none set. See `one_memory.ui_language` for what it
#: does to the SDK.
#: The display language a person chose in the App; None = never chosen or
#: "follow the system". See `one_memory.ui_language` for what it does to
#: the SDK.
ui_language: UiLanguage | None = None
@field_validator("sources")
+12 -16
View File
@@ -3,19 +3,14 @@
The App owns the choice (its language selector) and tells Core whenever it
changes, so Core can build every SDK client with it. It is one global value in
`config.json` (`ui_language`), beside `memory_mode` and `memory_features`;
absent means no language was set (a CLI/API-only install, or
`settings language system`). An App that follows the system sends the
language it resolved and shows, not null.
absent (never chosen, or "follow the system") means no explicit choice.
The owner's rule for the SDK:
- Any supported code, zh-Hans and en included: pass it as
`MemoryConfig.language`, so extraction and the other prompts use that
language. Every display language is passed explicitly (#243).
- No language at all (never set, as on a CLI/API-only install): pass no
language. The SDK keeps detecting Chinese or English from the text, exactly
as before this setting existed. The App does not leave it unset: it sends
the language it shows, including when it follows the system.
- zh-Hans, or no explicit choice: pass no language. The SDK keeps detecting
Chinese or English from the text, exactly as before this setting existed.
- Any other code, including en: pass it as `MemoryConfig.language`, so
extraction and the other prompts use that language.
The language never names a collection; a change rebuilds the SDK handles the
way a mode or feature change does (#218 use count).
@@ -56,6 +51,8 @@ UI_LANGUAGES: tuple[UiLanguage, ...] = (
)
#: Codes earlier App builds stored, and the code each one is now.
LEGACY_UI_LANGUAGES: dict[str, UiLanguage] = {"zh-CN": "zh-Hans"}
#: Display languages that leave the SDK's own zh/en detection in charge.
AUTO_DETECTED: frozenset[str] = frozenset({"zh-Hans"})
#: What status shows when no language is passed to the SDK.
SDK_LANGUAGE_AUTO = "auto"
@@ -69,9 +66,8 @@ def normalize_ui_language(value: object) -> UiLanguage | None:
def sdk_language(ui_language: str | None) -> str | None:
"""The `MemoryConfig.language` for a display language; None = SDK auto-detect.
Every supported code is passed as is; only an unset (or unknown) value
leaves the SDK to detect zh/en.
"""
return normalize_ui_language(ui_language)
"""The `MemoryConfig.language` for a display language; None = SDK auto-detect."""
language = normalize_ui_language(ui_language)
if language is None or language in AUTO_DETECTED:
return None
return language
+17 -32
View File
@@ -1,8 +1,8 @@
"""The App's display language, and the SDK prompt language it implies.
The owner's rule (#243): every display language, zh-Hans and en included, is
passed as `MemoryConfig.language` (hy-memory 2.0.11); only an unset language
passes none, so the SDK keeps detecting zh/en exactly as before. A change rebuilds the
The owner's rule: zh-Hans, or no explicit choice, passes no language, so the
SDK keeps detecting zh/en exactly as before; every other code (en included)
is passed as `MemoryConfig.language` (hy-memory 2.0.11). A change rebuilds the
handles the way a mode or feature change does, and the language never touches
the vector collection identity.
"""
@@ -28,8 +28,8 @@ from one_memory.secrets import InMemorySecretStore
from one_memory.ui_language import UI_LANGUAGES, normalize_ui_language, sdk_language
TOKEN = "ui-language-test"
#: Every code is passed to the SDK, zh-Hans included.
PASSED = ("en", "zh-Hans", "zh-Hant", "ja", "es", "de", "pt", "fr", "ko", "ru", "id")
#: Every code except zh-Hans is passed to the SDK.
PASSED = ("en", "zh-Hant", "ja", "es", "de", "pt", "fr", "ko", "ru", "id")
@pytest.fixture(autouse=True)
@@ -51,7 +51,7 @@ def _config(tmp_path: Path, language: str | None):
def test_eleven_codes_and_the_legacy_chinese_code() -> None:
assert PASSED == UI_LANGUAGES
assert ("en", "zh-Hans", *PASSED[1:]) == UI_LANGUAGES
assert len(UI_LANGUAGES) == 11
# Earlier App builds stored zh-CN; it is zh-Hans now.
assert normalize_ui_language("zh-CN") == "zh-Hans"
@@ -60,20 +60,19 @@ def test_eleven_codes_and_the_legacy_chinese_code() -> None:
assert normalize_ui_language(None) is None
def test_only_an_unset_language_leaves_the_sdk_to_detect() -> None:
def test_only_zh_hans_and_no_choice_leave_the_sdk_to_detect() -> None:
assert sdk_language(None) is None
assert sdk_language("zh-Hans") is None
assert sdk_language("zh-CN") is None
assert sdk_language("system") is None
assert sdk_language("xx") is None
# zh-Hans is passed like every other code; the legacy zh-CN maps to it.
assert sdk_language("zh-Hans") == "zh-Hans"
assert sdk_language("zh-CN") == "zh-Hans"
for code in PASSED:
assert sdk_language(code) == code
def test_no_language_builds_the_config_of_before(tmp_path) -> None:
def test_zh_hans_and_no_choice_build_the_config_of_before(tmp_path) -> None:
before = repr(dataclasses.asdict(_config(tmp_path, None)))
assert _config(tmp_path, None).language is None
assert _config(tmp_path, sdk_language("zh-Hans")).language == "zh-Hans"
assert repr(dataclasses.asdict(_config(tmp_path, sdk_language("zh-Hans")))) == before
@pytest.mark.parametrize("code", PASSED)
@@ -283,31 +282,17 @@ def test_ui_language_api_get_put_and_status(tmp_path, monkeypatch) -> None:
assert current["languages"] == list(UI_LANGUAGES)
assert reads.current_language() is None and writes.sdk_language() is None
# zh-Hans is passed explicitly: Chinese prompts, handles rebuilt (#243).
# zh-Hans: recorded, but the SDK still detects zh/en; nothing rebuilt.
body = (
await client.put("/api/v1/settings/ui-language", json={"ui_language": "zh-Hans"})
).json()
assert body == {
**current,
"ui_language": "zh-Hans",
"sdk_language": "zh-Hans",
"rebuilt": True,
}
assert reads.current_language() == "zh-Hans" and writes.sdk_language() == "zh-Hans"
assert retired == ["zh-Hans"]
processing = (await client.get("/api/v1/memory-processing")).json()
assert processing["pipeline"]["sdk_language"] == "zh-Hans"
# The same value again changes nothing.
body = (
await client.put("/api/v1/settings/ui-language", json={"ui_language": "zh-Hans"})
).json()
assert body["rebuilt"] is False and retired == ["zh-Hans"]
assert body == {**current, "ui_language": "zh-Hans", "rebuilt": False}
assert retired == [] and reads.current_language() is None
body = (await client.put("/api/v1/settings/ui-language", json={"ui_language": "ja"})).json()
assert body["sdk_language"] == "ja" and body["rebuilt"] is True
assert reads.current_language() == "ja" and writes.sdk_language() == "ja"
assert retired == ["zh-Hans", "ja"]
assert retired == ["ja"]
# An explicit English choice forces English prompts.
body = (await client.put("/api/v1/settings/ui-language", json={"ui_language": "en"})).json()
@@ -317,7 +302,7 @@ def test_ui_language_api_get_put_and_status(tmp_path, monkeypatch) -> None:
assert processing["pipeline"]["ui_language"] == "en"
assert processing["pipeline"]["sdk_language"] == "en"
# Unset (CLI `settings language system`): auto again.
# Back to "follow the system": auto again.
body = (await client.put("/api/v1/settings/ui-language", json={"ui_language": None})).json()
assert body["sdk_language"] == "auto" and body["rebuilt"] is True
assert reads.current_language() is None
@@ -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();
},
@@ -15,13 +15,9 @@ it("bakes fallback release identity when the packaging pipeline omits it", async
expect(config).toMatch(
/__ONE_MEMORY_BUILD_NUMBER__: JSON\.stringify\(\s*String\(releaseBuildNumber\)/,
);
expect(config).toContain(
"const releaseCommit = resolveReleaseCommit(process.env.ONE_MEMORY_COMMIT)",
expect(config).toMatch(
/process\.env\.ONE_MEMORY_COMMIT\?\.trim\(\) \|\| readSourceCommit\(\)/,
);
expect(config).toMatch(/if \(!trimmed\) return readSourceCommit\(\);/);
// A supplied commit must be the full 40-character form the runtime accepts.
expect(config).toContain("/^[0-9a-f]{40}$/i.test(trimmed)");
expect(config).toContain("return trimmed.toLowerCase();");
expect(config).toMatch(
/execFileSync\("git", \["rev-parse", "HEAD"\],[\s\S]*?encoding: "utf8"/,
);
@@ -1,4 +1,4 @@
import type { GuiLocale, LanguageSnapshot } from "@one-memory/i18n";
import type { LanguageSnapshot } from "@one-memory/i18n";
import { describe, expect, it, vi } from "vitest";
import { coreUiLanguage, installCoreLanguageSync } from "./core-language-sync";
@@ -21,16 +21,11 @@ function source(initial: LanguageSnapshot) {
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
describe("Core language sync", () => {
it("tells Core the language the GUI shows, system included", () => {
const shown = (
preference: LanguageSnapshot["preference"],
locale: GuiLocale,
) => coreUiLanguage({ preference, locale, revision: 0 });
expect(shown("system", "zh-Hans")).toBe("zh-Hans");
expect(shown("system", "en")).toBe("en");
expect(shown("zh-Hans", "zh-Hans")).toBe("zh-Hans");
expect(shown("en", "en")).toBe("en");
expect(shown("ja", "ja")).toBe("ja");
it("tells Core null for system and the code for an explicit choice", () => {
expect(coreUiLanguage("system")).toBeNull();
expect(coreUiLanguage("zh-Hans")).toBe("zh-Hans");
expect(coreUiLanguage("en")).toBe("en");
expect(coreUiLanguage("ja")).toBe("ja");
});
it("sends the stored choice once at launch, then every change", async () => {
@@ -43,33 +38,29 @@ describe("Core language sync", () => {
await settle();
language.change({ preference: "system", locale: "en", revision: 2 });
await settle();
expect(send.mock.calls).toEqual([["es"], ["ja"], ["en"]]);
expect(send.mock.calls).toEqual([["es"], ["ja"], [null]]);
dispose();
language.change({ preference: "de", locale: "de", revision: 3 });
await settle();
expect(send).toHaveBeenCalledTimes(3);
});
it("an untouched install sends the system language the GUI shows", async () => {
it("an untouched install sends null, which leaves the SDK on auto", async () => {
const language = source({
preference: "system",
locale: "zh-Hans",
locale: "ja",
revision: 0,
});
const send = vi.fn(async () => {});
installCoreLanguageSync({ language, send });
await settle();
expect(send.mock.calls).toEqual([["zh-Hans"]]);
// Choosing the language already shown changes nothing Core sees.
language.change({ preference: "zh-Hans", locale: "zh-Hans", revision: 1 });
await settle();
expect(send).toHaveBeenCalledTimes(1);
expect(send.mock.calls).toEqual([[null]]);
});
it("reports a failed send and retries it with the next change", async () => {
const language = source({ preference: "fr", locale: "fr", revision: 0 });
const send = vi
.fn<(value: string) => Promise<void>>()
.fn<(value: string | null) => Promise<void>>()
.mockRejectedValueOnce(new Error("core_unavailable"))
.mockResolvedValue(undefined);
const reportError = vi.fn();
+16 -18
View File
@@ -1,37 +1,35 @@
import type { GuiLocale, LanguageSnapshot } from "@one-memory/i18n";
import type { GuiLocale, LanguagePreference } from "@one-memory/i18n";
import type { NativeLanguageSource } from "./native-language";
/**
* What Core is told: the language the GUI shows. "Follow the system" sends
* the language it resolved to, so the memories match the interface a person
* sees; the App never leaves the SDK to detect zh/en (#243).
*/
export function coreUiLanguage(snapshot: LanguageSnapshot): GuiLocale {
return snapshot.locale;
/** What Core is told: a code, or null for "follow the system". */
export function coreUiLanguage(
preference: LanguagePreference,
): GuiLocale | null {
return preference === "system" ? null : preference;
}
/**
* Keep Core's `ui_language` setting on the display language the GUI shows.
* Keep Core's `ui_language` setting on the display language a person chose.
*
* Core builds every SDK client with it and passes it, zh-Hans included, as
* the SDK prompt language. Sent once when installed, so a choice made before
* this existed, or the system language followed at this launch, reaches
* Core; then on every committed change. A value Core already has is not sent
* again. Best effort: a failed send is reported and retried with the next
* change or launch, and never blocks the GUI.
* Core builds every SDK client with it: zh-Hans and "follow the system" pass
* no language (the SDK keeps detecting zh/en), any other choice sets the SDK
* prompt language. Sent once when installed, so a choice made before this
* existed reaches Core, then on every committed change; a value Core already
* has is not sent again. Best effort: a failed send is reported and retried
* with the next change or launch, and never blocks the GUI.
*/
export function installCoreLanguageSync(options: {
language: NativeLanguageSource;
send(uiLanguage: GuiLocale): Promise<void>;
send(uiLanguage: GuiLocale | null): Promise<void>;
reportError?(error: unknown): void;
}): () => void {
let closed = false;
let sent: GuiLocale | undefined;
let sent: GuiLocale | null | undefined;
let queue: Promise<void> = Promise.resolve();
const push = () => {
queue = queue.then(async () => {
if (closed) return;
const value = coreUiLanguage(options.language.read());
const value = coreUiLanguage(options.language.read().preference);
if (value === sent) return;
try {
await options.send(value);
@@ -526,22 +526,7 @@ describe("hourly isolated desktop launch", () => {
expect(resolvePackagedBuildNumber("-1")).toBeUndefined();
expect(resolvePackagedBuildNumber(undefined, "3907")).toBe(3907);
expect(resolvePackagedBuildNumber("3908", "3907")).toBe(3908);
});
it("resolves only a full 40-character commit and lowercases it", () => {
// Every downstream check (packaged-runtime install, local install, release
// download, release scripts) requires 40 lowercase hex characters.
const embedded = "388fd0e9b3c41a2d5e6f7a8b9c0d1e2f3a4b5c6d";
expect(resolvePackagedCommit(undefined, embedded)).toBe(embedded);
expect(resolvePackagedCommit(embedded.toUpperCase())).toBe(embedded);
expect(
resolvePackagedCommit(` ${embedded.toUpperCase()} `, undefined),
).toBe(embedded);
expect(resolvePackagedCommit("388fd0e9", embedded)).toBe(embedded);
expect(resolvePackagedCommit("388fd0e9")).toBeUndefined();
expect(resolvePackagedCommit("not-a-sha", embedded)).toBe(embedded);
expect(resolvePackagedCommit(`${embedded}0`, embedded)).toBe(embedded);
expect(resolvePackagedCommit("g".repeat(40), embedded)).toBe(embedded);
expect(resolvePackagedCommit(undefined, "unknown")).toBeUndefined();
expect(resolvePackagedCommit(undefined, "388fd0e9")).toBe("388fd0e9");
expect(resolvePackagedCommit("not-a-sha", "388fd0e9")).toBe("388fd0e9");
});
});
+1 -7
View File
@@ -143,12 +143,6 @@ export function resolvePackagedBuildNumber(
return undefined;
}
/**
* The packaged-runtime install, local install, release download and release
* scripts all require a full 40-character lowercase commit. Normalise here so
* that one format crosses this boundary: an uppercase full SHA is lowercased,
* and a short or malformed value is skipped so it cannot shadow the next source.
*/
export function resolvePackagedCommit(
environmentValue: unknown,
embeddedValue?: unknown,
@@ -156,7 +150,7 @@ export function resolvePackagedCommit(
for (const value of [environmentValue, embeddedValue]) {
if (typeof value !== "string") continue;
const trimmed = value.trim();
if (/^[0-9a-f]{40}$/i.test(trimmed)) return trimmed.toLowerCase();
if (/^[0-9a-f]{7,40}$/i.test(trimmed)) return trimmed;
}
return undefined;
}
@@ -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({
@@ -851,14 +879,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" },
);
});
@@ -1169,7 +1208,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
@@ -1239,12 +1277,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({
@@ -1258,8 +1352,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?.(),
@@ -1291,12 +1390,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 &&
@@ -1665,7 +1762,8 @@ if (desktopVersion !== null) {
? readLoginPreference(
runtimeDataDir,
process.platform,
preserveManagedRuntimeOnQuit,
runtimeServiceManagedByApp,
currentRuntimeServiceState,
)
: Promise.resolve({
supported: false,
@@ -1678,9 +1776,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() }
: {}),
@@ -1712,7 +1822,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 启动失败。请重新启动;如果仍然打不开,请重新安装,你的记忆数据不会受到影响。",
};
+2 -14
View File
@@ -11,20 +11,8 @@ const { resolveBuildNumber, resolveBuildVersion } =
};
const releaseVersion = resolveBuildVersion(process.env);
const releaseBuildNumber = resolveBuildNumber(process.env);
const releaseCommit = resolveReleaseCommit(process.env.ONE_MEMORY_COMMIT);
// The runtime accepts only a full 40-character commit, so an explicitly
// supplied malformed value fails the build instead of being baked and skipped.
function resolveReleaseCommit(explicit: string | undefined): string {
const trimmed = explicit?.trim();
if (!trimmed) return readSourceCommit();
if (!/^[0-9a-f]{40}$/i.test(trimmed)) {
throw new Error(
`ONE_MEMORY_COMMIT must be a full 40-character hex commit, got ${JSON.stringify(trimmed)}`,
);
}
return trimmed.toLowerCase();
}
const releaseCommit =
process.env.ONE_MEMORY_COMMIT?.trim() || readSourceCommit();
function readSourceCommit(): string {
try {
+4 -4
View File
@@ -189,14 +189,14 @@ node apps/cli/dist/one-memory.cjs settings features reset
```bash
node apps/cli/dist/one-memory.cjs settings language # 界面语言和 SDK 提示语言
node apps/cli/dist/one-memory.cjs settings language ja # 设为日语,立即生效
node apps/cli/dist/one-memory.cjs settings language system # 清除设置(不指定 SDK 语言)
node apps/cli/dist/one-memory.cjs settings language system # 跟随系统(不指定 SDK 语言)
```
- 可选 11 种:`en`、`zh-Hans`、`zh-Hant`、`ja`、`es`、`de`、`pt`、`fr`、`ko`、`ru`、`id`,以及 `system`;旧代码 `zh-CN` 按 `zh-Hans` 处理。
- App 切换语言时会自动设置;Desktop 每次启动也会同步一次界面实际显示的语言(App 选“跟随系统”时,发送它按系统语言解析出的语言,例如 `zh-Hans`)。全局设置,存于 `config.json` 的 `ui_language`;从未设置时不写入。
- 所有语言(包括 `zh-Hans` 和 `en`)都作为 `MemoryConfig.language` 传给 SDK,提取等提示使用该语言;只有未设置(`system`,或只用 CLI/API、从未打开 App 的安装)时不向 SDK 传语言,SDK 照旧自动识别中文或英文。
- App 切换语言时会自动设置(Desktop 启动时也会同步一次已保存的选择)。全局设置,存于 `config.json` 的 `ui_language`;从未选择时不写入。
- `zh-Hans` 和 `system` 不向 SDK 传语言,SDK 照旧自动识别中文或英文;其余语言(包括 `en`)作为 `MemoryConfig.language` 传给 SDK,提取等提示使用该语言。
- 修改会像功能开关一样重建 SDK 客户端;不迁移数据,不改集合名(需要 hy-memory 2.0.11 的 `MemoryConfig.language`)。
- `status --json` 的 `pipeline.ui_language` / `pipeline.sdk_language`(`auto` 或语言代码;`zh-Hans` 用户显示 `zh-Hans`)。API:`GET`/`PUT /api/v1/settings/ui-language`。
- `status --json` 的 `pipeline.ui_language` / `pipeline.sdk_language`(`auto` 或语言代码)。API:`GET`/`PUT /api/v1/settings/ui-language`。
## 8. 召回(recall)
@@ -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") });
});
});
}