Skip to content

Commit

Permalink
Lazy plugs (silverbulletmd#596)
Browse files Browse the repository at this point in the history
* Manifest caching and lazy loading of plug workers
* Fixes silverbulletmd#546 Plug unloading after time out
  • Loading branch information
zefhemel authored Dec 6, 2023
1 parent 8451680 commit 8527528
Show file tree
Hide file tree
Showing 16 changed files with 225 additions and 66 deletions.
8 changes: 7 additions & 1 deletion common/spaces/evented_space_primitives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,13 @@ export class EventedSpacePrimitives implements SpacePrimitives {
meta,
);
if (!selfUpdate) {
await this.dispatchEvent("file:changed", name, true);
await this.dispatchEvent(
"file:changed",
name,
true,
undefined,
newMeta.lastModified,
);
}
this.spaceSnapshot[name] = newMeta.lastModified;

Expand Down
2 changes: 1 addition & 1 deletion plug-api/silverbullet-syscall/space.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function deletePage(name: string): Promise<void> {
return syscall("space.deletePage", name);
}

export function listPlugs(): Promise<string[]> {
export function listPlugs(): Promise<FileMeta[]> {
return syscall("space.listPlugs");
}

Expand Down
2 changes: 2 additions & 0 deletions plugos/hooks/endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Deno.test("Run a plugos endpoint server", async () => {

await system.load(
new URL(`file://${workerPath}`),
"test",
0,
createSandbox,
);

Expand Down
2 changes: 1 addition & 1 deletion plugos/lib/datastore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { KvPrimitives } from "./kv_primitives.ts";
*/
export class DataStore {
constructor(
private kv: KvPrimitives,
readonly kv: KvPrimitives,
private prefix: KvKey = [],
private functionMap: FunctionMap = builtinFunctions,
) {
Expand Down
49 changes: 49 additions & 0 deletions plugos/manifest_cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { KvPrimitives } from "./lib/kv_primitives.ts";
import { Plug } from "./plug.ts";
import { Manifest } from "./types.ts";

export interface ManifestCache<T> {
getManifest(plug: Plug<T>, hash: number): Promise<Manifest<T>>;
}

export class KVPrimitivesManifestCache<T> implements ManifestCache<T> {
constructor(private kv: KvPrimitives, private manifestPrefix: string) {
}

async getManifest(plug: Plug<T>, hash: number): Promise<Manifest<T>> {
const [cached] = await this.kv.batchGet([[
this.manifestPrefix,
plug.name,
]]);
if (cached && cached.hash === hash) {
// console.log("Using KV cached manifest for", plug.name);
return cached.manifest;
}
await plug.sandbox.init();
const manifest = plug.sandbox.manifest!;
await this.kv.batchSet([{
key: [this.manifestPrefix, plug.name],
value: { manifest, hash },
}]);
return manifest;
}
}

export class InMemoryManifestCache<T> implements ManifestCache<T> {
private cache = new Map<string, {
manifest: Manifest<T>;
hash: number;
}>();

async getManifest(plug: Plug<T>, hash: number): Promise<Manifest<T>> {
const cached = this.cache.get(plug.workerUrl.href);
if (cached && cached.hash === hash) {
// console.log("Using memory cached manifest for", plug.name);
return cached.manifest;
}
await plug.sandbox.init();
const manifest = plug.sandbox.manifest!;
this.cache.set(plug.name!, { manifest, hash });
return manifest;
}
}
52 changes: 36 additions & 16 deletions plugos/plug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,40 @@ export class Plug<HookT> {
public grantedPermissions: string[] = [];
public sandbox: Sandbox<HookT>;

// Resolves once the worker has been loaded
// Resolves once the plug's manifest is available
ready: Promise<void>;

// Only available after ready resolves
public manifest?: Manifest<HookT>;
public assets?: AssetBundle;

// Time of last function invocation
unloadTimeout?: number;

constructor(
private system: System<HookT>,
public workerUrl: URL,
readonly name: string,
private hash: number,
private sandboxFactory: (plug: Plug<HookT>) => Sandbox<HookT>,
) {
this.runtimeEnv = system.env;

// Kick off worker
this.scheduleUnloadTimeout();

this.sandbox = this.sandboxFactory(this);
this.ready = this.sandbox.ready.then(() => {
this.manifest = this.sandbox.manifest!;
this.assets = new AssetBundle(
this.manifest.assets ? this.manifest.assets as AssetJson : {},
// Retrieve the manifest asynchonously, which may either come from a cache or be loaded from the worker
this.ready = system.options.manifestCache!.getManifest(this, this.hash)
.then(
(manifest) => {
this.manifest = manifest;
this.assets = new AssetBundle(
manifest.assets ? manifest.assets as AssetJson : {},
);
// TODO: These need to be explicitly granted, not just taken
this.grantedPermissions = manifest.requiredPermissions || [];
},
);
// TODO: These need to be explicitly granted, not just taken
this.grantedPermissions = this.manifest.requiredPermissions || [];
});
}

get name(): string | undefined {
return this.manifest?.name;
}

// Invoke a syscall
Expand All @@ -54,11 +60,26 @@ export class Plug<HookT> {
return !funDef.env || !this.runtimeEnv || funDef.env === this.runtimeEnv;
}

scheduleUnloadTimeout() {
if (!this.system.options.plugFlushTimeout) {
return;
}
// Reset the unload timeout, if set
if (this.unloadTimeout) {
clearTimeout(this.unloadTimeout);
}
this.unloadTimeout = setTimeout(() => {
this.stop();
}, this.system.options.plugFlushTimeout);
}

// Invoke a function
async invoke(name: string, args: any[]): Promise<any> {
// Ensure the worker is fully up and running
await this.ready;

this.scheduleUnloadTimeout();

// Before we access the manifest
const funDef = this.manifest!.functions[name];
if (!funDef) {
Expand Down Expand Up @@ -90,8 +111,7 @@ export class Plug<HookT> {
}

stop() {
if (this.sandbox) {
this.sandbox.stop();
}
console.log("Stopping sandbox for", this.name);
this.sandbox.stop();
}
}
2 changes: 2 additions & 0 deletions plugos/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ Deno.test("Run a deno sandbox", async () => {

const plug = await system.load(
new URL(`file://${workerPath}`),
"test",
0,
createSandbox,
);

Expand Down
43 changes: 31 additions & 12 deletions plugos/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,38 @@ export type SandboxFactory<HookT> = (plug: Plug<HookT>) => Sandbox<HookT>;
* Effectively this wraps a web worker, the reason to have this split from Plugs is to allow plugs to manage multiple sandboxes, e.g. for performance in the future
*/
export class Sandbox<HookT> {
private worker: Worker;
private worker?: Worker;
private reqId = 0;
private outstandingInvocations = new Map<
number,
{ resolve: (result: any) => void; reject: (e: any) => void }
>();

public ready: Promise<void>;
// public ready: Promise<void>;
public manifest?: Manifest<HookT>;

constructor(
readonly plug: Plug<HookT>,
workerOptions = {},
private workerOptions = {},
) {
this.worker = new Worker(plug.workerUrl, {
...workerOptions,
}

/**
* Should only invoked lazily (either by invoke, or by a ManifestCache to load the manifest)
*/
init(): Promise<void> {
console.log("Booting up worker for", this.plug.name);
if (this.worker) {
// Should not happen
console.warn("Double init of sandbox");
}
this.worker = new Worker(this.plug.workerUrl, {
...this.workerOptions,
type: "module",
});
this.ready = new Promise((resolve) => {
this.worker.onmessage = (ev) => {

return new Promise((resolve) => {
this.worker!.onmessage = (ev) => {
if (ev.data.type === "manifest") {
this.manifest = ev.data.manifest;
resolve();
Expand All @@ -46,14 +58,14 @@ export class Sandbox<HookT> {
try {
const result = await this.plug.syscall(data.name!, data.args!);

this.worker.postMessage({
this.worker!.postMessage({
type: "sysr",
id: data.id,
result: result,
} as WorkerMessage);
} catch (e: any) {
// console.error("Syscall fail", e);
this.worker.postMessage({
this.worker!.postMessage({
type: "sysr",
id: data.id,
error: e.message,
Expand All @@ -76,9 +88,13 @@ export class Sandbox<HookT> {
}
}

invoke(name: string, args: any[]): Promise<any> {
async invoke(name: string, args: any[]): Promise<any> {
if (!this.worker) {
// Lazy initialization
await this.init();
}
this.reqId++;
this.worker.postMessage({
this.worker!.postMessage({
type: "inv",
id: this.reqId,
name,
Expand All @@ -90,6 +106,9 @@ export class Sandbox<HookT> {
}

stop() {
this.worker.terminate();
if (this.worker) {
this.worker.terminate();
this.worker = undefined;
}
}
}
21 changes: 19 additions & 2 deletions plugos/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { EventEmitter } from "./event.ts";
import type { SandboxFactory } from "./sandbox.ts";
import { Plug } from "./plug.ts";
import { deepObjectMerge } from "$sb/lib/json.ts";
import { InMemoryManifestCache, ManifestCache } from "./manifest_cache.ts";

export interface SysCallMapping {
[key: string]: (ctx: SyscallContext, ...args: any) => Promise<any> | any;
Expand All @@ -28,13 +29,27 @@ type Syscall = {
callback: SyscallSignature;
};

export type SystemOptions = {
manifestCache?: ManifestCache<any>;
plugFlushTimeout?: number;
};

export class System<HookT> extends EventEmitter<SystemEvents<HookT>> {
protected plugs = new Map<string, Plug<HookT>>();
protected registeredSyscalls = new Map<string, Syscall>();
protected enabledHooks = new Set<Hook<HookT>>();

constructor(readonly env?: string) {
/**
* @param env either an environment or undefined for hybrid mode
*/
constructor(
readonly env: string | undefined,
readonly options: SystemOptions = {},
) {
super();
if (!options.manifestCache) {
options.manifestCache = new InMemoryManifestCache();
}
}

get loadedPlugs(): Map<string, Plug<HookT>> {
Expand Down Expand Up @@ -94,11 +109,13 @@ export class System<HookT> extends EventEmitter<SystemEvents<HookT>> {

async load(
workerUrl: URL,
name: string,
hash: number,
sandboxFactory: SandboxFactory<HookT>,
// Mapping plug name -> manifest overrides
manifestOverrides?: Record<string, Partial<Manifest<HookT>>>,
): Promise<Plug<HookT>> {
const plug = new Plug(this, workerUrl, sandboxFactory);
const plug = new Plug(this, workerUrl, name, hash, sandboxFactory);

// Wait for worker to boot, and pass back its manifest
await plug.ready;
Expand Down
5 changes: 4 additions & 1 deletion plugs/markdown/markdown_render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,19 @@ import { System } from "../../plugos/system.ts";
import { createSandbox } from "../../plugos/environments/deno_sandbox.ts";
import { loadMarkdownExtensions } from "../../common/markdown_parser/markdown_ext.ts";
import { renderMarkdownToHtml } from "./markdown_render.ts";
import { assertEquals } from "../../test_deps.ts";

Deno.test("Markdown render", async () => {
const system = new System<any>("server");
await system.load(
new URL("../../dist_plug_bundle/_plug/editor.plug.js", import.meta.url),
"editor",
0,
createSandbox,
);
await system.load(
new URL("../../dist_plug_bundle/_plug/tasks.plug.js", import.meta.url),
"tasks",
0,
createSandbox,
);
const lang = buildMarkdown(loadMarkdownExtensions(system));
Expand Down
2 changes: 1 addition & 1 deletion plugs/plug-manager/plugmanager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export async function updatePlugsCommand() {

const allPlugNames = [...builtinPlugNames, ...allCustomPlugNames];
// And delete extra ones
for (const existingPlug of await space.listPlugs()) {
for (const { name: existingPlug } of await space.listPlugs()) {
const plugName = existingPlug.substring(
"_plug/".length,
existingPlug.length - ".plug.js".length,
Expand Down
Loading

0 comments on commit 8527528

Please sign in to comment.