Skip to content

Commit 612b1a7

Browse files
committed
fix(sidecar): harden recovery convergence
1 parent d001f06 commit 612b1a7

12 files changed

Lines changed: 224 additions & 71 deletions

File tree

apps/packaged/src/index.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import {
3434
waitForLauncherAfterQuit,
3535
} from "./launcher-after-quit.js";
3636
import {
37-
clearPackagedLauncherDelegatedAttempt,
37+
clearPackagedLauncherDelegatedExitAttempt,
3838
confirmPackagedLauncherRuntime,
3939
resolvePackagedLauncherRuntime,
4040
} from "./launcher-runtime.js";
@@ -125,9 +125,12 @@ async function main(): Promise<void> {
125125
logger: console,
126126
paths: initialPaths,
127127
});
128-
if (existingDesktop.action === "exit" && existingDesktop.reason === "existing-focused") {
129-
await clearPackagedLauncherDelegatedAttempt(namespaceConfig, initialPaths, delegated);
130-
}
128+
await clearPackagedLauncherDelegatedExitAttempt(
129+
namespaceConfig,
130+
initialPaths,
131+
delegated,
132+
existingDesktop,
133+
);
131134
if (exitPackagedLauncherForExistingDesktop(existingDesktop, (code) => app.exit(code))) {
132135
return;
133136
}

apps/packaged/src/launcher-runtime.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
import { releaseChannelFromNamespace, releaseChannelFromVersion } from "@open-design/release";
2626

2727
import type { PackagedConfig, PackagedWebOutputMode, RawPackagedConfig } from "./config.js";
28+
import type { LauncherExistingDesktopGateResult } from "./launcher-after-quit.js";
2829
import type { PackagedNamespacePaths } from "./paths.js";
2930

3031
type LauncherPayloadManifest = {
@@ -620,16 +621,17 @@ export async function recordPackagedLauncherRuntimeFailedAttempt(
620621

621622
/**
622623
* Clear only the attempt pre-armed by the launcher that delegated this exact
623-
* payload pointer. A payload candidate can focus an already-running owner and
624-
* exit before resolving its own runtime; without this boundary the fresh
625-
* attempt would be misread as a failed generation on the next cold start.
624+
* payload pointer whenever the delegated candidate exits without ownership.
625+
* Whether focus succeeds or fails, leaving the fresh attempt behind would be
626+
* misread as a failed generation on the next cold start.
626627
*/
627-
export async function clearPackagedLauncherDelegatedAttempt(
628+
export async function clearPackagedLauncherDelegatedExitAttempt(
628629
config: PackagedConfig,
629630
paths: PackagedNamespacePaths,
630631
delegated: LauncherVersionPointer | null,
632+
result: LauncherExistingDesktopGateResult,
631633
): Promise<boolean> {
632-
if (delegated == null) return false;
634+
if (result.action !== "exit" || delegated == null) return false;
633635
const channel = inferLauncherChannel(config);
634636
const launcherPaths = resolveLauncherPaths({
635637
channel,

apps/packaged/tests/launcher-runtime.test.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { describe, expect, it } from "vitest";
1111

1212
import type { PackagedConfig } from "../src/config.js";
1313
import {
14-
clearPackagedLauncherDelegatedAttempt,
14+
clearPackagedLauncherDelegatedExitAttempt,
1515
confirmPackagedLauncherRuntime,
1616
type PackagedLauncherRuntime,
1717
resolvePackagedLauncherRuntime,
@@ -56,18 +56,26 @@ describe("resolvePackagedLauncherRuntime", () => {
5656
await mkdir(dirname(runtime.launcherPaths.attemptsPath), { recursive: true });
5757
await writeFile(runtime.launcherPaths.attemptsPath, `${JSON.stringify(attempt)}\n`);
5858

59-
await expect(clearPackagedLauncherDelegatedAttempt(config, paths, {
59+
await expect(clearPackagedLauncherDelegatedExitAttempt(config, paths, {
60+
generation: 2,
61+
version: "1.2.3-beta.6",
62+
}, { action: "continue", reason: "not-running" })).resolves.toBe(false);
63+
await expect(readFile(runtime.launcherPaths.attemptsPath, "utf8")).resolves.toContain(
64+
"1.2.3-beta.6",
65+
);
66+
67+
await expect(clearPackagedLauncherDelegatedExitAttempt(config, paths, {
6068
generation: 1,
6169
version: "1.2.3-beta.5",
62-
})).resolves.toBe(false);
70+
}, { action: "exit", reason: "existing-focused" })).resolves.toBe(false);
6371
await expect(readFile(runtime.launcherPaths.attemptsPath, "utf8")).resolves.toContain(
6472
"1.2.3-beta.6",
6573
);
6674

67-
await expect(clearPackagedLauncherDelegatedAttempt(config, paths, {
75+
await expect(clearPackagedLauncherDelegatedExitAttempt(config, paths, {
6876
generation: 2,
6977
version: "1.2.3-beta.6",
70-
})).resolves.toBe(true);
78+
}, { action: "exit", reason: "existing-focus-failed" })).resolves.toBe(true);
7179
await expect(readFile(runtime.launcherPaths.attemptsPath, "utf8")).rejects.toThrow();
7280
} finally {
7381
await rm(root, { force: true, recursive: true });

packages/sidecar/src/control/controller.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -529,17 +529,20 @@ export function bootstrapControlPlane({
529529
throw new SidecarControlError("invalid-input", "sidecar existing launch mode must be adopt or replace");
530530
}
531531
if (existing === "adopt") {
532+
let current: PrivateReadyDescriptor | null = null;
532533
try {
533-
const current = await readCurrentDescriptor(
534+
current = await readCurrentDescriptor(
534535
descriptor.identity,
535536
descriptor.roots,
536537
descriptor.projection,
537538
);
539+
} catch (error) {
540+
if (!(error instanceof SidecarControlError) || error.code !== "peer-unavailable") throw error;
541+
}
542+
if (current != null && processAlive(current.pid)) {
538543
const client = createClient<TMethods>(current);
539544
await client.probe();
540545
return adoptedLaunch({ peer: { client, descriptor: current }, stopTimeoutMs });
541-
} catch (error) {
542-
if (!(error instanceof SidecarControlError) || error.code !== "peer-unavailable") throw error;
543546
}
544547
} else {
545548
const converged = await stop(options.service, { graceMs: stopTimeoutMs });

packages/sidecar/tests/control-plane.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { spawn } from "node:child_process";
2-
import { mkdtemp, rm } from "node:fs/promises";
2+
import { access, mkdtemp, rm } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { afterEach, describe, expect, it } from "vitest";
@@ -346,6 +346,77 @@ describe("independent sidecar controller and body", () => {
346346
await expect(right.client.call("echo", { value: "right" })).resolves.toEqual({ value: "right" });
347347
});
348348

349+
it("does not spawn when an adopted identity exists but its peer is unprobeable", async () => {
350+
const { roots, scope } = await createFixture();
351+
const controller = createDemoController(scope, roots);
352+
const holder = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
353+
stdio: "ignore",
354+
windowsHide: true,
355+
});
356+
await new Promise<void>((resolveSpawn, rejectSpawn) => {
357+
holder.once("error", rejectSpawn);
358+
holder.once("spawn", resolveSpawn);
359+
});
360+
const holderPid = holder.pid;
361+
if (holderPid == null) throw new Error("unprobeable peer fixture did not report a pid");
362+
cleanups.push(async () => {
363+
if (holder.exitCode == null && holder.signalCode == null) holder.kill("SIGKILL");
364+
await new Promise<void>((resolveExit) => {
365+
if (holder.exitCode != null || holder.signalCode != null) resolveExit();
366+
else holder.once("exit", () => resolveExit());
367+
});
368+
});
369+
370+
const metadata = createPrivateLaunchForTest({
371+
projection: demoProjection,
372+
roots,
373+
scope,
374+
service: "daemon",
375+
});
376+
await writePrivateReadyDescriptorForTest(metadata, holderPid);
377+
const spawnMarker = join(roots.runtimeRoot, "unexpected-spawn");
378+
379+
await expect(controller.launch<DemoMethods>({
380+
args: [
381+
"-e",
382+
"require('node:fs').writeFileSync(process.argv[1], 'spawned')",
383+
spawnMarker,
384+
],
385+
executable: process.execPath,
386+
existing: "adopt",
387+
readyTimeoutMs: 200,
388+
service: "daemon",
389+
})).rejects.toMatchObject({ code: "peer-unavailable" });
390+
await expect(access(spawnMarker)).rejects.toThrow();
391+
});
392+
393+
it("reclaims an adopted identity whose recorded process has exited", async () => {
394+
const { roots, scope } = await createFixture();
395+
const controller = createDemoController(scope, roots);
396+
const metadata = createPrivateLaunchForTest({
397+
projection: demoProjection,
398+
roots,
399+
scope,
400+
service: "daemon",
401+
});
402+
await writePrivateReadyDescriptorForTest(metadata, 2_147_483_647);
403+
404+
const launch = await controller.launch<DemoMethods>({
405+
args: ["--import", "tsx", join(import.meta.dirname, "fixtures", "control-child.ts")],
406+
executable: process.execPath,
407+
existing: "adopt",
408+
readyTimeoutMs: 5_000,
409+
service: "daemon",
410+
});
411+
cleanups.push(async () => {
412+
await launch.stop();
413+
});
414+
415+
await expect(launch.client.call("echo", { value: "reclaimed" })).resolves.toEqual({
416+
value: "reclaimed",
417+
});
418+
});
419+
349420
it("agree on normalized identity, roots and caller-owned methods", async () => {
350421
const { roots, scope } = await createFixture();
351422
const launch = createPrivateLaunchForTest({

tools/pack/src/control.ts

Lines changed: 46 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { readFileSync, statSync } from "node:fs";
1+
import { readFileSync } from "node:fs";
22
import { join, resolve } from "node:path";
33

44
import {
@@ -18,39 +18,42 @@ import {
1818

1919
import type { ToolPackConfig } from "./config.js";
2020

21+
export type ToolPackControlMode = "desktop" | "headless";
22+
23+
const TOOL_PACK_SERVICE_STOPS = [
24+
{ service: APP_KEYS.DESKTOP, options: { graceMs: 15_000 } },
25+
{ service: APP_KEYS.WEB },
26+
{ service: APP_KEYS.DAEMON },
27+
] as const;
28+
2129
export function createToolPackControl(
2230
config: ToolPackConfig,
31+
mode: ToolPackControlMode,
2332
): SidecarControlAccess {
2433
const fallbackChannel = releaseChannelFromVersion(config.appVersion)
2534
?? releaseChannelFromNamespace(config.namespace, "default")
2635
?? "local";
2736
const namespaceRoot = resolve(config.roots.runtime.namespaceRoot);
2837
let scope: SidecarControlScope = { channel: fallbackChannel, generation: 0, namespace: config.namespace };
29-
let newestIdentityMtime = Number.NEGATIVE_INFINITY;
30-
for (const name of ["desktop-root.json", "headless-root.json"]) {
31-
const identityPath = join(namespaceRoot, "runtime", name);
32-
try {
33-
const identity = JSON.parse(readFileSync(identityPath, "utf8")) as {
34-
runtime?: { channel?: unknown; generation?: unknown; namespace?: unknown };
38+
const identityPath = join(namespaceRoot, "runtime", `${mode}-root.json`);
39+
try {
40+
const identity = JSON.parse(readFileSync(identityPath, "utf8")) as {
41+
runtime?: { channel?: unknown; generation?: unknown; namespace?: unknown };
42+
};
43+
if (
44+
typeof identity.runtime?.channel === "string"
45+
&& Number.isSafeInteger(identity.runtime.generation)
46+
&& (identity.runtime.generation as number) >= 0
47+
&& identity.runtime.namespace === config.namespace
48+
) {
49+
scope = {
50+
channel: identity.runtime.channel,
51+
generation: identity.runtime.generation as number,
52+
namespace: config.namespace,
3553
};
36-
const mtime = statSync(identityPath).mtimeMs;
37-
if (
38-
mtime > newestIdentityMtime
39-
&& typeof identity.runtime?.channel === "string"
40-
&& Number.isSafeInteger(identity.runtime.generation)
41-
&& (identity.runtime.generation as number) >= 0
42-
&& identity.runtime.namespace === config.namespace
43-
) {
44-
newestIdentityMtime = mtime;
45-
scope = {
46-
channel: identity.runtime.channel,
47-
generation: identity.runtime.generation as number,
48-
namespace: config.namespace,
49-
};
50-
}
51-
} catch {
52-
// Missing or invalid identities do not define a live control scope.
5354
}
55+
} catch {
56+
// A missing or invalid mode-owned identity does not define a live scope.
5457
}
5558
return accessControlPlane({
5659
runtimeRoot: join(namespaceRoot, "runtime"),
@@ -61,11 +64,7 @@ export function createToolPackControl(
6164
export async function stopToolPackServices(
6265
control: SidecarControlAccess,
6366
): Promise<SidecarConvergeResult[]> {
64-
const attempts = await stopSidecarServices(control, [
65-
{ service: APP_KEYS.DESKTOP, options: { graceMs: 15_000 } },
66-
{ service: APP_KEYS.WEB },
67-
{ service: APP_KEYS.DAEMON },
68-
]);
67+
const attempts = await stopSidecarServices(control, TOOL_PACK_SERVICE_STOPS);
6968
const failures = attempts.flatMap((attempt) =>
7069
attempt.status === "rejected"
7170
? [new Error(`failed to stop ${attempt.service}`, { cause: attempt.error })]
@@ -79,3 +78,21 @@ export async function stopToolPackServices(
7978
return attempt.result;
8079
});
8180
}
81+
82+
export async function convergeToolPackServices(
83+
control: SidecarControlAccess,
84+
): Promise<SidecarConvergeResult[]> {
85+
const results = await stopToolPackServices(control);
86+
const failures = results.flatMap((result, index) =>
87+
result.stopped
88+
? []
89+
: [new Error(
90+
`could not prove ${TOOL_PACK_SERVICE_STOPS[index]!.service} stopped`
91+
+ (result.pid == null ? "" : ` (pid ${result.pid})`),
92+
)],
93+
);
94+
if (failures.length > 0) {
95+
throw new AggregateError(failures, "failed to converge one or more packaged services");
96+
}
97+
return results;
98+
}

tools/pack/src/linux.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
} from "@open-design/platform";
2424

2525
import type { ToolPackConfig } from "./config.js";
26-
import { createToolPackControl, stopToolPackServices } from "./control.js";
26+
import { convergeToolPackServices, createToolPackControl, stopToolPackServices } from "./control.js";
2727
import { domToPptxBundleResource } from "./dom-to-pptx-resource.js";
2828
import { copyBundledResourceTrees, linuxResources, packBundledDshRuntime } from "./resources.js";
2929
import { copyOptionalVelaCliBinary } from "./vela-cli.js";
@@ -932,7 +932,7 @@ async function waitForMarker(markerPath: string, timeoutMs: number): Promise<boo
932932

933933
async function fetchDesktopStatus(config: ToolPackConfig): Promise<DesktopStatusSnapshot | null> {
934934
try {
935-
const desktop = await createToolPackControl(config).connect<DesktopSidecarMethods>(APP_KEYS.DESKTOP);
935+
const desktop = await createToolPackControl(config, "desktop").connect<DesktopSidecarMethods>(APP_KEYS.DESKTOP);
936936
return await desktop.call("status", {}, { timeoutMs: 2_000 });
937937
} catch {
938938
return null;
@@ -956,7 +956,7 @@ export async function startPackedLinuxApp(config: ToolPackConfig): Promise<Linux
956956

957957
// Resolve and converge the exact live generation before clearing its product
958958
// identity. Clearing first would make control fall back to cold generation 0.
959-
await stopToolPackServices(createToolPackControl(config));
959+
await convergeToolPackServices(createToolPackControl(config, "desktop"));
960960
await rm(desktopIdentityPath(config), { force: true }).catch(() => undefined);
961961

962962
// --appimage-extract-and-run bypasses FUSE-mounted SquashFS, which is too slow
@@ -1012,7 +1012,7 @@ async function teardownOrphanedStart(rootPid: number): Promise<void> {
10121012
}
10131013

10141014
export async function stopPackedLinuxApp(config: ToolPackConfig): Promise<LinuxStopResult> {
1015-
const results = await stopToolPackServices(createToolPackControl(config));
1015+
const results = await stopToolPackServices(createToolPackControl(config, "desktop"));
10161016
const pids = [...new Set(results.flatMap((result) => result.pid == null ? [] : [result.pid]))];
10171017
const remainingPids = [...new Set(results.flatMap((result) => !result.stopped && result.pid != null ? [result.pid] : []))];
10181018
const stoppedPids = [...new Set(results.flatMap((result) => result.stopped && result.pid != null ? [result.pid] : []))];
@@ -1050,7 +1050,8 @@ export async function inspectPackedLinuxApp(
10501050
throw new Error("linux inspect --headless supports status only; omit --expr and --path");
10511051
}
10521052

1053-
const desktop = await createToolPackControl(config).connect<DesktopSidecarMethods>(APP_KEYS.DESKTOP).catch(() => null);
1053+
const controlMode = options.headless === true ? "headless" : "desktop";
1054+
const desktop = await createToolPackControl(config, controlMode).connect<DesktopSidecarMethods>(APP_KEYS.DESKTOP).catch(() => null);
10541055
const status = await desktop?.call("status", {}, { timeoutMs: 2000 }).catch(() => null) ?? null;
10551056

10561057
if (options.headless === true) {
@@ -1313,7 +1314,7 @@ export async function startPackedLinuxHeadless(config: ToolPackConfig): Promise<
13131314
}
13141315

13151316
const nodeCommand = (await pathExists(nodePath)) ? nodePath : process.execPath;
1316-
await stopToolPackServices(createToolPackControl(config));
1317+
await convergeToolPackServices(createToolPackControl(config, "headless"));
13171318
const logPath = headlessLogPath(config);
13181319
await mkdir(dirname(logPath), { recursive: true });
13191320
await writeFile(logPath, "", "utf8");
@@ -1375,7 +1376,7 @@ export async function startPackedLinuxHeadless(config: ToolPackConfig): Promise<
13751376
}
13761377

13771378
export async function stopPackedLinuxHeadless(config: ToolPackConfig): Promise<LinuxStopResult> {
1378-
const results = await stopToolPackServices(createToolPackControl(config));
1379+
const results = await stopToolPackServices(createToolPackControl(config, "headless"));
13791380
const pids = [...new Set(results.flatMap((result) => result.pid == null ? [] : [result.pid]))];
13801381
const remainingPids = [...new Set(results.flatMap((result) => !result.stopped && result.pid != null ? [result.pid] : []))];
13811382
const stoppedPids = [...new Set(results.flatMap((result) => result.stopped && result.pid != null ? [result.pid] : []))];

0 commit comments

Comments
 (0)