mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
fix(update): refuse runtime repair while shared outputs are in use (#161239)
Reuse managed-service publication checks at each runtime publication boundary, comparing the publisher's exact physical outputs. Live disjoint runtimes stay available; observed shared consumers must be stopped before replacement. Document that stopped sibling profiles retain their own state and may need the updated CLI's existing Doctor migration before resuming. No schema version or automatic sibling migration is introduced. Validation: 67 focused tests pass; five overlap regressions take 202 ms. Fresh managed review is scoped-clean through P2. Isolated Linux native proof of the composed selector/runtime candidate covers disjoint, overlapping live, and stopped-sibling publication with preserved conversation witnesses.
This commit is contained in:
parent
3e29414ba1
commit
ecb13ad18d
4 changed files with 123 additions and 14 deletions
|
|
@ -45,6 +45,13 @@ The new build records its commit, so the next update can finish as already curre
|
|||
installation; the update leaves those files intact and reports the process and
|
||||
the stop/retry action.
|
||||
|
||||
Stopping another Gateway that shares this installation protects its running
|
||||
code; it does not migrate that Gateway's separate state. If its older state
|
||||
requires migration, run the updated CLI's `doctor --fix` under that service's own
|
||||
account, profile, and environment before resuming it. Doctor may restore the
|
||||
service itself, so inspect its status before starting it again through its
|
||||
service manager.
|
||||
|
||||
Source commands launched with `pnpm openclaw` also refuse an automatic rebuild
|
||||
while that installation's Gateway is running. Use the installed `openclaw update`
|
||||
or `node openclaw.mjs update` from the checkout to reach the updater's managed
|
||||
|
|
@ -962,7 +969,7 @@ the sentinel.
|
|||
<Step title="Sync plugins">
|
||||
Against the installed target, syncs plugins to the active channel before restarting the managed service. Dev uses bundled plugins; stable and beta use npm or ClawHub while preserving recorded source choices. A changed plugin snapshot runs fresh Doctor migrations; unchanged plugins do not run another full Doctor pass. The updater then revalidates the service owner, starts the Gateway, and verifies the final snapshot.
|
||||
|
||||
Source targets that support runtime completion also check their generated plugin runtime overlay and SDK aliases before loading plugin configuration. This completes artifacts omitted by an older updater on the first update to such a target; `update repair` performs the same check before Doctor. Exact artifacts remain untouched, including while a Gateway is running. Replacing missing or stale artifacts requires proof that the affected runtime is offline. A Gateway serving a physically separate runtime does not block completion. If service ownership or offline status cannot be verified, completion fails with recovery guidance instead of reporting a successful update. Older targets retain their existing generation behavior. Clean-source and staged-build validation still apply.
|
||||
Source targets that support runtime completion also check their generated plugin runtime overlay and SDK aliases before loading plugin configuration. This completes artifacts omitted by an older updater on the first update to such a target; `update repair` performs the same check before Doctor. Exact artifacts remain untouched, including while a Gateway is running. Replacing missing or stale artifacts requires proof that the selected Gateway's affected runtime is offline. Before publication and each subsequent write, completion also checks discovered managed Gateways and refuses any observed live sibling using overlapping output paths. Stop that sibling through its own service manager or Startup process before retrying. A Gateway serving a physically separate runtime does not block completion. If the selected service's ownership or offline status cannot be verified, completion fails with recovery guidance instead of reporting a successful update. Older targets retain their existing generation behavior. Clean-source and staged-build validation still apply.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import path from "node:path";
|
|||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { runNodeMain } from "../../../scripts/run-node.mts";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
|
||||
import * as serviceFiles from "../../daemon/inspect-files.js";
|
||||
import { ServiceInspectionError } from "../../daemon/service-inspection-error.js";
|
||||
import { withGatewayServiceOperationLock } from "../../daemon/service-operation-lock.js";
|
||||
import type { GatewayService } from "../../daemon/service.js";
|
||||
|
|
@ -463,6 +464,85 @@ it.each([
|
|||
}),
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ label: "already live", late: false, output: "dist-runtime", shared: "dist-runtime" },
|
||||
{
|
||||
label: "discovered before the effect",
|
||||
late: true,
|
||||
output: "dist-runtime",
|
||||
shared: "dist-runtime",
|
||||
},
|
||||
{ label: "physically disjoint", late: false, output: "dist-runtime", shared: undefined },
|
||||
{
|
||||
label: "custom output shared",
|
||||
late: false,
|
||||
output: "custom-runtime",
|
||||
shared: "custom-runtime",
|
||||
},
|
||||
{
|
||||
label: "custom output disjoint",
|
||||
late: false,
|
||||
output: "custom-runtime",
|
||||
shared: "dist-runtime",
|
||||
},
|
||||
])("fences runtime-only publication against a sibling that is $label", ({ late, output, shared }) =>
|
||||
withRuntimePublicationFixture(async ({ home, root, env, service }) => {
|
||||
const sibling = path.join(home, "sibling");
|
||||
await fs.mkdir(path.join(sibling, "dist"), { recursive: true });
|
||||
await fs.writeFile(path.join(sibling, "package.json"), JSON.stringify({ name: "openclaw" }));
|
||||
await fs.writeFile(path.join(sibling, "dist", "entry.js"), "export {};\n");
|
||||
await fs.mkdir(path.join(root, output), { recursive: true });
|
||||
if (shared) {
|
||||
await fs.symlink(path.join(root, shared), path.join(sibling, shared), "junction");
|
||||
}
|
||||
const directory = path.join(home, ".config", "systemd", "user");
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
const scan = serviceFiles.scanSystemdDir;
|
||||
vi.spyOn(serviceFiles, "scanSystemdDir").mockImplementation((params) =>
|
||||
params.dir === directory ? scan(params) : Promise.resolve([]),
|
||||
);
|
||||
const unit = "openclaw-gateway-sibling.service";
|
||||
const discoverSibling = () =>
|
||||
fs.writeFile(
|
||||
path.join(directory, unit),
|
||||
`[Service]\nEnvironment="OPENCLAW_PROFILE=sibling" "OPENCLAW_SERVICE_MARKER=openclaw" "OPENCLAW_SERVICE_KIND=gateway"\nExecStart=${JSON.stringify(process.execPath)} ${JSON.stringify(path.join(sibling, "dist", "entry.js"))} gateway\n`,
|
||||
);
|
||||
if (!late) {
|
||||
await discoverSibling();
|
||||
}
|
||||
vi.mocked(service.readCommand).mockImplementation(async (serviceEnv) => ({
|
||||
programArguments: [
|
||||
process.execPath,
|
||||
path.join(serviceEnv?.OPENCLAW_SYSTEMD_UNIT === unit ? sibling : root, "dist", "entry.js"),
|
||||
"gateway",
|
||||
],
|
||||
}));
|
||||
vi.mocked(service.readRuntime).mockImplementation(async (serviceEnv) => ({
|
||||
status: serviceEnv?.OPENCLAW_SYSTEMD_UNIT === unit ? "running" : "stopped",
|
||||
systemd: { managerUid: 2001 },
|
||||
}));
|
||||
const artifact = path.join(root, output, "published.txt");
|
||||
await fs.writeFile(artifact, "original");
|
||||
const publication = withGatewayRuntimeArtifactPublication(
|
||||
{ root, env, timeoutMs: 200, assertCurrent() {}, outputPaths: [output] },
|
||||
async (assertPublicationCurrent) => {
|
||||
if (late) {
|
||||
await discoverSibling();
|
||||
}
|
||||
await assertPublicationCurrent();
|
||||
await fs.writeFile(artifact, "candidate");
|
||||
},
|
||||
);
|
||||
const overlaps = shared === output;
|
||||
if (overlaps) {
|
||||
await expect(publication).rejects.toMatchObject({ reason: "runtime-artifact-publication" });
|
||||
} else {
|
||||
await expect(publication).resolves.toBeUndefined();
|
||||
}
|
||||
expect(await fs.readFile(artifact, "utf8")).toBe(overlaps ? "original" : "candidate");
|
||||
}),
|
||||
);
|
||||
|
||||
it.each(["inspection", "publication"])(
|
||||
"retains the caller's publication lease across %s awaits",
|
||||
(when) =>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
observedSystemdManagerUid,
|
||||
resolveUpdatedGatewayRestartPort,
|
||||
} from "./update-command-service-plan.js";
|
||||
import { assertManagedGatewayArtifactPublication } from "./update-command-service-revalidation.js";
|
||||
|
||||
export async function isManagedGatewayServiceOffline(state: GatewayServiceState): Promise<boolean> {
|
||||
// Loaded LaunchAgents can respawn even while disabled. Windows needs the live
|
||||
|
|
@ -155,8 +156,6 @@ export async function withGatewayRuntimeArtifactPublication<T>(
|
|||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
assertCurrent();
|
||||
const layout = await summarizeGatewayServiceLayout(state.command);
|
||||
assertCurrent();
|
||||
const database = await outputIdentity(resolveOpenClawStateSqlitePath(state.env));
|
||||
assertCurrent();
|
||||
const serviceName =
|
||||
|
|
@ -171,20 +170,22 @@ export async function withGatewayRuntimeArtifactPublication<T>(
|
|||
profile: resolveGatewayProfileSuffix(state.env.OPENCLAW_PROFILE),
|
||||
managerUid: observedSystemdManagerUid(state),
|
||||
});
|
||||
let serving: { root: PathIdentity; entrypoint: PathIdentity } | undefined;
|
||||
let disjoint = false;
|
||||
if (layout?.packageRootReal && layout.entrypointReal) {
|
||||
const inspectServing = async (command: GatewayServiceState["command"]) => {
|
||||
const layout = await summarizeGatewayServiceLayout(command);
|
||||
assertCurrent();
|
||||
if (!layout?.packageRootReal || !layout.entrypointReal) {
|
||||
return undefined;
|
||||
}
|
||||
const [installed, entrypoint] = await Promise.all([
|
||||
identity(layout.packageRootReal),
|
||||
outputIdentity(layout.entrypointReal),
|
||||
]);
|
||||
assertCurrent();
|
||||
serving = { root: installed, entrypoint };
|
||||
const servingOutputs = await Promise.all(
|
||||
outputPaths.map((output) => outputIdentity(path.join(installed.real, output))),
|
||||
);
|
||||
assertCurrent();
|
||||
disjoint =
|
||||
const disjoint =
|
||||
!same(target, installed) &&
|
||||
!destinations.some(
|
||||
(destination) =>
|
||||
|
|
@ -197,11 +198,17 @@ export async function withGatewayRuntimeArtifactPublication<T>(
|
|||
isPathInside(output.real, destination.real),
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
state.command ||
|
||||
state.installed ||
|
||||
state.loadState.status !== "not-loaded" ||
|
||||
!state.runtime?.missingUnit
|
||||
return { serving: { root: installed, entrypoint }, disjoint };
|
||||
};
|
||||
const inspected = await inspectServing(state.command);
|
||||
const serving = inspected?.serving;
|
||||
const disjoint = inspected?.disjoint ?? false;
|
||||
if (
|
||||
!inspected &&
|
||||
(state.command ||
|
||||
state.installed ||
|
||||
state.loadState.status !== "not-loaded" ||
|
||||
!state.runtime?.missingUnit)
|
||||
) {
|
||||
refuse();
|
||||
}
|
||||
|
|
@ -241,6 +248,18 @@ export async function withGatewayRuntimeArtifactPublication<T>(
|
|||
refuse();
|
||||
}
|
||||
}
|
||||
await assertManagedGatewayArtifactPublication({
|
||||
roots: [params.root],
|
||||
env: params.env,
|
||||
timeoutMs: params.timeoutMs,
|
||||
assertCurrent,
|
||||
updateInstallKind: "git",
|
||||
shouldRestart: false,
|
||||
inspectOverlap: async (_root, command) => {
|
||||
const consumer = await inspectServing(command);
|
||||
return consumer ? !consumer.disjoint : null;
|
||||
},
|
||||
});
|
||||
return { state, disjoint, parents, destinations, database, nativeIdentity, serving };
|
||||
};
|
||||
const inspect = async () => {
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ export async function assertManagedGatewayArtifactPublication(params: {
|
|||
updateInstallKind: "git" | "package" | "unknown";
|
||||
shouldRestart: boolean;
|
||||
phase?: "before-stop" | "publication";
|
||||
inspectOverlap?: typeof gatewayServiceCommandOverlapsPhysicalInstallation;
|
||||
}): Promise<void> {
|
||||
params.assertCurrent();
|
||||
const serving = params.selected;
|
||||
|
|
@ -202,7 +203,9 @@ export async function assertManagedGatewayArtifactPublication(params: {
|
|||
continue;
|
||||
}
|
||||
for (const root of params.roots) {
|
||||
const overlaps = await gatewayServiceCommandOverlapsPhysicalInstallation(root, state.command);
|
||||
const overlaps = await (
|
||||
params.inspectOverlap ?? gatewayServiceCommandOverlapsPhysicalInstallation
|
||||
)(root, state.command);
|
||||
params.assertCurrent();
|
||||
if (overlaps !== true || (await selectedConsumer(binding, state))) {
|
||||
continue;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue