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