You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/19 19:58:23 UTC
(maka) 02/03: fix(desktop): scope plugin Remote leases to actual connections
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git
commit bab203ab20b40e57d25d31bd32f83f1f239856ee
Author: Wang <m4...@gmail.com>
AuthorDate: Sun Sep 20 03:58:15 2026 +0800
fix(desktop): scope plugin Remote leases to actual connections
Fence stale calls and accept retired-connection cleanup even when reconnecting to the same Host. Verify replacement with real Main ownership and Remote adapters.
Generated-by: OpenAI Codex
---
.../main/__tests__/client-plugin-remote.test.ts | 66 +++++++++++++++++++---
...workhub-coordination-transcript-preload.test.ts | 4 +-
apps/desktop/src/main/client-plugin-remote-ipc.ts | 11 +++-
apps/desktop/src/preload/bridge-contract.d.ts | 6 +-
apps/desktop/src/preload/preload.ts | 23 ++++----
.../platform/desktop/client-plugin-remote.ts | 4 +-
.../desktop/create-client-plugin-services.ts | 16 +++---
7 files changed, 93 insertions(+), 37 deletions(-)
diff --git a/apps/desktop/src/main/__tests__/client-plugin-remote.test.ts b/apps/desktop/src/main/__tests__/client-plugin-remote.test.ts
index ea84a75d04..374d8b4855 100644
--- a/apps/desktop/src/main/__tests__/client-plugin-remote.test.ts
+++ b/apps/desktop/src/main/__tests__/client-plugin-remote.test.ts
@@ -25,6 +25,7 @@ import test from 'node:test';
import type { IpcMainInvokeEvent, WebContents } from 'electron';
import type { IpcHandler } from '../ipc-reconnect-policy.js';
import { registerClientPluginRemoteIpc } from '../client-plugin-remote-ipc.js';
+import { clientPluginRemote } from '../../renderer/platform/desktop/client-plugin-remote.js';
function renderer() {
const emitter = Object.assign(new EventEmitter(), {
@@ -40,6 +41,54 @@ function deferred<T>() {
return { resolve, promise };
}
+test('reconnecting to the same Host revokes old Remote leases without fencing the replacement Client', async () => {
+ const owner = renderer();
+ const nonce = randomUUID();
+ const handlers = new Map<string, IpcHandler>();
+ const calls: unknown[] = [];
+ const closes: string[] = [];
+ const register = () => registerClientPluginRemoteIpc({
+ ipcMain: { handle: (name, handler) => { handlers.set(name, handler); } },
+ ownsRenderer: contents => contents === owner.emitter as unknown as WebContents,
+ report: assert.ifError,
+ client: { hostEpoch: 'same-host', async request(_operation, input) {
+ switch (input.kind) {
+ case 'open_document': return { kind: 'document', document: randomUUID() };
+ case 'bind': return { kind: 'bound', handler: 'method', target: {
+ entryId: 'backend', activation: randomUUID(), registration: randomUUID(),
+ } };
+ case 'call': calls.push(input.input); return { kind: 'value', value: input.input };
+ case 'close_document': closes.push(input.document); return { kind: 'closed' };
+ default: assert.fail('unexpected request');
+ }
+ } },
+ });
+ const identity = { entryId: 'view', extensionId: 'demo', activation: randomUUID(), contentDigest: 'sha256-' + 'a'.repeat(64), clientDigest: 'sha256-' + 'b'.repeat(64) };
+ const remote = (epoch: string) => clientPluginRemote(
+ (_host, expected, input) => handlers.get('plugins:remote')!(owner.event, nonce, expected, input),
+ { profileId: 'origin', hostId: 'host' }, epoch,
+ )(identity, new AbortController().signal);
+ let dispose = register();
+ try {
+ const before = await handlers.get('plugins:connection')!(owner.event, nonce);
+ const old = remote(before.epoch);
+ const call = old.api.method<string, string>('echo');
+ assert.equal(await call('before reconnect'), 'before reconnect');
+ await dispose();
+ dispose = register();
+ const after = await handlers.get('plugins:connection')!(owner.event, nonce);
+ assert.equal(before.hostEpoch, after.hostEpoch);
+ assert.notEqual(before.epoch, after.epoch);
+ await assert.rejects(call('never replay'));
+ await old.close();
+ const next = remote(after.epoch);
+ assert.equal(await next.api.method<string, string>('echo')('after reconnect'), 'after reconnect');
+ await next.close();
+ assert.deepEqual(calls, ['before reconnect', 'after reconnect']);
+ assert.equal(closes.length, 2);
+ } finally { await dispose(); }
+});
+
test('Remote documents belong to one Renderer and drain navigation, late opens and crashes without replay', async () => {
let handler!: IpcHandler;
let files!: IpcHandler;
@@ -63,11 +112,12 @@ test('Remote documents belong to one Renderer and drain navigation, late opens a
} },
});
const nonce = randomUUID();
- const invoke = (event: IpcMainInvokeEvent, input: unknown) => handler(event, nonce, input);
+ const { epoch, hostEpoch } = await connection(one.event, nonce);
+ const invoke = (event: IpcMainInvokeEvent, input: unknown) => handler(event, nonce, epoch, input);
try {
- assert.deepEqual(await connection(one.event, nonce), { hostEpoch: 'host-process' });
+ assert.equal(hostEpoch, 'host-process');
await assert.rejects(async () => connection({ ...one.event, senderFrame: two.event.senderFrame }, nonce), /live Desktop/);
- await assert.rejects(files(one.event, nonce, {}, {kind:'pick'}), /unavailable for this Host/);
+ await assert.rejects(files(one.event, nonce, epoch, {}, {kind:'pick'}), /unavailable for this Host/);
const opened = await invoke(one.event, { kind: 'open_document' });
await assert.rejects(invoke(two.event, { kind: 'next', document: opened.document, stream: randomUUID() }), /belong/);
await assert.rejects(invoke({ ...one.event, senderFrame: two.event.senderFrame }, { kind: 'open_document' }), /live Desktop/);
@@ -100,6 +150,7 @@ test('Remote documents belong to one Renderer and drain navigation, late opens a
test('local file actions require a published Client and reject a selection returned after navigation', {timeout: 1000}, async () => {
let files!: IpcHandler;
+ let connection!: IpcHandler;
const owner = renderer();
const selection = deferred<string | null>();
const picking = deferred<void>();
@@ -107,7 +158,7 @@ test('local file actions require a published Client and reject a selection retur
let published = true;
const identity = {entryId:'view', activation:randomUUID(), clientDigest:'sha256-'+'a'.repeat(64)};
const dispose = registerClientPluginRemoteIpc({
- ipcMain: {handle(channel, listener) { if (channel === 'plugins:files') files = listener; }},
+ ipcMain: {handle(channel, listener) { if (channel === 'plugins:files') files = listener; else if (channel === 'plugins:connection') connection = listener; }},
ownsRenderer: contents => contents === owner.emitter as unknown as WebContents,
report: assert.ifError,
client: {hostEpoch: 'host-process', async request() { throw new Error('Unexpected Remote command'); }},
@@ -121,14 +172,15 @@ test('local file actions require a published Client and reject a selection retur
},
});
const nonce = randomUUID();
+ const { epoch } = await connection(owner.event, nonce);
const path = resolve('SKILL.md');
try {
- await files(owner.event, nonce, identity, {kind:'open',path});
+ await files(owner.event, nonce, epoch, identity, {kind:'open',path});
published = false;
- await assert.rejects(files(owner.event, nonce, identity, {kind:'open',path}), /publication retired/);
+ await assert.rejects(files(owner.event, nonce, epoch, identity, {kind:'open',path}), /publication retired/);
assert.deepEqual(opened,[path]);
published = true;
- const rejected = assert.rejects(files(owner.event, nonce, identity, {kind:'pick'}), /retired during file selection/);
+ const rejected = assert.rejects(files(owner.event, nonce, epoch, identity, {kind:'pick'}), /retired during file selection/);
await picking.promise;
owner.emitter.emit('did-start-navigation', {}, 'new-page', false, true);
selection.resolve(path);
diff --git a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts
index 34622c67df..9b5301a4c3 100644
--- a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts
+++ b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts
@@ -68,7 +68,7 @@ test('WorkHub upload references round-trip through idle answers, both queue mode
if (channel === 'runtime-host:activeIdentity') return owner;
if (channel === 'runtime-host:identities') return [owner];
assert.equal((args[0] as typeof owner).hostId, owner.hostId);
- if (channel === 'plugins:connection') return { hostEpoch: 'host-process-epoch' };
+ if (channel === 'plugins:connection') return { epoch: 'remote-connection-epoch', hostEpoch: 'host-process-epoch' };
if (channel === 'attachments:prepare') {
assert.equal(args[1], nativeSessionId);
assert.deepEqual(structuredClone(args[2]), [{ name: 'brief.txt', mimeType: 'text/plain', base64: 'aGVsbG8=' }]);
@@ -91,7 +91,7 @@ test('WorkHub upload references round-trip through idle answers, both queue mode
});
const services = createDesktopWorkHubServices(bridge);
const connection = await bridge.clientPlugins.connection(owner);
- assert.equal(connection.epoch, owner.targetEpoch);
+ assert.equal(connection.epoch, 'remote-connection-epoch');
assert.equal(connection.hostEpoch, 'host-process-epoch');
const commands = coordinationCommands({
hostEpoch: connection.hostEpoch, signal: new AbortController().signal,
diff --git a/apps/desktop/src/main/client-plugin-remote-ipc.ts b/apps/desktop/src/main/client-plugin-remote-ipc.ts
index be2909c0b6..08dd0d0c9e 100644
--- a/apps/desktop/src/main/client-plugin-remote-ipc.ts
+++ b/apps/desktop/src/main/client-plugin-remote-ipc.ts
@@ -20,6 +20,7 @@
import { HOST_OPERATION_SPECS, type PluginRemoteInput, type PluginRemoteResult } from '@maka/runtime-host/protocol';
import type { IpcMain, IpcMainInvokeEvent, WebContents } from 'electron';
import { isAbsolute } from 'node:path';
+import { randomUUID } from 'node:crypto';
import type { PluginClientQueryInput } from '@maka/runtime-host/protocol';
interface DocumentOwner {
@@ -47,6 +48,8 @@ export function registerClientPluginRemoteIpc(input: {
};
}): () => Promise<void> {
const owners = new Map<WebContents, DocumentOwner>();
+ // A reconnect to the same Host still replaces its connection-owned handles.
+ const epoch = randomUUID();
const draining = new Set<Promise<unknown>>();
let closed = false;
const request = (value: PluginRemoteInput) => input.client.request('plugin.remote', value, 40_000);
@@ -95,12 +98,13 @@ export function registerClientPluginRemoteIpc(input: {
input.ipcMain.handle('plugins:connection', (event, nonce: unknown) => {
ownerFor(event, nonce);
- return { hostEpoch: input.client.hostEpoch };
+ return { epoch, hostEpoch: input.client.hostEpoch };
});
- input.ipcMain.handle('plugins:remote', async (event, nonce: unknown, raw: unknown): Promise<PluginRemoteResult> => {
+ input.ipcMain.handle('plugins:remote', async (event, nonce: unknown, expectedEpoch: unknown, raw: unknown): Promise<PluginRemoteResult | { kind: 'connection_retired' }> => {
const value = HOST_OPERATION_SPECS['plugin.remote'].decodeInput(raw);
const owner = ownerFor(event, nonce);
+ if (expectedEpoch !== epoch) return { kind: 'connection_retired' };
if (value.kind === 'open_document') {
if (owner.documents.size + owner.pendingOpens >= 32) throw new Error('Remote document limit exceeded');
owner.pendingOpens++;
@@ -124,8 +128,9 @@ export function registerClientPluginRemoteIpc(input: {
return track(owner.pending, request(value));
});
- input.ipcMain.handle('plugins:files', async (event, nonce: unknown, rawClient: unknown, raw: unknown) => {
+ input.ipcMain.handle('plugins:files', async (event, nonce: unknown, expectedEpoch: unknown, rawClient: unknown, raw: unknown) => {
const owner = ownerFor(event, nonce);
+ if (expectedEpoch !== epoch) throw new Error('Client connection has retired');
const files = input.files;
if (!files) throw new Error('Desktop-local files are unavailable for this Host');
if (!rawClient || typeof rawClient !== 'object' || !raw || typeof raw !== 'object')
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index 716aff6540..2115b23ce0 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -856,10 +856,10 @@ export interface MakaBridge {
};
clientPlugins: {
- file(host: DesktopRuntimeHostRef, targetEpoch: string, identity: ClientIdentity, input: ClientFileRequest): Promise<string | null>;
+ file(host: DesktopRuntimeHostRef, connectionEpoch: string, identity: ClientIdentity, input: ClientFileRequest): Promise<string | null>;
connection(host: DesktopRuntimeHostRef): Promise<{ epoch: string; hostEpoch: string; localFiles: boolean }>;
- session(host: DesktopRuntimeHostRef, targetEpoch: string, sessionId: string): Promise<string>;
- remote(host: DesktopRuntimeHostRef, targetEpoch: string, input: OperationInput<'plugin.remote'>): Promise<OperationOutput<'plugin.remote'> | { kind: 'connection_retired' }>;
+ session(host: DesktopRuntimeHostRef, connectionEpoch: string, sessionId: string): Promise<string>;
+ remote(host: DesktopRuntimeHostRef, connectionEpoch: string, input: OperationInput<'plugin.remote'>): Promise<OperationOutput<'plugin.remote'> | { kind: 'connection_retired' }>;
query(host: DesktopRuntimeHostRef, input: OperationInput<'plugin.client.query'>): Promise<OperationOutput<'plugin.client.query'>>;
subscribeChanges(host: DesktopRuntimeHostRef, handler: (revision: string) => void): () => void;
subscribeContext(host: DesktopRuntimeHostRef, handler: () => void): () => void;
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index c00e89354c..7747615536 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -1420,24 +1420,23 @@ const makaBridge = {
];
return () => { for (const dispose of disposers) dispose(); };
},
- async file(host, targetEpoch, identity, input) {
+ async file(host, connectionEpoch, identity, input) {
const scope = await runtimeHostScope(host);
- if (scope.targetEpoch !== targetEpoch) throw new Error('Client connection has retired');
- return ipcRenderer.invoke('plugins:files', scope, browserDocumentId, identity, input);
+ return ipcRenderer.invoke('plugins:files', scope, browserDocumentId, connectionEpoch, identity, input);
},
async connection(host) {
const scope = await runtimeHostScope(host);
- const connection = await ipcRenderer.invoke('plugins:connection', scope, browserDocumentId) as { hostEpoch: string };
- return { epoch: scope.targetEpoch, hostEpoch: connection.hostEpoch, localFiles: runtimeHostMetadataFor(scope)?.profileKind === 'local' };
+ const connection = await ipcRenderer.invoke('plugins:connection', scope, browserDocumentId) as { epoch: string; hostEpoch: string };
+ return { ...connection, localFiles: runtimeHostMetadataFor(scope)?.profileKind === 'local' };
},
- async session(host, targetEpoch, sessionId) {
+ async session(host, connectionEpoch, sessionId) {
const scope = await runtimeHostScope(host);
- if (scope.targetEpoch !== targetEpoch) throw new Error('Client connection has retired');
+ const connection = await ipcRenderer.invoke('plugins:connection', scope, browserDocumentId) as { epoch: string };
+ if (connection.epoch !== connectionEpoch) throw new Error('Client connection has retired');
return recordRuntimeHostSessionScope(scope, sessionId);
},
- async remote(host, targetEpoch, input) {
+ async remote(host, connectionEpoch, input) {
const scope = await runtimeHostScope(host);
- if (scope.targetEpoch !== targetEpoch) return { kind: 'connection_retired' };
let value = HOST_OPERATION_SPECS['plugin.remote'].decodeInput(input);
if ('binding' in value && value.binding.sessionId !== null) {
const session = await runtimeHostSessionRef(value.binding.sessionId);
@@ -1445,9 +1444,9 @@ const makaBridge = {
throw new Error('Remote Session belongs to another Host');
value = { ...value, binding: { ...value.binding, sessionId: session.sessionId } };
}
- return HOST_OPERATION_SPECS['plugin.remote'].decodeOutput(
- await ipcRenderer.invoke('plugins:remote', scope, browserDocumentId, value),
- );
+ const result = await ipcRenderer.invoke('plugins:remote', scope, browserDocumentId, connectionEpoch, value);
+ if (result?.kind === 'connection_retired') return { kind: 'connection_retired' };
+ return HOST_OPERATION_SPECS['plugin.remote'].decodeOutput(result);
},
async query(host, input) {
const scope = await runtimeHostScope(host);
diff --git a/apps/desktop/src/renderer/platform/desktop/client-plugin-remote.ts b/apps/desktop/src/renderer/platform/desktop/client-plugin-remote.ts
index d81555c630..801e6b69c0 100644
--- a/apps/desktop/src/renderer/platform/desktop/client-plugin-remote.ts
+++ b/apps/desktop/src/renderer/platform/desktop/client-plugin-remote.ts
@@ -28,12 +28,12 @@ import type { ClientHostRef } from '../../features/client-plugins/index.js';
export function clientPluginRemote(
transport: MakaBridge['clientPlugins']['remote'],
host: ClientHostRef,
- targetEpoch: string,
+ connectionEpoch: string,
): ClientRemoteFactory {
return (identity: ClientIdentity, signal: AbortSignal) => {
let document: Promise<string> | undefined;
let closing: Promise<void> | undefined;
- const request = (input: Parameters<typeof transport>[2]) => transport(host, targetEpoch, input);
+ const request = (input: Parameters<typeof transport>[2]) => transport(host, connectionEpoch, input);
const assertLive = () => {
signal.throwIfAborted();
if (closing) throw new Error('Client Remote is closed');
diff --git a/apps/desktop/src/renderer/platform/desktop/create-client-plugin-services.ts b/apps/desktop/src/renderer/platform/desktop/create-client-plugin-services.ts
index ad9b255ddc..26b5dc1dd8 100644
--- a/apps/desktop/src/renderer/platform/desktop/create-client-plugin-services.ts
+++ b/apps/desktop/src/renderer/platform/desktop/create-client-plugin-services.ts
@@ -43,22 +43,22 @@ export function createDesktopClientPluginServices(
},
connect(host) {
const query: typeof bridge.clientPlugins.query = async (origin, input) => bridge.clientPlugins.query(origin, input);
- let targetEpoch: string | undefined;
+ let connectionEpoch: string | undefined;
let localFiles = false;
return {
subscribeContext: (listener) => bridge.clientPlugins.subscribeContext(host, listener),
async session(sessionId) {
- if (!targetEpoch) throw new Error('Client catalog has no connection identity');
- return bridge.clientPlugins.session(host, targetEpoch, sessionId);
+ if (!connectionEpoch) throw new Error('Client catalog has no connection identity');
+ return bridge.clientPlugins.session(host, connectionEpoch, sessionId);
},
remote(identity, signal) {
- if (!targetEpoch) throw new Error('Client catalog has no connection identity');
- return clientPluginRemote(bridge.clientPlugins.remote, host, targetEpoch)(identity, signal);
+ if (!connectionEpoch) throw new Error('Client catalog has no connection identity');
+ return clientPluginRemote(bridge.clientPlugins.remote, host, connectionEpoch)(identity, signal);
},
localFiles(identity, signal) {
- if (!targetEpoch) throw new Error('Client catalog has no connection identity');
+ if (!connectionEpoch) throw new Error('Client catalog has no connection identity');
if (!localFiles) return undefined;
- const epoch = targetEpoch;
+ const epoch = connectionEpoch;
return {
pick: () => bounded(bridge.clientPlugins.file(host, epoch, identity, {kind:'pick'}), signal),
async open(path) {
@@ -89,7 +89,7 @@ export function createDesktopClientPluginServices(
const current = await bounded(bridge.clientPlugins.connection(host), signal);
if (epoch !== current.epoch || connection.hostEpoch !== current.hostEpoch)
throw new Error('Client connection changed during snapshot');
- targetEpoch = epoch;
+ connectionEpoch = epoch;
localFiles = connection.localFiles;
return { revision, connection: epoch, hostEpoch: connection.hostEpoch, entries };
},
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org