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