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:08:35 UTC

(maka) branch feat/runtime-host-rust updated: fix(plugins): distinguish Host epochs from Desktop target identity

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


The following commit(s) were added to refs/heads/feat/runtime-host-rust by this push:
     new eeaf468dc6 fix(plugins): distinguish Host epochs from Desktop target identity
eeaf468dc6 is described below

commit eeaf468dc60c5fd6d6e8dd34cb8399473af3f541
Author: Wang <m4...@gmail.com>
AuthorDate: Sun Sep 20 03:08:28 2026 +0800

    fix(plugins): distinguish Host epochs from Desktop target identity
    
    Read the process epoch from the originating Main connection, revalidate it across catalog reads, and retire Client instances on Host restart.
    
    Generated-by: OpenAI Codex
---
 .../desktop/src/main/__tests__/client-plugin-remote.test.ts |  9 ++++++---
 .../workhub-coordination-transcript-preload.test.ts         |  9 +++++++--
 apps/desktop/src/main/client-plugin-remote-ipc.ts           |  6 ++++++
 apps/desktop/src/preload/bridge-contract.d.ts               |  2 +-
 apps/desktop/src/preload/preload.ts                         |  3 ++-
 .../platform/desktop/create-client-plugin-services.ts       |  5 +++--
 packages/ui/src/client-plugins/runtime.ts                   | 10 +++++++---
 packages/ui/tests/client-plugins.test.mjs                   | 13 ++++++++++---
 8 files changed, 42 insertions(+), 15 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 0cb0581b9f..ea84a75d04 100644
--- a/apps/desktop/src/main/__tests__/client-plugin-remote.test.ts
+++ b/apps/desktop/src/main/__tests__/client-plugin-remote.test.ts
@@ -43,6 +43,7 @@ function deferred<T>() {
 test('Remote documents belong to one Renderer and drain navigation, late opens and crashes without replay', async () => {
   let handler!: IpcHandler;
   let files!: IpcHandler;
+  let connection!: IpcHandler;
   const one = renderer();
   const two = renderer();
   const allowed = new Set([one.emitter, two.emitter]);
@@ -51,10 +52,10 @@ test('Remote documents belong to one Renderer and drain navigation, late opens a
   let late: ReturnType<typeof deferred<string>> | undefined;
   let attempts = 0;
   const dispose = registerClientPluginRemoteIpc({
-    ipcMain: { handle: (channel, listener) => { if (channel === 'plugins:remote') handler = listener; else files = listener; } },
+    ipcMain: { handle: (channel, listener) => { if (channel === 'plugins:remote') handler = listener; else if (channel === 'plugins:files') files = listener; else connection = listener; } },
     ownsRenderer: (contents: WebContents) => allowed.has(contents as unknown as typeof one.emitter),
     report: (error) => errors.push(error),
-    client: { async request(_operation, input) {
+    client: { hostEpoch: 'host-process', async request(_operation, input) {
       if (input.kind === 'open_document') return { kind: 'document', document: late ? await late.promise : randomUUID() };
       if (input.kind === 'close_document') { closed.push(input.document); return { kind: 'closed' }; }
       attempts++;
@@ -64,6 +65,8 @@ 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);
   try {
+    assert.deepEqual(await connection(one.event, nonce), { 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/);
     const opened = await invoke(one.event, { kind: 'open_document' });
     await assert.rejects(invoke(two.event, { kind: 'next', document: opened.document, stream: randomUUID() }), /belong/);
@@ -107,7 +110,7 @@ test('local file actions require a published Client and reject a selection retur
     ipcMain: {handle(channel, listener) { if (channel === 'plugins:files') files = listener; }},
     ownsRenderer: contents => contents === owner.emitter as unknown as WebContents,
     report: assert.ifError,
-    client: {async request() { throw new Error('Unexpected Remote command'); }},
+    client: {hostEpoch: 'host-process', async request() { throw new Error('Unexpected Remote command'); }},
     files: {
       async validate(input) {
         assert.equal(input.kind, 'bundle');
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 d549345647..aec892a0d4 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,6 +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 === 'workhub:prepareAttachments') {
             assert.deepEqual(structuredClone(args[1]), [{ name: 'brief.txt', mimeType: 'text/plain', base64: 'aGVsbG8=' }]);
             return preparationResult;
@@ -88,9 +89,13 @@ test('WorkHub upload references round-trip through idle answers, both queue mode
     else Reflect.deleteProperty(globalThis, 'window');
   });
   const services = createDesktopWorkHubServices(bridge);
+  const connection = await bridge.clientPlugins.connection(owner);
+  assert.equal(connection.epoch, owner.targetEpoch);
+  assert.equal(connection.hostEpoch, 'host-process-epoch');
   const commands = coordinationCommands({
-    hostEpoch: 'epoch', signal: new AbortController().signal,
-    remote: { method: (name: string) => async (input: { turnId?: string; attachments?: AttachmentRef[]; placement?: string; content?: { attachments: AttachmentRef[] } }) => {
+    hostEpoch: connection.hostEpoch, signal: new AbortController().signal,
+    remote: { method: (name: string) => async (input: { originHostEpoch?: string; turnId?: string; attachments?: AttachmentRef[]; placement?: string; content?: { attachments: AttachmentRef[] } }) => {
+      if (name === 'enqueue') assert.equal(input.originHostEpoch, 'host-process-epoch');
       sent.push({ channel: name, attachments: input.attachments ?? input.content!.attachments });
       return { ok: true, result: name === 'answer' ? { turnId: input.turnId } : { disposition: input.placement === 'current_turn' ? 'steering' : 'followup' } };
     } } as unknown as Parameters<typeof coordinationCommands>[0]['remote'],
diff --git a/apps/desktop/src/main/client-plugin-remote-ipc.ts b/apps/desktop/src/main/client-plugin-remote-ipc.ts
index bb079bf64e..be2909c0b6 100644
--- a/apps/desktop/src/main/client-plugin-remote-ipc.ts
+++ b/apps/desktop/src/main/client-plugin-remote-ipc.ts
@@ -35,6 +35,7 @@ interface DocumentOwner {
 export function registerClientPluginRemoteIpc(input: {
   readonly ipcMain: Pick<IpcMain, 'handle'>;
   readonly client: {
+    readonly hostEpoch: string;
     request(operation: 'plugin.remote', input: PluginRemoteInput, timeoutMs?: number): Promise<PluginRemoteResult>;
   };
   readonly ownsRenderer: (contents: WebContents) => boolean;
@@ -92,6 +93,11 @@ export function registerClientPluginRemoteIpc(input: {
     return state;
   };
 
+  input.ipcMain.handle('plugins:connection', (event, nonce: unknown) => {
+    ownerFor(event, nonce);
+    return { hostEpoch: input.client.hostEpoch };
+  });
+
   input.ipcMain.handle('plugins:remote', async (event, nonce: unknown, raw: unknown): Promise<PluginRemoteResult> => {
     const value = HOST_OPERATION_SPECS['plugin.remote'].decodeInput(raw);
     const owner = ownerFor(event, nonce);
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index caca42784c..496b52f672 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -857,7 +857,7 @@ export interface MakaBridge {
 
   clientPlugins: {
     file(host: DesktopRuntimeHostRef, targetEpoch: string, identity: ClientIdentity, input: ClientFileRequest): Promise<string | null>;
-    connection(host: DesktopRuntimeHostRef): Promise<{ epoch: string; localFiles: boolean }>;
+    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' }>;
     query(host: DesktopRuntimeHostRef, input: OperationInput<'plugin.client.query'>): Promise<OperationOutput<'plugin.client.query'>>;
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index b1e3a2745e..d271cd0d3a 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -1430,7 +1430,8 @@ const makaBridge = {
     },
     async connection(host) {
       const scope = await runtimeHostScope(host);
-      return { epoch: scope.targetEpoch, localFiles: runtimeHostMetadataFor(scope)?.profileKind === 'local' };
+      const connection = await ipcRenderer.invoke('plugins:connection', scope, browserDocumentId) as { hostEpoch: string };
+      return { epoch: scope.targetEpoch, hostEpoch: connection.hostEpoch, localFiles: runtimeHostMetadataFor(scope)?.profileKind === 'local' };
     },
     async session(host, targetEpoch, sessionId) {
       const scope = await runtimeHostScope(host);
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 7bf5768cb4..ad9b255ddc 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
@@ -86,11 +86,12 @@ export function createDesktopClientPluginServices(
             cursor = page.nextCursor;
           } while (cursor);
           if (revision === undefined) throw new Error('Client snapshot has no revision');
-          if (epoch !== (await bounded(bridge.clientPlugins.connection(host), signal)).epoch)
+          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;
           localFiles = connection.localFiles;
-          return { revision, connection: epoch, entries };
+          return { revision, connection: epoch, hostEpoch: connection.hostEpoch, entries };
         },
         async source(descriptor, signal) {
           const parts: string[] = [];
diff --git a/packages/ui/src/client-plugins/runtime.ts b/packages/ui/src/client-plugins/runtime.ts
index 7d9ae80608..b1fff2188a 100644
--- a/packages/ui/src/client-plugins/runtime.ts
+++ b/packages/ui/src/client-plugins/runtime.ts
@@ -31,6 +31,8 @@ export interface ClientSnapshot {
   readonly revision: string;
   /** New transport identity revokes handles even when package bytes did not change. */
   readonly connection?: string;
+  /** Host process epoch, distinct from the Client's transport/target identity. */
+  readonly hostEpoch?: string;
   readonly entries: readonly ClientDescriptor[];
 }
 export interface ClientDiagnostic { readonly identity?: ClientIdentity; readonly error: unknown }
@@ -52,6 +54,7 @@ export class ClientRuntime {
   #active: ClientInstance[] = [];
   #revision?: string;
   #connection?: string;
+  #hostEpoch?: string;
   #pending?: AbortController;
   #work: Promise<void> = Promise.resolve();
   #closed = false;
@@ -69,7 +72,7 @@ export class ClientRuntime {
     this.#pending = request;
     this.#work = this.#work.catch(() => {}).then(async () => {
       request.signal.throwIfAborted();
-      if (this.#revision === snapshot.revision && this.#connection === snapshot.connection) return;
+      if (this.#revision === snapshot.revision && this.#connection === snapshot.connection && this.#hostEpoch === snapshot.hostEpoch) return;
       this.#revision = undefined;
       const timeout = setTimeout(() => request.abort(new Error('Client initialization timed out')), 30_000);
       try { await this.#replace(snapshot, request.signal); }
@@ -96,10 +99,11 @@ export class ClientRuntime {
     const staged: ClientInstance[] = [];
     // Withdraw stale UI before any asynchronous loading, including failed candidates.
     const changed = changedPackages(this.#active.map((instance) => instance.descriptor), snapshot.entries);
-    if (snapshot.connection !== this.#connection) {
+    if (snapshot.connection !== this.#connection || snapshot.hostEpoch !== this.#hostEpoch) {
       for (const instance of this.#active) changed.add(instance.descriptor.extensionId);
     }
     this.#connection = snapshot.connection;
+    this.#hostEpoch = snapshot.hostEpoch;
     const stale = this.#active.filter((instance) => changed.has(instance.descriptor.extensionId));
     this.#active = this.#active.filter((instance) => !changed.has(instance.descriptor.extensionId));
     for (const id of changed) this.#modules.delete(id);
@@ -145,7 +149,7 @@ export class ClientRuntime {
         signal.throwIfAborted();
         if (this.#active.some((instance) => instance.descriptor.entryId === descriptor.entryId)) continue;
         if (this.#fenced.has(descriptor.entryId)) throw new Error('Client cleanup unconfirmed; reload the document');
-        const instance = new ClientInstance(descriptor, (error) => this.#options.report({ identity: descriptor, error }), this.#options.remote, this.#options.localFiles, snapshot.connection);
+        const instance = new ClientInstance(descriptor, (error) => this.#options.report({ identity: descriptor, error }), this.#options.remote, this.#options.localFiles, snapshot.hostEpoch);
         staged.push(instance);
         await interruptible(instance.initialize(materialize(descriptor.extensionId).default, this.#options.document), signal);
       }
diff --git a/packages/ui/tests/client-plugins.test.mjs b/packages/ui/tests/client-plugins.test.mjs
index 61d86d1a86..833c96edac 100644
--- a/packages/ui/tests/client-plugins.test.mjs
+++ b/packages/ui/tests/client-plugins.test.mjs
@@ -201,7 +201,7 @@ test('unrelated catalog changes preserve UI state; dependency replacement and co
     const generation=fixture.generations[${JSON.stringify(id)}]=(fixture.generations[${JSON.stringify(id)}]||0)+1;
     const dependency=${dependency ? `require(${JSON.stringify(dependency)}).generation` : 'null'};
     return {generation,default:{activate(ctx){
-      fixture.observed[${JSON.stringify(id)}]={generation,dependency,signal:ctx.signal};
+      fixture.observed[${JSON.stringify(id)}]={generation,dependency,signal:ctx.signal,hostEpoch:ctx.hostEpoch};
       ctx.slots.register('session.composer.before','panel',()=>null);
       ctx.style('.owned-by-${id} {}');
     }}};
@@ -219,9 +219,10 @@ test('unrelated catalog changes preserve UI state; dependency replacement and co
     document, modules: { fixture: { generations, observed } }, report() {},
     source: async (descriptor) => sources.get(descriptor.extensionId),
   });
-  const snapshot = { revision: 'initial', connection: 'connection-one', entries };
+  const snapshot = { revision: 'initial', connection: 'connection-one', hostEpoch: 'host-one', entries };
   try {
     await runtime.reconcile(snapshot);
+    assert.equal(observed.base.hostEpoch, 'host-one');
     const independent = runtime.slots.snapshot().find((entry) => entry.owner.extensionId === 'independent');
     const originalBase = observed.base;
     await runtime.reconcile({ ...snapshot, revision: 'unrelated-control-change' });
@@ -246,9 +247,15 @@ test('unrelated catalog changes preserve UI state; dependency replacement and co
     assert.equal(observed.dependent.dependency, 3, 'dependent must import the replacement module');
     assert.equal(runtime.slots.snapshot().find((entry) => entry.owner.extensionId === 'independent'), independent);
     const beforeReconnect = observed.independent;
-    await runtime.reconcile({ revision: 'updated', connection: 'connection-two', entries: replaced });
+    const reconnected = { revision: 'updated', connection: 'connection-two', hostEpoch: 'host-one', entries: replaced };
+    await runtime.reconcile(reconnected);
     assert.equal(beforeReconnect.signal.aborted, true);
     assert.deepEqual(generations, { base: 4, dependent: 4, independent: 2 });
+    const beforeRestart = observed.independent;
+    await runtime.reconcile({ ...reconnected, hostEpoch: 'host-two' });
+    assert.equal(beforeRestart.signal.aborted, true);
+    assert.equal(observed.independent.hostEpoch, 'host-two');
+    assert.deepEqual(generations, { base: 5, dependent: 5, independent: 3 });
     assert.equal(document.querySelectorAll('style').length, 3);
   } finally { await runtime.close(); }
   assert.equal(document.querySelectorAll('style,script').length, 0);


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org