Skip to content

Commit

Permalink
chore: multi-channel rpc
Browse files Browse the repository at this point in the history
Multi-channel rpc, matching AI Lab. This doesn't give us a direct benefit by
itself, but would allow us to start splitting the 29 functions in the current API
up better / allow for growth, and alignment with AI Lab will make it easier to
share future improvements/debugging.

Fixes podman-desktop#1265.

Signed-off-by: Tim deBoer <[email protected]>
  • Loading branch information
deboer-tim committed Feb 7, 2025
1 parent 5bf07ee commit b57d199
Show file tree
Hide file tree
Showing 8 changed files with 318 additions and 28 deletions.
1 change: 1 addition & 0 deletions packages/backend/src/api-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import examplesCatalog from '../assets/examples.json';
import type { ExamplesList } from '/@shared/src/models/examples';

export class BootcApiImpl implements BootcApi {
static readonly CHANNEL: string = 'BootcApi';
private history: History;
private webview: podmanDesktopApi.Webview;

Expand Down
4 changes: 2 additions & 2 deletions packages/frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import type { BootcApi } from '/@shared/src/BootcAPI';
import { BootcApi } from '/@shared/src/BootcAPI';
import { RpcBrowser } from '/@shared/src/messages/MessageProxy';

export interface RouterState {
Expand All @@ -25,7 +25,7 @@ export interface RouterState {

const podmanDesktopApi = acquirePodmanDesktopApi();
export const rpcBrowser: RpcBrowser = new RpcBrowser(window, podmanDesktopApi);
export const bootcClient: BootcApi = rpcBrowser.getProxy<BootcApi>();
export const bootcClient: BootcApi = rpcBrowser.getProxy<BootcApi>(BootcApi);

export const saveRouterState = (state: RouterState): void => {
podmanDesktopApi.setState(state);
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/src/stores/rpcReadable.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ vi.mock('/@/api/client', async () => {
postMessage: (message: unknown) => {
if (message && typeof message === 'object' && 'channel' in message) {
const f = rpcBrowser.subscribers.get(message.channel as string);
f?.('');
f?.forEach(listener => listener(''));
}
},
} as unknown as PodmanDesktopApi;
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/BootcAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { ImageInfo, ImageInspectInfo, ManifestInspectInfo } from '@podman-d
import type { ExamplesList } from './models/examples';

export abstract class BootcApi {
static readonly CHANNEL: string = 'BootcApi';
abstract checkPrereqs(): Promise<string | undefined>;
abstract checkVMLaunchPrereqs(build: BootcBuildInfo): Promise<string | undefined>;
abstract launchVM(build: BootcBuildInfo): Promise<void>;
Expand Down
252 changes: 248 additions & 4 deletions packages/shared/src/messages/MessageProxy.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
* Copyright (C) 2024-2025 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -16,15 +16,21 @@
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import { test, expect, beforeAll } from 'vitest';
import { test, expect, vi, describe, beforeEach, afterEach } from 'vitest';
import { RpcBrowser, RpcExtension } from './MessageProxy';
import type { Webview } from '@podman-desktop/api';
import * as defaultNoTimeoutChannels from './NoTimeoutChannels';
import { getChannel } from './utils';

let webview: Webview;
let window: Window;
let api: PodmanDesktopApi;

beforeAll(() => {
vi.mock('./NoTimeoutChannels', async () => ({
noTimeoutChannels: [],
}));

beforeEach(() => {
let windowListener: (message: unknown) => void;
let webviewListener: (message: unknown) => void;

Expand All @@ -51,8 +57,17 @@ beforeAll(() => {
} as unknown as PodmanDesktopApi;
});

test('init logic should be executing once', () => {
vi.spyOn(webview, 'onDidReceiveMessage');
const rpcExtension = new RpcExtension(webview);
rpcExtension.init();

expect(webview.onDidReceiveMessage).toHaveBeenCalledOnce();
});

test('Test register channel no argument', async () => {
const rpcExtension = new RpcExtension(webview);
rpcExtension.init();
const rpcBrowser = new RpcBrowser(window, api);

rpcExtension.register('ping', () => {
Expand All @@ -64,6 +79,7 @@ test('Test register channel no argument', async () => {

test('Test register channel one argument', async () => {
const rpcExtension = new RpcExtension(webview);
rpcExtension.init();
const rpcBrowser = new RpcBrowser(window, api);

rpcExtension.register('double', (value: number) => {
Expand All @@ -75,6 +91,7 @@ test('Test register channel one argument', async () => {

test('Test register channel multiple arguments', async () => {
const rpcExtension = new RpcExtension(webview);
rpcExtension.init();
const rpcBrowser = new RpcBrowser(window, api);

rpcExtension.register('sum', (...args: number[]) => {
Expand All @@ -86,22 +103,69 @@ test('Test register channel multiple arguments', async () => {

test('Test register instance with async', async () => {
class Dummy {
static readonly CHANNEL: string = 'dummy';
async ping(): Promise<string> {
return 'pong';
}
}

const rpcExtension = new RpcExtension(webview);
rpcExtension.init();
const rpcBrowser = new RpcBrowser(window, api);

rpcExtension.registerInstance(Dummy, new Dummy());

const proxy = rpcBrowser.getProxy<Dummy>();
const proxy = rpcBrowser.getProxy<Dummy>(Dummy);
expect(await proxy.ping()).toBe('pong');
});

test('Test register instance and implemented abstract classes', async () => {
abstract class Foo {
static readonly CHANNEL: string = 'dummy';
abstract ping(): Promise<'pong'>;
}

class Dummy implements Foo {
async ping(): Promise<'pong'> {
return 'pong';
}
}

const rpcExtension = new RpcExtension(webview);
rpcExtension.init();
const rpcBrowser = new RpcBrowser(window, api);

rpcExtension.registerInstance(Foo, new Dummy());

const proxy = rpcBrowser.getProxy<Foo>(Foo);
expect(await proxy.ping()).toBe('pong');
});

test('Test register instance and extended abstract classes', async () => {
abstract class Foo {
static readonly CHANNEL: string = 'dummy';
abstract ping(): Promise<'pong'>;
}

class Dummy extends Foo {
override async ping(): Promise<'pong'> {
return 'pong';
}
}

const rpcExtension = new RpcExtension(webview);
rpcExtension.init();
const rpcBrowser = new RpcBrowser(window, api);

rpcExtension.registerInstance(Foo, new Dummy());

const proxy = rpcBrowser.getProxy<Foo>(Foo);
expect(await proxy.ping()).toBe('pong');
});

test('Test raising exception', async () => {
const rpcExtension = new RpcExtension(webview);
rpcExtension.init();
const rpcBrowser = new RpcBrowser(window, api);

rpcExtension.register('raiseError', () => {
Expand All @@ -110,3 +174,183 @@ test('Test raising exception', async () => {

await expect(rpcBrowser.invoke('raiseError')).rejects.toThrow('big error');
});

test('getChannel should use CHANNEL property of classType provided', () => {
class Dummy {
static readonly CHANNEL: string = 'dummy';
async ping(): Promise<'pong'> {
return new Promise(vi.fn());
}
}

const channel = getChannel(Dummy, 'ping');
expect(channel).toBe('dummy-ping');
});

describe('subscribe', () => {
beforeEach(() => {
window.addEventListener = vi.fn();

(defaultNoTimeoutChannels.noTimeoutChannels as string[]) = [];
});

function getMessageListener(): (event: MessageEvent) => void {
expect(window.addEventListener).toHaveBeenCalledOnce();
expect(window.addEventListener).toHaveBeenCalledWith('message', expect.any(Function));
return vi.mocked(window.addEventListener).mock.calls[0][1] as (event: MessageEvent) => void;
}

test('subscriber should be called on event received', async () => {
const rpcBrowser = new RpcBrowser(window, api);
const messageListener = getMessageListener();

const listener = vi.fn();
rpcBrowser.subscribe('example', listener);

messageListener({
data: {
id: 'example',
body: 'hello',
},
} as unknown as MessageEvent);

expect(listener).toHaveBeenCalledOnce();
});

test('all subscribers should be called if multiple exists', async () => {
const rpcBrowser = new RpcBrowser(window, api);
const messageListener = getMessageListener();

const listeners = Array.from({ length: 10 }, _ => vi.fn());

listeners.forEach(listener => rpcBrowser.subscribe('example', listener));

messageListener({
data: {
id: 'example',
body: 'hello',
},
} as unknown as MessageEvent);

for (const listener of listeners) {
expect(listener).toHaveBeenCalledWith('hello');
}
});

test('subscribers which unsubscribe should not be called', async () => {
const rpcBrowser = new RpcBrowser(window, api);
const messageListener = getMessageListener();

const [listenerA, listenerB] = [vi.fn(), vi.fn()];

const unsubscriberA = rpcBrowser.subscribe('example', listenerA);
const unsubscriberB = rpcBrowser.subscribe('example', listenerB);

messageListener({
data: {
id: 'example',
body: 'hello',
},
} as unknown as MessageEvent);

// unsubscriber the listener B
unsubscriberB.unsubscribe();

messageListener({
data: {
id: 'example',
body: 'hello',
},
} as unknown as MessageEvent);

// unsubscriber the listener A
unsubscriberA.unsubscribe();

messageListener({
data: {
id: 'example',
body: 'hello',
},
} as unknown as MessageEvent);

expect(listenerA).toHaveBeenCalledTimes(2);
expect(listenerB).toHaveBeenCalledOnce();
});
});

describe('no timeout channel', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.useFakeTimers();

(defaultNoTimeoutChannels.noTimeoutChannels as string[]) = [];
});

afterEach(() => {
vi.restoreAllMocks();
});

test('default function should have a timeout', async () => {
class Dummy {
static readonly CHANNEL: string = 'dummy';
async ping(): Promise<'pong'> {
return new Promise(vi.fn());
}
}

const rpcExtension = new RpcExtension(webview);
rpcExtension.init();
const rpcBrowser = new RpcBrowser(window, api);

rpcExtension.registerInstance(Dummy, new Dummy());

const proxy = rpcBrowser.getProxy<Dummy>(Dummy);

let error: Error | undefined;
proxy.ping().catch((err: unknown) => {
error = err as Error;
});

await vi.advanceTimersByTimeAsync(5_000);
expect(error?.message).toBe('Timeout');
});

test('noTimeoutChannels should not have a timeout', async () => {
class Dummy {
static readonly CHANNEL: string = 'dummy';
async ping(): Promise<'pong'> {
return new Promise(resolve => {
setTimeout(resolve.bind(undefined, 'pong'), 8_000);
});
}
}

// fake the noTimeoutChannels
(defaultNoTimeoutChannels.noTimeoutChannels as string[]) = [`${Dummy.CHANNEL}-ping`];

const rpcExtension = new RpcExtension(webview);
rpcExtension.init();
const rpcBrowser = new RpcBrowser(window, api);

rpcExtension.registerInstance(Dummy, new Dummy());

const proxy = rpcBrowser.getProxy<Dummy>(Dummy);

let error: Error | undefined;
let result: 'pong' | undefined;
proxy
.ping()
.then(mResult => {
result = mResult;
})
.catch((err: unknown) => {
error = err as Error;
});

await vi.advanceTimersByTimeAsync(5_000);
expect(error).toBeUndefined();
await vi.advanceTimersByTimeAsync(5_000);
expect(error).toBeUndefined();
expect(result).toBe('pong');
});
});
Loading

0 comments on commit b57d199

Please sign in to comment.