Skip to content

Commit 1041737

Browse files
authored
Fence fleet reconciliation during app removal (#4)
Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>
1 parent 6754bba commit 1041737

4 files changed

Lines changed: 38 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3939
backgrounding or transport loss while retaining the same server session,
4040
shell state, working directory, and scrollback. Disconnect text is no longer
4141
written into the terminal.
42+
- App-removal preparation now quiesces and fences automatic fleet care before
43+
deleting owned Apple channel machines, so an in-flight reconciliation pass
44+
cannot recreate a machine from stale pre-removal state.
4245

4346
## [0.0.3] - 2026-07-24
4447

src/server/channel-computers.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ const CONTAINER_CANDIDATES = [process.env.HELM_CONTAINER_CLI, "/usr/local/bin/co
7272
const COMMAND_TIMEOUT_MS = Math.max(5_000, Number(process.env.HELM_MACHINE_COMMAND_TIMEOUT_MS || 120_000));
7373
const IDLE_AFTER_MS = Math.max(60_000, Number(process.env.HELM_MACHINE_IDLE_MS || 15 * 60_000));
7474
const RECONCILE_EVERY_MS = Math.max(15_000, Number(process.env.HELM_FLEET_INTERVAL_MS || 60_000));
75+
const INITIAL_RECONCILE_MS = Math.max(25, Number(process.env.HELM_FLEET_INITIAL_MS || 2_000));
7576
const UPDATE_EVERY_MS = Math.max(24 * 60 * 60_000, Number(process.env.HELM_MACHINE_UPDATE_MS || 7 * 24 * 60 * 60_000));
7677
const UPDATE_RETRY_MS = Math.max(60 * 60_000, Number(process.env.HELM_MACHINE_UPDATE_RETRY_MS || 6 * 60 * 60_000));
7778
const MAX_WORKSPACE_SYNC_BYTES = Math.max(64 * 1024 ** 2, Number(process.env.HELM_WORKSPACE_SYNC_MAX_BYTES || 2 * 1024 ** 3));
@@ -85,7 +86,10 @@ const terminalSessions = new Map<string, MachineTerminal>();
8586
const channelLocks = new Map<number, Promise<unknown>>();
8687
const syncTimers = new Map<number, NodeJS.Timeout>();
8788
let reconcileTimer: NodeJS.Timeout | null = null;
89+
let reconcileStartupTimer: NodeJS.Timeout | null = null;
8890
let reconcileRunning = false;
91+
let reconcileEnabled = false;
92+
let reconcilePass: Promise<void> | null = null;
8993

9094
const installationId = (): string => {
9195
let id = String(q1("SELECT installation_id FROM workspace WHERE id=1")?.installation_id || "");
@@ -757,6 +761,10 @@ export async function appRemovalStatus(): Promise<{ backend: ChannelComputerBack
757761
export async function prepareAppRemoval(): Promise<{ backend: ChannelComputerBackend; deleted: number; remaining: number }> {
758762
const backend = configuredChannelBackend();
759763
if (backend !== "apple") return { backend, deleted: 0, remaining: 0 };
764+
// Uninstall is a fleet-wide terminal state. Quiesce and fence the reconciler
765+
// before enumerating machines so an already-running pass cannot recreate a
766+
// machine from its stale pre-removal snapshot after deletion completes.
767+
await shutdownChannelComputers();
760768
const install = installationId();
761769
const prefix = `1helm-${install}-channel-`;
762770
const machineIds = await ownedInstallationMachineIds();
@@ -1028,19 +1036,32 @@ export async function reconcileChannelComputers(channelIds?: Iterable<number>):
10281036
}
10291037

10301038
export function startChannelComputerReconciler(): void {
1031-
if (reconcileTimer) return;
1039+
if (reconcileEnabled) return;
1040+
reconcileEnabled = true;
10321041
const tick = (): void => {
1033-
if (reconcileRunning) return;
1042+
if (!reconcileEnabled || reconcileRunning) return;
10341043
reconcileRunning = true;
1035-
void reconcileChannelComputers().catch((error) => console.error("channel computer reconcile failed:", (error as Error).message)).finally(() => { reconcileRunning = false; });
1044+
const pass: Promise<void> = reconcileChannelComputers()
1045+
.then(() => undefined)
1046+
.catch((error) => console.error("channel computer reconcile failed:", (error as Error).message))
1047+
.finally(() => {
1048+
reconcileRunning = false;
1049+
if (reconcilePass === pass) reconcilePass = null;
1050+
});
1051+
reconcilePass = pass;
1052+
void pass;
10361053
};
1037-
setTimeout(tick, 2_000).unref();
1054+
reconcileStartupTimer = setTimeout(() => { reconcileStartupTimer = null; tick(); }, INITIAL_RECONCILE_MS);
1055+
reconcileStartupTimer.unref();
10381056
reconcileTimer = setInterval(tick, RECONCILE_EVERY_MS);
10391057
reconcileTimer.unref();
10401058
}
10411059

10421060
export async function shutdownChannelComputers(): Promise<void> {
1061+
reconcileEnabled = false;
1062+
if (reconcileStartupTimer) { clearTimeout(reconcileStartupTimer); reconcileStartupTimer = null; }
10431063
if (reconcileTimer) { clearInterval(reconcileTimer); reconcileTimer = null; }
1064+
await reconcilePass?.catch(() => undefined);
10441065
for (const session of [...terminalSessions.values()]) closeChannelTerminal(session.id);
10451066
for (const timer of syncTimers.values()) clearTimeout(timer);
10461067
syncTimers.clear();

test/channel-computers.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ process.env.HELM_CHANNEL_COMPUTER_BACKEND = "apple";
1919
process.env.HELM_CONTAINER_CLI = fakeCli;
2020
process.env.FAKE_CONTAINER_STATE = fakeState;
2121
process.env.HELM_FLEET_INTERVAL_MS = "600000";
22+
process.env.HELM_FLEET_INITIAL_MS = "25";
2223
process.env.HELM_MACHINE_IDLE_MS = "60000";
2324

2425
const db = await import("../src/server/db.ts");
@@ -143,9 +144,16 @@ test("Apple channel-computer contract preserves isolation, files, wakes, archive
143144
const backend = await readFile(join(root, "src", "server", "channel-computers.ts"), "utf8");
144145
assert.match(backend, /machine", "run", "-it"[\s\S]*guestWords\("\/bin\/bash", "-l"\)/, "interactive Apple terminals request an explicit guest login shell");
145146

147+
// Reproduce the real Apple race: uninstall begins while an automatic fleet
148+
// pass is already inspecting a machine. Removal must wait for that pass,
149+
// fence future ticks, and leave nothing for a stale snapshot to recreate.
150+
process.env.FAKE_CONTAINER_INSPECT_DELAY_MS = "200";
151+
computers.startChannelComputerReconciler();
152+
await new Promise((resolveWait) => setTimeout(resolveWait, 50));
146153
const removal = await computers.prepareAppRemoval();
147154
assert.equal(removal.deleted, 1, "uninstall preparation deletes every remaining VM owned by this exact 1Helm installation");
148155
assert.equal(removal.remaining, 0);
156+
await new Promise((resolveWait) => setTimeout(resolveWait, 100));
149157
assert.equal(existsSync(join(fakeState, "machines", betaComputer.machine_id)), false, "no owned channel VM survives uninstall preparation");
150158
computers.reactivateComputersAfterPreparedRemoval();
151159
assert.equal(db.q1("SELECT desired_state FROM channel_computers WHERE channel_id=?", beta.channelId).desired_state, "auto", "a later reinstall can rebuild the removed VM from its preserved host mirror");

test/fake-container.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ if (action === "create") {
6363
const config = readConfig(name);
6464
if (!config) fail(`machine ${name} not found`);
6565
if (action === "inspect") {
66+
const delay = Math.max(0, Number(process.env.FAKE_CONTAINER_INSPECT_DELAY_MS || 0));
67+
if (delay) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay);
6668
process.stdout.write(JSON.stringify(config));
6769
process.exit(0);
6870
}

0 commit comments

Comments
 (0)