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
37 changed files with 2044 additions and 521 deletions

No files matched your search

-9
View File
@@ -4,15 +4,6 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased
- SDK: Intention dedup is now configurable per client
(`MemoryConfig.recall.intention_dedup_enabled`, default `true`;
`recall.intention_dedup_threshold`, default `MEMORY_DEDUP_THRESHOLD` or
`0.95`). Env defaults: `MEMORY_INTENTION_DEDUP_ENABLED`,
`MEMORY_INTENTION_DEDUP_THRESHOLD`. When on, behaviour is unchanged
(duplicates found at read time are still deleted); when off, no intention
dedup runs, at read time or within a write batch, and nothing is hidden,
dropped or deleted. The threshold applies to read-time dedup only. Refs #241.
## MemHub 1.0.6 — 2026-11-02
Build 37, on `hy-memory==2.0.11`. Linux x64/arm64 Runtime and macOS arm64
-20
View File
@@ -182,29 +182,9 @@ SDK 不设置 Rerank Search 并发上限。开启后模型在 `HyMemoryClient`
| `MEMORY_RECALL_STRENGTH_TAU` | `180` | 衰减时间常数,天 |
| `MEMORY_RECALL_ACCESS_TRACKING` | `true` | Strength 开启时是否回写访问信息 |
| `MEMORY_ENTITY_STORE_ENABLED` | `false` | 是否维护独立 entity collection;当前 Reader 不直接读取 |
| `MEMORY_INTENTION_DEDUP_ENABLED` | `true` | 是否对 L7 Intention 判重(读侧和写入侧批内);见下文 |
| `MEMORY_INTENTION_DEDUP_THRESHOLD` | `MEMORY_DEDUP_THRESHOLD`,再缺省为 `0.95` | Intention 读侧判重的 cosine 阈值 |
旧数据可调用当前公开方法 `client.build_entity_store(user_id)` 补建 Entity Store。
Intention 判重:开启时(默认,与以往行为一致),Search 召回的 L7 Intention 中,
同 `user_id` + 同 `agent_id` + `memory_at` 同一天 + `valid_until` 相同、且 cosine ≥ 阈值的
条目视为复本,只返回最早写入的一条,其余在后台从向量库**删除**。判重不区分内容中的项目、
对象等语义差异,内容高度相似但对象不同的意图可能被误删;写入时同一批抽取结果也会做一次
批内判重。关闭后这两处都不判重,不隐藏、不丢弃、也不删除意图。两项对应
`MemoryConfig.recall.intention_dedup_enabled` /
`recall.intention_dedup_threshold`,按 client 生效,环境变量只提供默认值:
```python
client = HyMemoryClient.from_config({
"recall": {"intention_dedup_enabled": False},
})
```
`MEMORY_DEDUP_THRESHOLD` 同时被写入侧批内意图判重和 dedup 内核使用;
`MEMORY_INTENTION_DEDUP_THRESHOLD` 只影响读侧 Intention 判重,写入侧批内判重的阈值
仍取 `MEMORY_DEDUP_THRESHOLD`。
## 日志、Trace 与历史
| 变量 | 默认值 | 说明 |
-19
View File
@@ -432,14 +432,6 @@ class RecallConfig:
retrieval_clauses_require_keyword: bool = None # env: MEMORY_RETRIEVAL_CLAUSES_REQUIRE_KEYWORD
retrieval_clauses_min_keyword_score: float = None # normalized BM25 floor
supplemental_propositions_enabled: bool = None # env: MEMORY_SUPPLEMENTAL_PROPOSITIONS_ENABLED
# ── Intention(L7)判重 ──
# 开启(默认):召回时同 user+agent+同天 memory_at+同 valid_until 且
# cosine≥阈值 的意图视为复本,只返回最早一条,其余异步从 VDB 删除(历史行为)。
# 关闭:读侧与写入侧批内都不判重——不隐藏、不丢弃、不删除任何意图。
intention_dedup_enabled: bool = None # env: MEMORY_INTENTION_DEDUP_ENABLED
# 读侧意图判重 cosine 阈值。env: MEMORY_INTENTION_DEDUP_THRESHOLD,
# 未设置时回退到共享的 MEMORY_DEDUP_THRESHOLD(兼容旧部署),默认 0.95。
intention_dedup_threshold: float = None
# 最小分数阈值
min_score_threshold: float = 0.0
@@ -758,15 +750,6 @@ class RecallConfig:
self.supplemental_propositions_enabled = _get_env_bool(
"MEMORY_SUPPLEMENTAL_PROPOSITIONS_ENABLED", False
)
if self.intention_dedup_enabled is None:
self.intention_dedup_enabled = _get_env_bool(
"MEMORY_INTENTION_DEDUP_ENABLED", True
)
if self.intention_dedup_threshold is None:
self.intention_dedup_threshold = _get_env_float(
"MEMORY_INTENTION_DEDUP_THRESHOLD",
_get_env_float("MEMORY_DEDUP_THRESHOLD", 0.95),
)
@dataclass
@@ -1401,8 +1384,6 @@ class MemoryConfig:
"supplemental_propositions_enabled": (
self.recall.supplemental_propositions_enabled
),
"intention_dedup_enabled": self.recall.intention_dedup_enabled,
"intention_dedup_threshold": self.recall.intention_dedup_threshold,
},
"pipeline": {
"default_version": self.pipeline.default_version,
+10 -31
View File
@@ -15,9 +15,6 @@ HY Memory - Intention (L7) 召回 + 惰性过期转换 + 复本判定
→ 视为同一个意图的复本(extractor 复读机 / 多 session 重复表达)。
保留组里 `gmt_created` 最早的(老的先来先得,稳定 id);
- **异步删库**:其余复本 fire-and-forget 从 VDB 删除,不影响本次调用 RT。
- **开关**:判重受 `MemoryConfig.recall.intention_dedup_enabled`(默认开)与
`recall.intention_dedup_threshold` 控制,由 reader 按 client 配置传入。
关闭时完全跳过判重:不隐藏、不删除任何意图(写入侧批内判重同样跳过)。
Q: 为什么要看 memory_at 和 valid_until?
A: 意图是 point-in-time 信号。同一句"我周五要开会",如果一条 memory_at 在
@@ -41,14 +38,8 @@ from ...models.memory import MemoryLayer, MemoryStatus
logger = logging.getLogger(__name__)
def _get_dedup_threshold(configured: Optional[float] = None) -> float:
"""去重 cosine 阈值:优先用 client 配置;否则(或配置非法)沿用 dedup 内核的
MEMORY_DEDUP_THRESHOLD,默认 0.95。"""
try:
if configured is not None:
return float(configured)
except (TypeError, ValueError):
logger.warning(f"[intention] invalid dedup threshold: {configured!r}")
def _get_dedup_threshold() -> float:
"""去重 cosine 阈值:沿用 dedup 内核的 MEMORY_DEDUP_THRESHOLD,默认 0.95。"""
try:
return float(os.environ.get("MEMORY_DEDUP_THRESHOLD", 0.95))
except (TypeError, ValueError):
@@ -253,8 +244,6 @@ async def recall_intentions(
stats_out: Optional[Dict[str, Any]] = None,
include_sources: Optional[List[str]] = None,
exclude_sources: Optional[List[str]] = None,
dedup_enabled: bool = True,
dedup_threshold: Optional[float] = None,
) -> List[Dict[str, Any]]:
"""
召回未过期的 L7_INTENTION 节点(VDB),惰性过期转 L2_FACT,判重并异步删复本。
@@ -271,11 +260,6 @@ async def recall_intentions(
now: 当前时间(默认 datetime.now(),测试可注入)
stats_out: 可选 dict,本函数把分段耗时与数量**原地回填**进去,供调用方落
READ_INTENTION pipeline log。传 None 则完全不统计(零开销)。
dedup_enabled: 是否执行读侧判重(及复本异步删库)。False 时跳过判重,
所有未过期意图原样返回,不删除任何节点。默认 True(历史行为)。
dedup_threshold: 判重 cosine 阈值。None 时回退到 env
MEMORY_DEDUP_THRESHOLD(默认 0.95),兼容直接调用本函数的旧代码;
reader 总是传入 `recall.intention_dedup_threshold`。
Returns:
[{"node_id", "score", "node", "layer": "l7_intention", "source": "vdb_intention"}, ...]
@@ -288,7 +272,7 @@ async def recall_intentions(
埋点字段(回填进 stats_out):
耗时(ms): vdb_search_ms / expire_ms / backfill_embed_ms / dedup_ms / total_ms
数量: fetched / expired / survivors / dedup_dropped / returned
配置: fetch_limit / out_limit / dedup_enabled / dedup_threshold
配置: fetch_limit / out_limit / dedup_threshold
说明: expire_ms、backfill_embed_ms 只有真的发生了对应操作才非 0,
借此可判断慢在 VDB 检索、逐条过期转换、补拉向量还是判重计算。
"""
@@ -374,7 +358,7 @@ async def recall_intentions(
_t = time.perf_counter()
_backfilled = 0
_missing_cnt = 0
if survivors and dedup_enabled: # 补拉只为判重;关闭时无需这次 VDB 读
if survivors:
missing_ids = [
h.get("node_id") for h in survivors
if h.get("node_id") and not getattr(h.get("node"), "embedding", None)
@@ -407,23 +391,18 @@ async def recall_intentions(
)
# 2. 判重:同 user + 同天 memory_at + valid_until 同日 + cosine≥threshold
# dedup_enabled=False → 完全跳过:不隐藏、不删除。
_t = time.perf_counter()
threshold = _get_dedup_threshold(dedup_threshold)
if dedup_enabled:
try:
kept, delete_ids = _partition_duplicates(survivors, threshold)
except Exception as e:
# 任何 dedup 异常 → 降级为不去重(返回全部 survivors 截断 limit)
logger.warning(f"[intention] dedup partition failed, degrade to raw: {e}")
kept, delete_ids = survivors, []
else:
threshold = _get_dedup_threshold()
try:
kept, delete_ids = _partition_duplicates(survivors, threshold)
except Exception as e:
# 任何 dedup 异常 → 降级为不去重(返回全部 survivors 截断 limit)
logger.warning(f"[intention] dedup partition failed, degrade to raw: {e}")
kept, delete_ids = survivors, []
# 纯 CPU(cosine 两两比较);survivors 多时可能显著
_stat(
dedup_ms=round((time.perf_counter() - _t) * 1000, 2),
dedup_dropped=len(delete_ids),
dedup_enabled=bool(dedup_enabled),
dedup_threshold=threshold,
)
-6
View File
@@ -1856,12 +1856,6 @@ class BaseReadPipeline(ReadPipeline):
stats_out=_intent_stats,
include_sources=request.include_sources,
exclude_sources=request.exclude_sources,
dedup_enabled=bool(getattr(
self.config.recall, "intention_dedup_enabled", True
)),
dedup_threshold=getattr(
self.config.recall, "intention_dedup_threshold", None
),
)
await trace_log.log_intention(
stats=_intent_stats,
+1 -6
View File
@@ -635,7 +635,6 @@ class MemoryWriter(WritePipeline):
不查 VDB、不做"新写入 vs 库里已有"的对比,避免给 VDB 增加读压力。
库内存量污染由 recall 侧异步删除逐步清理。best-effort:任何异常都
回退到"不判重、全写入",绝不影响 upsert 主流程。
`recall.intention_dedup_enabled=False` 时跳过批内判重,全部写入。
"""
if not intentions:
return []
@@ -661,11 +660,7 @@ class MemoryWriter(WritePipeline):
# ============ 批内 dedup(不查 VDB,只对本批 intentions 互斥判重) ============
keep_mask: List[bool] = [True] * len(intentions)
try:
dropped = (
self._intention_batch_dedup(intentions, embeddings)
if getattr(self.config.recall, "intention_dedup_enabled", True)
else []
)
dropped = self._intention_batch_dedup(intentions, embeddings)
for i in dropped:
if 0 <= i < len(keep_mask):
keep_mask[i] = False
@@ -9,7 +9,7 @@ import type {
Tray,
} from "electron";
import { describe, expect, it, vi } from "vitest";
import { installBackgroundTray } from "./background-tray";
import { installBackgroundTray, type RuntimeHosting } from "./background-tray";
import type { LoginPreference } from "./login-preference";
async function flush() {
@@ -27,6 +27,7 @@ function fixture(
status: "disabled",
};
let changed: (() => void) | undefined;
let hosting: RuntimeHosting | Error = "background-service";
const languageSnapshot: LanguageSnapshot = {
preference: "zh-Hans",
locale: "zh-Hans",
@@ -50,6 +51,10 @@ function fixture(
};
return state;
});
const readRuntimeHosting = vi.fn(async () => {
if (hosting instanceof Error) throw hosting;
return hosting;
});
const open = vi.fn();
const reportError = vi.fn();
const install = () =>
@@ -77,6 +82,7 @@ function fixture(
reportError,
readLoginPreference,
setLoginPreference,
readRuntimeHosting,
...(trial
? { allowLoginStartup: false, productName: "One Memory Trial" }
: {}),
@@ -91,6 +97,7 @@ function fixture(
menus,
readLoginPreference,
setLoginPreference,
readRuntimeHosting,
open,
reportError,
install,
@@ -103,6 +110,9 @@ function fixture(
setState(value: LoginPreference) {
state = value;
},
setHosting(value: RuntimeHosting | Error) {
hosting = value;
},
};
}
@@ -179,6 +189,50 @@ describe("native macOS background tray", () => {
expect(f.tray.eventNames()).toEqual([]);
});
it("says quitting stops the memory service when the App hosts it", async () => {
const f = fixture();
f.setHosting("app-hosted");
f.install();
await flush();
expect(f.menu()[3]?.label).toBe("退出 MemHub(同时停止记忆服务)");
f.changeLanguage("es");
await flush();
expect(f.menu()[3]?.label).toBe(
"Salir de MemHub (también detiene el servicio de memoria)",
);
});
it("re-reads who hosts the Runtime every time the menu opens", async () => {
const f = fixture();
f.install();
await flush();
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
// The user turned the background service off in Settings.
f.setHosting("app-hosted");
f.tray.emit("mouse-down");
await flush();
expect(f.menu()[3]?.label).toContain("同时停止记忆服务");
f.setHosting("background-service");
f.tray.emit("right-click");
await flush();
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
expect(f.readRuntimeHosting).toHaveBeenCalledTimes(3);
});
it("never claims quit stops the service when the state is unknown", async () => {
const f = fixture();
f.setHosting(new Error("launchctl timed out"));
f.install();
await flush();
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
f.setHosting("unknown");
f.tray.emit("mouse-down");
await flush();
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
});
it("blocks login changes for trial, development and non-macOS builds", async () => {
for (const f of [
fixture(true, "darwin", true),
+16 -1
View File
@@ -10,6 +10,8 @@ import type {
import type { LoginPreference } from "./login-preference";
import type { NativeLanguageSource } from "./native-language";
export type RuntimeHosting = "background-service" | "app-hosted" | "unknown";
export const LOGIN_SERVICE_NAME = "com.tencent.onememory.runtime.plist";
interface TrayOptions {
@@ -24,6 +26,9 @@ interface TrayOptions {
reportError(message: string): void;
readLoginPreference(): Promise<LoginPreference>;
setLoginPreference(enabled: boolean): Promise<LoginPreference>;
/** Who keeps the Runtime alive, re-read on every menu refresh: the
* background service (quit leaves it running) or this App (quit stops it). */
readRuntimeHosting?(): Promise<RuntimeHosting>;
allowLoginStartup?: boolean;
productName?: string;
language?: NativeLanguageSource;
@@ -67,6 +72,13 @@ export function installBackgroundTray(options: TrayOptions): () => void {
} else {
label += t("nativeDevelopmentUnavailable");
}
let hosting: RuntimeHosting = "unknown";
try {
hosting = (await options.readRuntimeHosting?.()) ?? "unknown";
} catch {
hosting = "unknown";
}
if (disposed || current !== generation) return;
tray.setContextMenu(
options.buildMenu([
{ label: t("nativeOpen"), click: options.open },
@@ -92,7 +104,10 @@ export function installBackgroundTray(options: TrayOptions): () => void {
},
{ type: "separator" },
{
label: t("nativeQuit"),
// Unknown keeps the plain label: quit then stops nothing.
label: t(
hosting === "app-hosted" ? "nativeQuitStopsService" : "nativeQuit",
),
click: () => {
if (!disposed) app.quit();
},
@@ -5,6 +5,7 @@ import {
claimRuntimeServiceForApp,
claimRuntimeServiceForCli,
enableRuntimeService,
type RuntimeServiceState,
runtimeServiceEnableMarkerPath,
} from "@one-memory/runtime-control";
import { expect, it } from "vitest";
@@ -98,3 +99,25 @@ it("rejects unsupported and unconfirmed writes", async () => {
),
).rejects.toThrow("login_preference_unsupported");
});
it("reads a launchctl-disabled service as off and not switchable, asked fresh", async () => {
const dataDir = await appOwned();
let state: RuntimeServiceState = {
state: "disabled",
reason: "service-disabled-by-launchctl",
jobPid: null,
};
const serviceState = async () => state;
await expect(
readLoginPreference(dataDir, "darwin", true, serviceState),
).resolves.toEqual({
supported: false,
enabled: false,
status: "disabled-by-system",
});
state = { state: "enabled", reason: "service-enabled", jobPid: 4321 };
await expect(
readLoginPreference(dataDir, "darwin", true, serviceState),
).resolves.toEqual({ supported: true, enabled: true, status: "enabled" });
});
+15 -1
View File
@@ -1,6 +1,7 @@
import {
disableRuntimeService,
enableRuntimeService,
type RuntimeServiceState,
readRuntimeServiceOwner,
runtimeServiceEnabled,
setAppRuntimeStartAtLogin,
@@ -12,10 +13,16 @@ export interface LoginPreference {
status: string;
}
/** "Launch at login" is the background service switch: it writes the enable
* marker the LaunchAgent's `KeepAlive` waits on. On it means the Runtime
* outlives the App and quitting leaves it running; off means quitting stops
* it. `serviceState`, when given, is asked fresh so a `launchctl disable`
* override (which the switch cannot undo) reads as off, not as on. */
export async function readLoginPreference(
dataDir: string,
platform: NodeJS.Platform,
supported: boolean,
serviceState?: () => Promise<RuntimeServiceState>,
): Promise<LoginPreference> {
if (platform !== "darwin" || !supported) {
return { supported: false, enabled: false, status: "unsupported" };
@@ -28,6 +35,12 @@ export async function readLoginPreference(
if (owner?.owner !== "app") {
return { supported: false, enabled: false, status: "not-installed" };
}
if (
serviceState &&
(await serviceState()).reason === "service-disabled-by-launchctl"
) {
return { supported: false, enabled: false, status: "disabled-by-system" };
}
return {
supported: true,
enabled: owner.start_at_login && (await runtimeServiceEnabled(dataDir)),
@@ -46,6 +59,7 @@ export async function setLoginPreference(
platform: NodeJS.Platform,
input: unknown,
supported: boolean,
serviceState?: () => Promise<RuntimeServiceState>,
): Promise<LoginPreference> {
if (
!input ||
@@ -63,5 +77,5 @@ export async function setLoginPreference(
await setAppRuntimeStartAtLogin(dataDir, input.openAtLogin);
if (input.openAtLogin) await enableRuntimeService(dataDir);
else await disableRuntimeService(dataDir);
return readLoginPreference(dataDir, platform, supported);
return readLoginPreference(dataDir, platform, supported, serviceState);
}
+130 -17
View File
@@ -23,7 +23,9 @@ import {
type HostPermissionClient,
installPackagedRuntime,
installUserService,
queryRuntimeServiceState,
RecallBrokerSupervisor,
type RuntimeServiceState,
RuntimeSupervisor,
readInstalledRuntimeSource,
readPublishedCorePort,
@@ -110,6 +112,7 @@ import { showMigrationFailureDialog } from "./migration-failure-dialog";
import { isAllowedNavigation } from "./navigation-policy";
import { installDenyAllPermissionPolicy } from "./permissions";
import { isHostPresent, recallHosts } from "./recall-hosts";
import { reconcileRunningRuntime } from "./runtime-adoption";
import { shutdownRuntime } from "./runtime-shutdown";
import { createSourceChangeHandler } from "./source-change-handler";
import { SourceDirectoryPicker } from "./source-directory-picker";
@@ -140,7 +143,12 @@ let runtimeSupervisor: RuntimeSupervisor | null = null;
// Where the Runtime keeps its receipt. Stopping it on a full quit needs this,
// and `stop` runs outside `initialize`, where the directory is chosen.
let runtimeDataDir: string | null = null;
let preserveManagedRuntimeOnQuit = false;
// Whether quitting stops the Runtime is asked of the service manager at quit
// time (`currentRuntimeServiceState`), never remembered from startup. The one
// startup fact kept is a Runtime whose build could not be read: this App never
// stops one it could not compare with its own.
let preserveRuntimeOnQuitReason: string | null = null;
let runtimeServiceManagedByApp = false;
let disposeTray: (() => void) | null = null;
let disposeApplicationMenu: (() => void) | null = null;
let languagePreferences: LanguagePreferences | null = null;
@@ -247,6 +255,25 @@ if (trialProfile) {
app.setPath("userData", hourlyUserDataDir);
}
/** Ask the service manager, now, whether the background service for this data
* dir is on. Never cached: quit and the tray each re-read it, so a switch made
* in Settings, by `memhub service`, or by `launchctl` counts immediately. */
function currentRuntimeServiceState(): Promise<RuntimeServiceState> {
if (!runtimeDataDir) {
return Promise.resolve({
state: "unknown",
reason: "service-state-query-failed",
jobPid: null,
});
}
return queryRuntimeServiceState({
dataDir: runtimeDataDir,
home: app.getPath("home"),
platform: process.platform,
uid: process.getuid?.(),
});
}
function prepareProtocol(): void {
protocol.registerSchemesAsPrivileged([
{
@@ -498,6 +525,7 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
!preview &&
(hostIntegrationEnabled || desktopTestIsolation !== null) &&
installedApplication;
runtimeServiceManagedByApp = appManagedRuntime;
const runningPackagedHost =
app.isPackaged && !isolatedHourly
? await resolveRunningPackagedHostPaths({
@@ -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 启动失败。请重新启动;如果仍然打不开,请重新安装,你的记忆数据不会受到影响。",
};
@@ -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") });
});
});
}
-346
View File
@@ -1,346 +0,0 @@
# -*- coding: utf-8 -*-
"""
Intention(L7)读侧判重开关(issue #241)。
覆盖:
1. 默认开启:行为不变,复本只返回最早一条,其余异步删库;
2. 关闭:不隐藏、不删除,两条意图都返回;
3. 阈值生效:cosine 低于阈值不判重,高于阈值判重;
4. reader 按 client 的 MemoryConfig.recall 读取开关/阈值,而非调用时的 env;
5. MemoryConfig 默认值与 env 回退;
6. 写入侧批内判重同样受开关控制;阈值不影响写入侧;
7. 关闭时不为判重补拉 embedding;配置阈值非法时不影响召回。
"""
from __future__ import annotations
import uuid
from datetime import datetime
import pytest
from hy_memory.config import MemoryConfig
from hy_memory.pipelines.base import ReadRequest
from hy_memory.pipelines._retrieval.intention import recall_intentions
from tests.test_intention_dedup import (
_FakeVectorStore,
_make_intention_node,
_run,
)
_MA = datetime(2025, 8, 6)
_VU = datetime(2025, 8, 31, 23, 59)
_NOW = datetime(2025, 8, 6, 12, 0)
def _unit(vec):
n = sum(x * x for x in vec) ** 0.5 or 1.0
return [x / n for x in vec]
def _seed_project_pair(vs, *, emb_a, emb_b):
"""A / B 两个项目的近似意图:同 user/agent/同天/同截止日。A 更早写入。"""
a = _make_intention_node(
"A 项目每周一触发发布前校验流程", memory_at=_MA, valid_until=_VU,
gmt_created=datetime(2025, 8, 1), embedding=emb_a,
)
b = _make_intention_node(
"B 项目每周一触发发布前校验流程", memory_at=_MA, valid_until=_VU,
gmt_created=datetime(2025, 8, 2), embedding=emb_b,
)
_run(vs.upsert(a))
_run(vs.upsert(b))
return a, b
# ============================================================================
# recall_intentions 参数
# ============================================================================
def test_default_on_keeps_earliest_and_deletes_duplicate():
vs = _FakeVectorStore()
emb = _unit([1.0, 0.0])
a, b = _seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
stats = {}
result = _run(recall_intentions(
vs, emb, user_ids=["u1"], limit=10, now=_NOW, stats_out=stats,
))
assert [h["node_id"] for h in result] == [a.node_id]
assert vs.delete_calls == [b.node_id]
assert stats["dedup_enabled"] is True
assert stats["dedup_dropped"] == 1
def test_off_returns_both_and_deletes_nothing():
vs = _FakeVectorStore()
emb = _unit([1.0, 0.0])
a, b = _seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
stats = {}
result = _run(recall_intentions(
vs, emb, user_ids=["u1"], limit=10, now=_NOW, stats_out=stats,
dedup_enabled=False,
))
assert {h["node_id"] for h in result} == {a.node_id, b.node_id}
assert vs.delete_calls == []
assert stats["dedup_enabled"] is False
assert stats["dedup_dropped"] == 0
def test_threshold_respected():
# cosine(a, b) = 0.9
emb_a = _unit([1.0, 0.0])
emb_b = _unit([0.9, (1 - 0.81) ** 0.5])
# 默认 0.95 → 0.9 不算复本
vs = _FakeVectorStore()
_seed_project_pair(vs, emb_a=emb_a, emb_b=emb_b)
result = _run(recall_intentions(
vs, emb_a, user_ids=["u1"], limit=10, now=_NOW, dedup_threshold=0.95,
))
assert len(result) == 2
assert vs.delete_calls == []
# 调低到 0.85 → 判重,删较晚的 B
vs = _FakeVectorStore()
a, b = _seed_project_pair(vs, emb_a=emb_a, emb_b=emb_b)
stats = {}
result = _run(recall_intentions(
vs, emb_a, user_ids=["u1"], limit=10, now=_NOW,
dedup_threshold=0.85, stats_out=stats,
))
assert [h["node_id"] for h in result] == [a.node_id]
assert vs.delete_calls == [b.node_id]
assert stats["dedup_threshold"] == 0.85
def test_explicit_threshold_wins_over_env(monkeypatch):
monkeypatch.setenv("MEMORY_DEDUP_THRESHOLD", "0.5")
emb_a = _unit([1.0, 0.0])
emb_b = _unit([0.9, (1 - 0.81) ** 0.5])
vs = _FakeVectorStore()
_seed_project_pair(vs, emb_a=emb_a, emb_b=emb_b)
result = _run(recall_intentions(
vs, emb_a, user_ids=["u1"], limit=10, now=_NOW, dedup_threshold=0.95,
))
assert len(result) == 2
assert vs.delete_calls == []
def test_off_skips_embedding_backfill():
vs = _FakeVectorStore()
emb = _unit([1.0, 0.0])
_seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
backfill_calls = []
async def _get_embeddings(ids):
backfill_calls.append(ids)
return {}
vs.get_embeddings = _get_embeddings
for n in vs._nodes.values():
n.embedding = None
result = _run(recall_intentions(
vs, emb, user_ids=["u1"], limit=10, now=_NOW, dedup_enabled=False,
))
assert len(result) == 2
assert backfill_calls == []
def test_invalid_threshold_degrades_to_env_default(monkeypatch):
monkeypatch.delenv("MEMORY_DEDUP_THRESHOLD", raising=False)
vs = _FakeVectorStore()
emb = _unit([1.0, 0.0])
a, b = _seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
stats = {}
result = _run(recall_intentions(
vs, emb, user_ids=["u1"], limit=10, now=_NOW, stats_out=stats,
dedup_threshold="not-a-number",
))
assert [h["node_id"] for h in result] == [a.node_id]
assert stats["dedup_threshold"] == 0.95
# ============================================================================
# 写入侧批内判重
# ============================================================================
class _SameVectorEmbed:
"""所有文本同一向量:cosine=1,开启判重时必判为复本。"""
async def embed_batch(self, texts):
return [_unit([1.0, 0.0]) for _ in texts]
async def embed_queued(self, text):
return _unit([1.0, 0.0])
def _store_batch(config_recall):
from hy_memory.pipelines.base import WriteRequest
from hy_memory.pipelines.writer import MemoryWriter
cfg = MemoryConfig.from_dict({"recall": config_recall})
vs = _FakeVectorStore()
writer = MemoryWriter(cfg, embed_service=_SameVectorEmbed(), vector_store=vs)
req = WriteRequest(
content="", messages=[], user_id="u1", agent_id="a1",
session_id="s1", request_id=uuid.uuid4().hex,
)
intentions = [
{"content": "A 项目每周一触发发布前校验流程", "tags": [], "valid_until": None},
{"content": "B 项目每周一触发发布前校验流程", "tags": [], "valid_until": None},
]
return _run(writer._store_intentions(intentions, req, vs, req.request_id)), vs
def test_write_batch_dedup_default_on_unchanged():
ids, vs = _store_batch({})
assert len(ids) == 1
assert [n.content for n in vs._nodes.values()] == [
"A 项目每周一触发发布前校验流程",
]
def test_write_batch_dedup_off_stores_both():
ids, vs = _store_batch({"intention_dedup_enabled": False})
assert len(ids) == 2
assert len(vs._nodes) == 2
def test_read_threshold_does_not_change_write_batch_dedup(monkeypatch):
"""intention_dedup_threshold 只管读侧;写入侧仍取 MEMORY_DEDUP_THRESHOLD。"""
monkeypatch.delenv("MEMORY_DEDUP_THRESHOLD", raising=False)
ids, _ = _store_batch({"intention_dedup_threshold": 1.5})
assert len(ids) == 1
# ============================================================================
# MemoryConfig
# ============================================================================
def test_config_defaults(monkeypatch):
for k in (
"MEMORY_INTENTION_DEDUP_ENABLED",
"MEMORY_INTENTION_DEDUP_THRESHOLD",
"MEMORY_DEDUP_THRESHOLD",
):
monkeypatch.delenv(k, raising=False)
cfg = MemoryConfig()
assert cfg.recall.intention_dedup_enabled is True
assert cfg.recall.intention_dedup_threshold == 0.95
def test_config_env_fallbacks(monkeypatch):
monkeypatch.setenv("MEMORY_INTENTION_DEDUP_ENABLED", "false")
monkeypatch.setenv("MEMORY_DEDUP_THRESHOLD", "0.9")
monkeypatch.delenv("MEMORY_INTENTION_DEDUP_THRESHOLD", raising=False)
cfg = MemoryConfig()
assert cfg.recall.intention_dedup_enabled is False
# 未设专用阈值 → 回退共享 MEMORY_DEDUP_THRESHOLD(兼容旧部署)
assert cfg.recall.intention_dedup_threshold == 0.9
monkeypatch.setenv("MEMORY_INTENTION_DEDUP_THRESHOLD", "0.8")
assert MemoryConfig().recall.intention_dedup_threshold == 0.8
def test_config_from_dict_overrides_env(monkeypatch):
monkeypatch.setenv("MEMORY_INTENTION_DEDUP_ENABLED", "true")
cfg = MemoryConfig.from_dict({"recall": {
"intention_dedup_enabled": False,
"intention_dedup_threshold": 0.99,
}})
assert cfg.recall.intention_dedup_enabled is False
assert cfg.recall.intention_dedup_threshold == 0.99
assert cfg.to_dict()["recall"]["intention_dedup_enabled"] is False
# ============================================================================
# reader:按 client 配置,而不是调用时的 env
# ============================================================================
def _reader_for(enabled: bool, vs):
from hy_memory.pipelines.reader_hybrid import HybridReadPipeline
cfg = MemoryConfig.from_dict({
"vector_store": {
"provider": "chroma",
"collection_name": f"t_intent_sw_{uuid.uuid4().hex[:8]}",
"embedding_dims": 2,
},
"recall": {"intention_dedup_enabled": enabled},
})
reader = HybridReadPipeline(cfg, vector_store=vs)
reader._vector_store = vs
reader._vector_store_initialized = True
return reader
async def _no_keyword_hits(**kw):
return []
def _read_project_pair(enabled: bool):
"""经 reader 完整读路径召回 A/B 两条近似意图;返回 (返回的意图 id, 删库调用, a, b)。"""
vs = _FakeVectorStore()
vs.keyword_search = _no_keyword_hits
emb = _unit([1.0, 0.0])
a, b = _seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
# 读路径用真实 now:让两条都未过期且同一天
today = datetime.now()
for n in (a, b):
n.memory_at = today
n.valid_until = None
reader = _reader_for(enabled, vs)
_run(reader.initialize())
resp = _run(reader.read(ReadRequest(
query="发布前校验", query_embedding=emb,
user_ids=["u1"], agent_ids=["a1"], limit=10, intention_limit=5,
)))
ids = {
m["memory_id"] for m in resp.memories
if m.get("layer") == "l7_intention"
}
return ids, list(vs.delete_calls), a, b
@pytest.mark.parametrize("env_value", ["true", "false"])
def test_reader_uses_per_client_config_not_env(monkeypatch, env_value):
"""调用时的 env 与 client 配置无关:两个 client 一开一关,各自生效。"""
monkeypatch.setenv("MEMORY_INTENTION_DEDUP_ENABLED", env_value)
monkeypatch.setenv("MEMORY_DEDUP_THRESHOLD", "0.0")
ids_on, deleted_on, a, b = _read_project_pair(True)
assert ids_on == {a.node_id}
assert deleted_on == [b.node_id]
ids_off, deleted_off, a, b = _read_project_pair(False)
assert ids_off == {a.node_id, b.node_id}
assert deleted_off == []
def test_reader_passes_client_threshold(monkeypatch):
monkeypatch.setenv("MEMORY_DEDUP_THRESHOLD", "0.1")
seen = []
async def _spy(*args, **kwargs):
seen.append(kwargs)
return []
monkeypatch.setattr("hy_memory.pipelines.reader_base.recall_intentions", _spy)
vs = _FakeVectorStore()
vs.keyword_search = _no_keyword_hits
reader = _reader_for(True, vs)
reader.config.recall.intention_dedup_threshold = 0.97
_run(reader.initialize())
_run(reader.read(ReadRequest(
query="发布前校验", query_embedding=_unit([1.0, 0.0]),
user_ids=["u1"], agent_ids=["a1"], limit=10, intention_limit=5,
)))
assert seen and seen[0]["dedup_enabled"] is True
assert seen[0]["dedup_threshold"] == 0.97