Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,26 @@ test('copies Desktop diagnostics while Runtime Host is unavailable', async () =>
runtimeHostProcessLogs: () => [
'[2026-08-20T00:00:00.000Z] ERROR [runtime-host] local Host child exited: pid=42 code=23 signal=none',
],
runtimeHostConnections: () => [{
epoch: 'guest-target',
target: {
profile: {
id: 'offline-guest', name: 'Shared Session', kind: 'remote',
access: 'session_guest', rootId: 'a'.repeat(64),
transport: { kind: 'tls', url: 'wss://example.com' },
},
credential: 'private-guest-credential',
},
readiness: 'reconnecting',
reconnect: {
failures: 27,
firstFailureAt: Date.parse('2026-09-09T00:00:00Z'),
lastFailureAt: Date.parse('2026-09-09T01:00:00Z'),
},
error: Object.assign(new Error('route unavailable api_key=sk-secretvalue123'), {
code: 'peer_reachability_needs_repair',
}),
}],
resolveActiveRuntimeHost: () => undefined,
resolveRuntimeHost: () => ({
getDiagnostics: async () => {
Expand All @@ -296,6 +316,12 @@ test('copies Desktop diagnostics while Runtime Host is unavailable', async () =>
/Recent local Runtime Host process exits \(1\)[\s\S]*pid=42 code=23 signal=none/,
);
assert.match(clipboard, /Diagnostics unavailable: Runtime Host disconnected/);
assert.match(clipboard, /Runtime Host connections \(1\)\n"offline-guest": reconnecting/);
assert.match(clipboard, /Failed attempts: 27/);
assert.match(clipboard, /First failure: 2026-09-09T00:00:00.000Z/);
assert.match(clipboard, /Last failure: 2026-09-09T01:00:00.000Z/);
assert.match(clipboard, /Latest error \[peer_reachability_needs_repair\]: route unavailable/);
assert.doesNotMatch(clipboard, /private-guest-credential|sk-secretvalue123/);
});

test('acknowledges one previous-run notice while keeping its diagnostics copyable', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { BotIncomingMessage } from '@maka/runtime/bots';
import {
RuntimeHostOperationError,
RuntimeHostPeerError,
RuntimeHostPeerReachabilityUnavailableError,
RuntimeHostPermanentReconnectError,
RuntimeHostRequestInterruptedError,
type RuntimeHostSpawnedProcess,
Expand Down Expand Up @@ -1245,6 +1246,88 @@ test('keeps an initially unavailable Direct target live and wakes it on new rout
await manager.close();
});

test('repeated offline Guest failures preserve Local readiness without rebroadcasting each retry', async (t) => {
const local = candidateHarness();
const remote = candidateHarness({ hostId: 'a'.repeat(64), ownership: 'external' });
const warn = t.mock.method(console, 'warn', () => {});
const info = t.mock.method(console, 'info', () => {});
const logCount = () => warn.mock.callCount() + info.mock.callCount();
const guestErrors: string[] = [];
let attempts = 0;
let recovered = false;
let changedFailure = false;
const manager = await startRuntimeHostDesktopManager(
{} as DesktopRuntimeHostCandidateStartInput,
{
startCandidate: async (input) => {
if (!input.profileTarget) return ready(local.candidate);
attempts++;
if (recovered) return ready(remote.candidate);
throw changedFailure
? new RuntimeHostPeerError('coordination_unavailable', 'relay unavailable')
: new RuntimeHostPeerReachabilityUnavailableError('12D3KooWpeer');
},
onTargetStateChanged: (state) => {
if (state.target.profile.id === 'offline-guest' && state.readiness !== 'ready' && state.error) {
guestErrors.push(state.error.message);
}
},
reconnectBackoff: { minMs: 60_000, maxMs: 60_000 },
},
);
t.after(() => manager.close());
await manager.mountGuest(peerTarget('offline-guest', 'session_guest'), () => {});
const initialPublications = guestErrors.length;
const initialWarnings = logCount();
assert.equal(initialWarnings, 1);
for (let retry = 0; retry < 3; retry++) {
manager.wakePeerRecovery('offline-guest');
await new Promise<void>((resolve) => setImmediate(resolve));
}
assert.ok(attempts >= 4, 'retries remain live');
assert.equal(guestErrors.length, initialPublications, 'identical errors are not Host transitions');
assert.equal(logCount(), initialWarnings, 'an offline error is logged once');
assert.equal(manager.defaultProfileId(), 'local');
assert.equal(manager.current('local')?.candidate, local.candidate);

changedFailure = true;
manager.wakePeerRecovery('offline-guest');
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(guestErrors.at(-1), 'relay unavailable', 'a different failure updates diagnostics');
for (let retry = 0; retry < 20; retry++) {
changedFailure = !changedFailure;
manager.wakePeerRecovery('offline-guest');
await new Promise<void>((resolve) => setImmediate(resolve));
}
assert.equal(logCount(), initialWarnings, 'changing dial errors do not append logs during one outage');
const diagnostic = manager.entries().find((state) => state.target.profile.id === 'offline-guest');
assert.equal(diagnostic?.reconnect?.failures, attempts, 'diagnostics retain every failed attempt');
assert.ok(diagnostic?.reconnect);
assert.ok(diagnostic.reconnect.lastFailureAt >= diagnostic.reconnect.firstFailureAt);
recovered = true;
manager.wakePeerRecovery('offline-guest');
await manager.waitUntilReady('offline-guest');
assert.equal(manager.current('offline-guest')?.candidate, remote.candidate);
assert.equal(manager.current('local')?.candidate, local.candidate);
assert.equal(logCount(), initialWarnings + 1, 'recovery logs one summary');
assert.equal(info.mock.calls.at(-1)?.arguments[1]?.failedAttempts, attempts - 1);
assert.equal(
manager.entries().find((state) => state.target.profile.id === 'offline-guest')?.reconnect,
undefined,
'a recovered target no longer has pending failures',
);
recovered = false;
remote.disconnect();
await new Promise<void>((resolve) => setImmediate(resolve));
manager.wakePeerRecovery('offline-guest');
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(logCount(), initialWarnings + 2, 'a later outage is reported again');
assert.equal(
manager.entries().find((state) => state.target.profile.id === 'offline-guest')?.reconnect?.failures,
1,
);
});

test('marks a retrying Direct target unavailable on permanent failure', async () => {
const local = candidateHarness();
const permanent = new RuntimeHostPermanentReconnectError('credential rejected');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@
*/

import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, test } from "node:test";
import { createClientRuntimeHostProfileCatalog } from "@maka/runtime-host/client";
import { resolveDesktopRuntimeHostStartup } from "../runtime-host-profile-service.js";
import {
createDesktopRuntimeHostManagedServiceStore,
findDesktopRuntimeHostManagedServiceBinding,
Expand Down Expand Up @@ -251,3 +254,44 @@ test("persists a WSL deployment through its environment control route", async ()
);
await assert.rejects(store.save(profile, deployedService), /already bound/u);
});


test("waits for a live deployment writer and recovers after it is killed", async (t) => {
const root = await mkdtemp(join(tmpdir(), "maka-managed-deployment-lock-"));
roots.push(root);
const store = createDesktopRuntimeHostManagedServiceStore(root);
await store.save(profile, deployedService);
const before = await store.read();
const child = spawn(process.execPath, [
"--input-type=module",
"--eval",
[
`import { withProcessLifetimeFileUpdateLock } from ${JSON.stringify(import.meta.resolve("@maka/storage/process-lifetime-file-update-lock"))};`,
"await withProcessLifetimeFileUpdateLock(process.argv[1], async () => {",
" process.send('locked');",
" await new Promise(() => setInterval(() => {}, 1000));",
"});",
].join("\n"),
join(root, "runtime-host-deployments.json"),
], { stdio: ["ignore", "ignore", "inherit", "ipc"] });
const exited = once(child, "exit");
t.after(async () => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
await exited;
});
await Promise.race([
once(child, "message"),
exited.then(() => { throw new Error("Deployment writer exited before acquiring its lock"); }),
]);

// Startup may reclaim old directory markers, but cannot remove a current
// writer's marker or release its OS lease.
await resolveDesktopRuntimeHostStartup(root);
let settled = false;
const pending = store.read().finally(() => { settled = true; });
await new Promise((resolve) => setTimeout(resolve, 100));
assert.equal(settled, false);
child.kill("SIGKILL");
await exited;
assert.deepEqual(await pending, before);
});
152 changes: 152 additions & 0 deletions apps/desktop/src/main/__tests__/runtime-host-new-task-preload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/


import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { runInNewContext } from 'node:vm';
import test from 'node:test';
import { build } from 'esbuild';
import type { MakaBridge } from '../../preload/bridge-contract.js';

async function loadBridge(changeDuringRead: 'guest' | 'owner') {
const owner = {
hostId: 'local-host', targetEpoch: 'local-epoch', profileId: 'local',
profileName: 'Local', profileKind: 'local', profileAccess: 'owner', readiness: 'ready',
};
const guest = {
hostId: 'shared-host', targetEpoch: 'shared-epoch', profileId: 'shared',
profileName: 'Shared', profileKind: 'remote', profileAccess: 'session_guest',
readiness: 'reconnecting',
};
const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
const emit = (channel: string, value: unknown) => {
for (const listener of listeners.get(channel) ?? []) listener({}, value);
};
let catalogReads = 0;
let projectReads = 0;
const scopedCalls: string[] = [];
const ipcRenderer = {
on(channel: string, listener: (...args: unknown[]) => void) {
const handlers = listeners.get(channel) ?? new Set();
handlers.add(listener);
listeners.set(channel, handlers);
},
off(channel: string, listener: (...args: unknown[]) => void) {
listeners.get(channel)?.delete(listener);
},
send() {},
async invoke(channel: string, scope?: { hostId?: string }) {
if (scope?.hostId) {
scopedCalls.push(scope.hostId);
assert.equal(scope.hostId, owner.hostId, 'Guest reconnects must not redirect Owner reads');
}
switch (channel) {
case 'runtime-host:activeIdentity': return { ...owner };
case 'runtime-host:identities': return [{ ...owner }, { ...guest }];
case 'runtime-host-profiles:getSnapshot':
catalogReads++;
return {
defaultProfileId: 'local',
entries: [
{ profile: { id: 'local', name: 'Local', kind: 'local' },
hostId: owner.hostId, enabled: true, readiness: 'ready' },
{ profile: { id: 'shared', name: 'Shared', kind: 'remote', access: 'session_guest' },
hostId: guest.hostId, enabled: true, readiness: 'reconnecting' },
],
};
case 'projects:getSnapshot':
projectReads++;
if (changeDuringRead === 'guest') {
for (let index = 0; index < 20; index++) {
emit('runtime-host-profiles:changed', {
...guest, epoch: guest.targetEpoch, isDefault: false,
});
}
} else if (projectReads === 1) {
owner.targetEpoch = 'replacement-epoch';
emit('runtime-host-profiles:changed', {
...owner, epoch: owner.targetEpoch, isDefault: true,
});
}
return {
projects: [],
capabilities: { chooseClientDirectory: true, selectNoProject: true },
};
case 'app:info': return { projectId: null, projectGit: {} };
case 'settings:get': return { projects: {}, chatDefaults: {} };
case 'onboarding:getSnapshot': return {
state: { kind: 'ready_empty' }, milestones: [], sessions: [], connections: [],
defaultSlug: null, chatModelChoices: [], sessionSendOutcomes: {},
};
case 'session-local:catalog': return [{ scope: owner, sessions: [], authoritative: true }];
case 'session-collaboration:mount:list':
case 'sessions:list': return [];
default: throw new Error('Unexpected channel: ' + channel);
}
},
};
let bridge: MakaBridge | undefined;
const bundle = await build({
entryPoints: [fileURLToPath(new URL('../../../src/preload/preload.ts', import.meta.url))],
bundle: true, write: false, platform: 'node', format: 'cjs', external: ['electron'],
});
const require = createRequire(import.meta.url);
runInNewContext(bundle.outputFiles[0]!.text, {
require: (id: string) => id === 'electron' ? {
ipcRenderer,
contextBridge: { exposeInMainWorld: (name: string, value: MakaBridge) => {
if (name === 'maka') bridge = value;
} },
} : require(id),
process: { env: {} }, Buffer, console, setTimeout, clearTimeout, TextEncoder, TextDecoder,
crypto: globalThis.crypto,
});
assert.ok(bridge);
return { bridge, catalogReads: () => catalogReads, scopedCalls };
}

test('offline Guest notifications cannot starve the Local new-task catalog or redirect onboarding', async () => {
const { bridge, catalogReads, scopedCalls } = await loadBridge('guest');
let invalidations = 0;
const unsubscribe = bridge.newTasks.subscribeChanges(() => invalidations++);
try {
const catalog = await bridge.newTasks.getCatalog();
assert.equal(catalogReads(), 1, 'Guest state changes cannot invalidate an Owner catalog read');
assert.equal(invalidations, 0);
assert.equal(catalog.defaultProfileId, 'local');
assert.equal(catalog.hosts.length, 1);
assert.equal(catalog.hosts[0]?.profile.id, 'local');
assert.equal(catalog.hosts[0]?.readiness, 'ready');
const snapshot = await bridge.onboarding.getSnapshot();
assert.equal(snapshot.state.kind, 'ready_empty');
assert.ok(scopedCalls.length > 0);
assert.ok(scopedCalls.every(hostId => hostId === 'local-host'));
} finally {
unsubscribe();
}
});

test('an Owner replacement still invalidates the new-task catalog', async () => {
const { bridge, catalogReads } = await loadBridge('owner');
const catalog = await bridge.newTasks.getCatalog();
assert.equal(catalogReads(), 2);
assert.equal(catalog.hosts[0]?.readiness, 'ready');
});
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,48 @@ afterEach(async () => {
);
});

for (const hasDeployment of [false, true]) {
test(`recovers an abandoned deployment lock before loading Host choices (saved=${hasDeployment})`, async () => {
const root = await clientRoot();
const catalog = createClientRuntimeHostProfileCatalog(root);
const managedServices = createDesktopRuntimeHostManagedServiceStore(root);
if (hasDeployment) {
await catalog.create(MANAGED_PROFILE, "token");
await managedServices.save(MANAGED_PROFILE, MANAGED_SERVICE);
}
const before = await managedServices.read();
await mkdir(join(root, "runtime-host-deployments.json.lock"));

const startup = await resolveDesktopRuntimeHostStartup(root, { catalog });
const service = createDesktopRuntimeHostProfileService({
clientDataRoot: root,
startup,
catalog,
managedServices,
states: () => [ready({ profile: LOCAL_RUNTIME_HOST_PROFILE })],
enable: async () => undefined,
disable: async () => undefined,
setDefault: () => undefined,
finalizePairing: async () => undefined,
});
const snapshot = await service.getSnapshot();
assert.equal(snapshot.defaultProfileId, LOCAL_RUNTIME_HOST_PROFILE.id);
assert.equal(snapshot.entries[0]?.readiness, "ready");
assert.equal(snapshot.entries.length, hasDeployment ? 2 : 1);
assert.deepEqual(await managedServices.read(), before);
if (hasDeployment) assert.equal(snapshot.entries[1]?.managedService, true);
});
}

test("does not discard unexpected contents in an abandoned deployment lock", async () => {
const root = await clientRoot();
const lock = join(root, "runtime-host-deployments.json.lock");
await mkdir(lock);
await writeFile(join(lock, "unexpected"), "retain me");
await assert.rejects(resolveDesktopRuntimeHostStartup(root), { code: "ENOTEMPTY" });
assert.equal(await readFile(join(lock, "unexpected"), "utf8"), "retain me");
});

test("migrates the former selected Host into enabled and default preferences", async () => {
const root = await clientRoot();
await createClientRuntimeHostProfileCatalog(root).create(PROFILE, "token");
Expand Down
Loading
Loading