From bb1ffdea25cb18bdad4ebd13007c45af869f3be4 Mon Sep 17 00:00:00 2001 From: Koushik Dutta Date: Fri, 10 Dec 2021 11:39:06 -0800 Subject: [PATCH] server: mixin table refactor --- server/src/plugin/plugin-device.ts | 188 ++++++++++++++++----------- server/src/plugin/plugin-host-api.ts | 40 ++++-- server/src/plugin/plugin-host.ts | 3 + server/src/runtime.ts | 25 +++- server/src/services/plugin.ts | 2 +- 5 files changed, 172 insertions(+), 86 deletions(-) diff --git a/server/src/plugin/plugin-device.ts b/server/src/plugin/plugin-device.ts index 949c7f80c..83cf6aa4b 100644 --- a/server/src/plugin/plugin-device.ts +++ b/server/src/plugin/plugin-device.ts @@ -18,8 +18,12 @@ interface MixinTableEntry { allInterfaces: string[]; proxy: any; error?: Error; + passthrough: boolean; } +export const RefreshSymbol = Symbol('ScryptedDeviceRefresh'); +export const QueryInterfaceSymbol = Symbol("ScryptedPluginDeviceQueryInterface"); + export class PluginDeviceProxyHandler implements ProxyHandler, ScryptedDevice { scrypted: ScryptedRuntime; id: string; @@ -49,7 +53,7 @@ export class PluginDeviceProxyHandler implements ProxyHandler, ScryptedDevi } } - invalidateMixinTable() { + rebuildMixinTable() { if (!this.mixinTable) return this.invalidate(); @@ -88,6 +92,7 @@ export class PluginDeviceProxyHandler implements ProxyHandler, ScryptedDevi break; this.mixinTable.shift(); this.invalidateEntry(entry); + console.log('invalidating mixin', entry.mixinProviderId); } this.ensureProxy(lastValidMixinId); @@ -99,14 +104,13 @@ export class PluginDeviceProxyHandler implements ProxyHandler, ScryptedDevi if (!pluginDevice) throw new PluginError(`device ${this.id} does not exist`); - let previousEntry: Promise; if (!lastValidMixinId) { if (this.mixinTable) return Promise.resolve(pluginDevice); this.mixinTable = []; - previousEntry = (async () => { + const entry = (async () => { let proxy; try { if (!pluginDevice.nativeId) { @@ -128,6 +132,7 @@ export class PluginDeviceProxyHandler implements ProxyHandler, ScryptedDevi const interfaces: ScryptedInterface[] = getState(pluginDevice, ScryptedInterfaceProperty.providedInterfaces) || []; return { + passthrough: false, proxy, interfaces: new Set(interfaces), allInterfaces: interfaces, @@ -136,7 +141,7 @@ export class PluginDeviceProxyHandler implements ProxyHandler, ScryptedDevi this.mixinTable.unshift({ mixinProviderId: undefined, - entry: previousEntry, + entry, }); } else { @@ -145,11 +150,8 @@ export class PluginDeviceProxyHandler implements ProxyHandler, ScryptedDevi const prevTable = this.mixinTable.find(table => table.mixinProviderId === lastValidMixinId); if (!prevTable) throw new PluginError('mixin table partial invalidation was called with invalid lastValidMixinId'); - previousEntry = prevTable.entry; } - const type = getDisplayType(pluginDevice); - for (const mixinId of getState(pluginDevice, ScryptedInterfaceProperty.mixins) || []) { if (lastValidMixinId) { if (mixinId === lastValidMixinId) @@ -158,65 +160,11 @@ export class PluginDeviceProxyHandler implements ProxyHandler, ScryptedDevi } const wrappedMixinTable = this.mixinTable.slice(); + const entry = this.rebuildEntry(pluginDevice, mixinId, Promise.resolve(wrappedMixinTable)); this.mixinTable.unshift({ mixinProviderId: mixinId, - entry: (async () => { - let { allInterfaces } = await previousEntry; - try { - const mixinProvider = this.scrypted.getDevice(mixinId) as ScryptedDevice & MixinProvider; - const interfaces = await mixinProvider?.canMixin(type, allInterfaces) as any as ScryptedInterface[]; - if (!interfaces) { - // this is not an error - // do not advertise interfaces so it is skipped during - // vtable lookup. - console.log(`mixin provider ${mixinId} can no longer mixin ${this.id}`); - const mixins: string[] = getState(pluginDevice, ScryptedInterfaceProperty.mixins) || []; - this.scrypted.stateManager.setPluginDeviceState(pluginDevice, ScryptedInterfaceProperty.mixins, mixins.filter(mid => mid !== mixinId)) - this.scrypted.datastore.upsert(pluginDevice); - return { - allInterfaces, - interfaces: new Set(), - proxy: undefined as any, - }; - } - - allInterfaces = allInterfaces.slice(); - allInterfaces.push(...interfaces); - const combinedInterfaces = [...new Set(allInterfaces)]; - - const wrappedHandler = new PluginDeviceProxyHandler(this.scrypted, this.id); - wrappedHandler.mixinTable = wrappedMixinTable; - const wrappedProxy = new Proxy(wrappedHandler, wrappedHandler); - - const host = this.scrypted.getPluginHostForDeviceId(mixinId); - const deviceState = await host.remote.createDeviceState(this.id, - async (property, value) => this.scrypted.stateManager.setPluginDeviceState(pluginDevice, property, value)); - const mixinProxy = await mixinProvider.getMixin(wrappedProxy, allInterfaces as ScryptedInterface[], deviceState); - if (!mixinProxy) - throw new PluginError(`mixin provider ${mixinId} did not return mixin for ${this.id}`); - - return { - interfaces: new Set(interfaces), - allInterfaces: combinedInterfaces, - proxy: mixinProxy, - }; - } - catch (e) { - // on any error, do not advertise interfaces - // on this mixin, so as to prevent total failure? - // this has been the behavior for a while, - // but maybe interfaces implemented by that mixin - // should rethrow the error caught here in applyMixin. - console.warn(e); - return { - allInterfaces, - interfaces: new Set(), - error: e, - proxy: undefined as any, - }; - } - })(), + entry, }); } @@ -226,6 +174,81 @@ export class PluginDeviceProxyHandler implements ProxyHandler, ScryptedDevi }); } + async rebuildEntry(pluginDevice: PluginDevice, mixinId: string, wrappedMixinTablePromise: Promise): Promise { + const wrappedMixinTable = await wrappedMixinTablePromise; + const previousEntry = wrappedMixinTable[0].entry; + + const type = getDisplayType(pluginDevice); + + let { allInterfaces } = await previousEntry; + try { + const mixinProvider = this.scrypted.getDevice(mixinId) as ScryptedDevice & MixinProvider; + const interfaces = mixinProvider?.interfaces?.includes(ScryptedInterface.MixinProvider) && await mixinProvider?.canMixin(type, allInterfaces) as any as ScryptedInterface[]; + if (!interfaces) { + // this is not an error + // do not advertise interfaces so it is skipped during + // vtable lookup. + console.log(`mixin provider ${mixinId} can no longer mixin ${this.id}`); + const mixins: string[] = getState(pluginDevice, ScryptedInterfaceProperty.mixins) || []; + this.scrypted.stateManager.setPluginDeviceState(pluginDevice, ScryptedInterfaceProperty.mixins, mixins.filter(mid => mid !== mixinId)) + this.scrypted.datastore.upsert(pluginDevice); + return { + passthrough: true, + allInterfaces, + interfaces: new Set(), + proxy: undefined as any, + }; + } + + const previousInterfaces = allInterfaces; + allInterfaces = previousInterfaces.slice(); + allInterfaces.push(...interfaces); + const combinedInterfaces = [...new Set(allInterfaces)]; + + const wrappedHandler = new PluginDeviceProxyHandler(this.scrypted, this.id); + wrappedHandler.mixinTable = wrappedMixinTable; + const wrappedProxy = new Proxy(wrappedHandler, wrappedHandler); + + const implementer = await (mixinProvider as any)[QueryInterfaceSymbol](ScryptedInterface.MixinProvider); + const host = this.scrypted.getPluginHostForDeviceId(implementer); + // todo: remove this and pass the setter directly. + const deviceState = await host.remote.createDeviceState(this.id, + async (property, value) => this.scrypted.stateManager.setPluginDeviceState(pluginDevice, property, value)); + const mixinProxy = await mixinProvider.getMixin(wrappedProxy, allInterfaces as ScryptedInterface[], deviceState); + if (!mixinProxy) + throw new PluginError(`mixin provider ${mixinId} did not return mixin for ${this.id}`); + + // mixin is a passthrough of no interfaces changed, and the proxy is the same + // a mixin can be a passthrough even if it implements an interface (like Settings), + // so long as the wrapped proxy also implements that interface. + // techically it is a passthrough if the proxies are the same instance, but + // better to be explicit here about interface differences. + const passthrough = wrappedProxy === mixinProxy && previousInterfaces.length === combinedInterfaces.length; + + return { + passthrough, + interfaces: new Set(interfaces), + allInterfaces: combinedInterfaces, + proxy: mixinProxy, + }; + } + catch (e) { + // on any error, do not advertise interfaces + // on this mixin, so as to prevent total failure? + // this has been the behavior for a while, + // but maybe interfaces implemented by that mixin + // should rethrow the error caught here in applyMixin. + console.warn(e); + return { + passthrough: false, + allInterfaces, + interfaces: new Set(), + error: e, + proxy: undefined as any, + }; + } + } + get(target: any, p: PropertyKey, receiver: any): any { if (p === 'constructor') return; @@ -241,7 +264,7 @@ export class PluginDeviceProxyHandler implements ProxyHandler, ScryptedDevi if (allInterfaceProperties.includes(prop)) return getState(pluginDevice, prop); - if (p === RefreshSymbol) + if (p === RefreshSymbol || p === QueryInterfaceSymbol) return new Proxy(() => p, this); if (!isValidInterfaceMethod(pluginDevice.state.interfaces.value, prop)) @@ -287,19 +310,28 @@ export class PluginDeviceProxyHandler implements ProxyHandler, ScryptedDevi if (!iface) throw new PluginError(`unknown method ${method}`); - for (const mixin of this.mixinTable) { - const { interfaces, proxy } = await mixin.entry; - // this could be null? - if (interfaces.has(iface)) { - if (!proxy) - throw new PluginError(`device is unavailable ${this.id} (mixin ${mixin.mixinProviderId})`); - return proxy[method](...argArray); - } + const found = await this.findMixin(iface); + if (found) { + const { mixin, entry } = found; + const { proxy } = entry; + if (!proxy) + throw new PluginError(`device is unavailable ${this.id} (mixin ${mixin.mixinProviderId})`); + return proxy[method](...argArray); } throw new PluginError(`${method} not implemented`) } + async findMixin(iface: string) { + for (const mixin of this.mixinTable) { + const entry = await mixin.entry; + const { interfaces } = entry; + if (interfaces.has(iface)) { + return { mixin, entry }; + } + } + } + async apply(target: any, thisArg: any, argArray?: any): Promise { const method = target(); @@ -308,6 +340,16 @@ export class PluginDeviceProxyHandler implements ProxyHandler, ScryptedDevi if (method === RefreshSymbol) return this.applyMixin('refresh', argArray); + if (method === QueryInterfaceSymbol) { + const iface = argArray[0]; + const found = await this.findMixin(iface); + if (found?.entry.interfaces.has(iface)) { + return found.mixin.mixinProviderId || this.id; + } + + throw new PluginError(`${iface} not implemented`) + } + if (!isValidInterfaceMethod(pluginDevice.state.interfaces.value, method)) throw new PluginError(`device ${this.id} does not support method ${method}`); @@ -326,5 +368,3 @@ export class PluginDeviceProxyHandler implements ProxyHandler, ScryptedDevi return this.applyMixin(method, argArray); } } - -export const RefreshSymbol = Symbol('ScryptedDeviceRefresh'); diff --git a/server/src/plugin/plugin-host-api.ts b/server/src/plugin/plugin-host-api.ts index 4a3534e01..f76f417af 100644 --- a/server/src/plugin/plugin-host-api.ts +++ b/server/src/plugin/plugin-host-api.ts @@ -41,16 +41,38 @@ export class PluginHostAPI extends PluginAPIManagedListeners implements PluginAP this.pluginId = plugin._id; } - async onMixinEvent(id: string, nativeId: ScryptedNativeId, eventInterface: any, eventData?: any) { + // do we care about mixin validation here? + // maybe to prevent/notify errant dangling events? + async onMixinEvent(id: string, nativeIdOrMixinDevice: ScryptedNativeId|any, eventInterface: any, eventData?: any) { + // nativeId code path has been deprecated in favor of mixin object 12/10/2021 const device = this.scrypted.findPluginDeviceById(id); - const mixinProvider = this.scrypted.findPluginDevice(this.pluginId, nativeId); - const mixins: string[] = getState(device, ScryptedInterfaceProperty.mixins) || []; - if (!mixins.includes(mixinProvider._id)) - throw new Error(`${mixinProvider._id} is not a mixin provider for ${id}`); - const tableEntry = this.scrypted.devices[device._id].handler.mixinTable.find(entry => entry.mixinProviderId === mixinProvider._id); - const { interfaces } = await tableEntry.entry; - if (!interfaces.has(eventInterface)) - throw new Error(`${mixinProvider._id} does not mixin ${eventInterface} for ${id}`); + if (!nativeIdOrMixinDevice || typeof nativeIdOrMixinDevice === 'string') { + // todo: deprecate this code path + const mixinProvider = this.scrypted.findPluginDevice(this.pluginId, nativeIdOrMixinDevice); + const mixins: string[] = getState(device, ScryptedInterfaceProperty.mixins) || []; + if (!mixins.includes(mixinProvider._id)) + throw new Error(`${mixinProvider._id} is not a mixin provider for ${id}`); + + this.scrypted.findPluginDevice(this.pluginId, nativeIdOrMixinDevice); + const tableEntry = this.scrypted.devices[device._id].handler.mixinTable.find(entry => entry.mixinProviderId === mixinProvider._id); + const { interfaces } = await tableEntry.entry; + if (!interfaces.has(eventInterface)) + throw new Error(`${mixinProvider._id} does not mixin ${eventInterface} for ${id}`); + } + else { + let found = false; + for (const mixin of this.scrypted.devices[device._id].handler.mixinTable) { + const { proxy } = await mixin.entry; + if (proxy === nativeIdOrMixinDevice) { + found = true; + break; + } + } + if (!found) { + const mixinProvider = this.scrypted.findPluginDevice(this.pluginId, nativeIdOrMixinDevice); + throw new Error(`${mixinProvider._id} does not mixin ${eventInterface} for ${id}`); + } + } this.scrypted.stateManager.notifyInterfaceEvent(device, eventInterface, eventData); } diff --git a/server/src/plugin/plugin-host.ts b/server/src/plugin/plugin-host.ts index dab1526c6..dcfd6dd1e 100644 --- a/server/src/plugin/plugin-host.ts +++ b/server/src/plugin/plugin-host.ts @@ -60,6 +60,9 @@ export class PluginHost { } this.ws = {}; + // const pluginDevices = new Set(Object.values(this.scrypted.pluginDevices).filter(d => d.pluginId === this.pluginId).map(d => d._id)); + // this.scrypted.invalidateMixins(pluginDevices); + for (const device of Object.values(this.scrypted.devices)) { const pluginDevice = this.scrypted.pluginDevices[device.handler.id]; if (!pluginDevice) { diff --git a/server/src/runtime.ts b/server/src/runtime.ts index 46513d301..f476a4b2f 100644 --- a/server/src/runtime.ts +++ b/server/src/runtime.ts @@ -427,14 +427,35 @@ export class ScryptedRuntime { } // should this be async? - invalidatePluginDeviceMixins(id: string) { + rebuildPluginDeviceMixinTable(id: string) { const proxyPair = this.devices[id]; if (!proxyPair) return; - proxyPair.handler.invalidateMixinTable(); + proxyPair.handler.rebuildMixinTable(); return proxyPair; } + invalidateMixins(mixinIds: Set) { + // const ret = new Set(mixinIds); + + // for (const device of Object.values(this.devices)) { + // const pluginDevice = this.pluginDevices[device.handler.id]; + // if (ret.has(pluginDevice._id)) + // continue; + // if (!pluginDevice) { + // console.warn('PluginDevice missing?', device.handler.id); + // continue; + // } + // for (const mixin of getState(pluginDevice, ScryptedInterfaceProperty.mixins) || []) { + // if (this.scrypted.findPluginDeviceById(mixin)?.pluginId === this.pluginId) { + // device.handler.invalidate(); + // } + // } + // } + + // return ret; + } + async installNpm(pkg: string, version?: string): Promise { const registry = (await axios(`https://registry.npmjs.org/${pkg}`)).data; if (!version) { diff --git a/server/src/services/plugin.ts b/server/src/services/plugin.ts index 35d9b9e4f..e2a132106 100644 --- a/server/src/services/plugin.ts +++ b/server/src/services/plugin.ts @@ -29,7 +29,7 @@ export class PluginComponent { const pluginDevice = this.scrypted.findPluginDeviceById(id); this.scrypted.stateManager.setPluginDeviceState(pluginDevice, ScryptedInterfaceProperty.mixins, [...new Set(mixins)] || []); await this.scrypted.datastore.upsert(pluginDevice); - const device = this.scrypted.invalidatePluginDeviceMixins(id); + const device = this.scrypted.rebuildPluginDeviceMixinTable(id); await device?.handler.ensureProxy(); } async getMixins(id: string) {