Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/sync/editor/generic/ipywidgets-state.ts
Views: 687
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6NOTE: Like much of our Jupyter-related code in CoCalc,7the code in this file is very much run in *both* the8frontend web browser and backend project server.9*/1011import { EventEmitter } from "events";12import { Map as iMap } from "immutable";13import {14close,15delete_null_fields,16is_object,17len,18auxFileToOriginal,19} from "@cocalc/util/misc";20import { SyncDoc } from "./sync-doc";21import { SyncTable } from "@cocalc/sync/table/synctable";22import { Client } from "./types";23import { debounce } from "lodash";24import sha1 from "sha1";2526type State = "init" | "ready" | "closed";2728type Value = { [key: string]: any };2930// When there is no activity for this much time, them we31// do some garbage collection. This is only done in the32// backend project, and not by frontend browser clients.33// The garbage collection is deleting models and related34// data when they are not referenced in the notebook.35// Also, we don't implement complete object delete yet so instead we36// set the data field to null, which clears all state about and37// object and makes it easy to know to ignore it.38const GC_DEBOUNCE_MS = 10000;3940// If for some reason GC needs to be deleted, e.g., maybe you41// suspect a bug, just toggle this flag. In particular, note42// includeThirdPartyReferences below that has to deal with a special43// case schema that k3d uses for references, which they just made up,44// which works with official upstream, since that has no garbage45// collection.46const DISABLE_GC = false;4748// ignore messages past this age.49const MAX_MESSAGE_TIME_MS = 10000;5051interface CommMessage {52header: { msg_id: string };53parent_header: { msg_id: string };54content: any;55buffers: any[];56}5758export interface Message {59// don't know yet...60}6162export type SerializedModelState = { [key: string]: any };6364export class IpywidgetsState extends EventEmitter {65private syncdoc: SyncDoc;66private client: Client;67private table: SyncTable;68private state: State = "init";69private table_options: any[] = [];70private create_synctable: Function;71private gc: Function;7273// TODO: garbage collect this, both on the frontend and backend.74// This should be done in conjunction with the main table (with gc75// on backend, and with change to null event on the frontend).76private buffers: {77[model_id: string]: {78[path: string]: { buffer: Buffer; hash: string };79};80} = {};81// Similar but used on frontend82private arrayBuffers: {83[model_id: string]: {84[path: string]: { buffer: ArrayBuffer; hash: string };85};86} = {};8788// If capture_output[msg_id] is defined, then89// all output with that msg_id is captured by the90// widget with given model_id. This data structure91// is ONLY used in the project, and is not synced92// between frontends and project.93private capture_output: { [msg_id: string]: string[] } = {};9495// If the next output should be cleared. Use for96// clear_output with wait=true.97private clear_output: { [model_id: string]: boolean } = {};9899constructor(syncdoc: SyncDoc, client: Client, create_synctable: Function) {100super();101this.syncdoc = syncdoc;102this.client = client;103this.create_synctable = create_synctable;104if (this.syncdoc.data_server == "project") {105// options only supported for project...106// ephemeral -- don't store longterm in database107// persistent -- doesn't automatically vanish when all browser clients disconnect108this.table_options = [{ ephemeral: true, persistent: true }];109}110this.gc =111!DISABLE_GC && client.is_project() // no-op if not project or DISABLE_GC112? debounce(() => {113// return; // temporarily disabled since it is still too aggressive114if (this.state == "ready") {115this.deleteUnused();116}117}, GC_DEBOUNCE_MS)118: () => {};119}120121init = async (): Promise<void> => {122const query = {123ipywidgets: [124{125string_id: this.syncdoc.get_string_id(),126model_id: null,127type: null,128data: null,129},130],131};132this.table = await this.create_synctable(query, this.table_options, 0);133134// TODO: here the project should clear the table.135136this.set_state("ready");137138this.table.on("change", (keys) => {139this.emit("change", keys);140});141};142143keys = (): { model_id: string; type: "value" | "state" | "buffer" }[] => {144// return type is arrow of s145this.assert_state("ready");146const x = this.table.get();147if (x == null) {148return [];149}150const keys: { model_id: string; type: "value" | "state" | "buffer" }[] = [];151x.forEach((val, key) => {152if (val.get("data") != null && key != null) {153const [, model_id, type] = JSON.parse(key);154keys.push({ model_id, type });155}156});157return keys;158};159160get = (model_id: string, type: string): iMap<string, any> | undefined => {161const key: string = JSON.stringify([162this.syncdoc.get_string_id(),163model_id,164type,165]);166const record = this.table.get(key);167if (record == null) {168return undefined;169}170return record.get("data");171};172173// assembles together state we know about the widget with given model_id174// from info in the table, and returns it as a Javascript object.175getSerializedModelState = (176model_id: string,177): SerializedModelState | undefined => {178this.assert_state("ready");179const state = this.get(model_id, "state");180if (state == null) {181return undefined;182}183const state_js = state.toJS();184let value: any = this.get(model_id, "value");185if (value != null) {186value = value.toJS();187if (value == null) {188throw Error("value must be a map");189}190for (const key in value) {191state_js[key] = value[key];192}193}194return state_js;195};196197get_model_value = (model_id: string): Value => {198this.assert_state("ready");199let value: any = this.get(model_id, "value");200if (value == null) {201return {};202}203value = value.toJS();204if (value == null) {205return {};206}207return value;208};209210/*211Setting and getting buffers.212213- Setting the model buffers only happens on the backend project.214This is done in response to a comm message from the kernel215that has content.data.buffer_paths set.216217- Getting the model buffers only happens in the frontend browser.218This happens when creating models that support widgets, and often219happens in conjunction with deserialization.220221Getting a model buffer for a given path can happen222*at any time* after the buffer is created, not just right when223it is created like in JupyterLab! The reason is because a browser224can connect or get refreshed at any time, and then they need the225buffer to reconstitue the model. Moreover, a user might only226scroll the widget into view in their (virtualized) notebook at any227point, and it is only then that point the model gets created.228This means that we have to store and garbage collect model229buffers, which is a problem I don't think upstream ipywidgets230has to solve.231*/232getModelBuffers = async (233model_id: string,234): Promise<{235buffer_paths: string[][];236buffers: ArrayBuffer[];237}> => {238let value: iMap<string, string> | undefined = this.get(model_id, "buffers");239if (value == null) {240return { buffer_paths: [], buffers: [] };241}242// value is an array from JSON of paths array to array buffers:243const buffer_paths: string[][] = [];244const buffers: ArrayBuffer[] = [];245if (this.arrayBuffers[model_id] == null) {246this.arrayBuffers[model_id] = {};247}248const f = async (path: string) => {249const hash = value?.get(path);250if (!hash) {251// It is important to look for !hash, since we use hash='' as a sentinel (in this.clearOutputBuffers)252// to indicate that we want to consider a buffer as having been deleted. This is very important253// to do since large outputs are often buffers in output widgets, and clear_output254// then needs to delete those buffers, or output never goes away.255return;256}257const cur = this.arrayBuffers[model_id][path];258if (cur?.hash == hash) {259buffer_paths.push(JSON.parse(path));260buffers.push(cur.buffer);261return;262}263try {264const buffer = await this.clientGetBuffer(model_id, path);265this.arrayBuffers[model_id][path] = { buffer, hash };266buffer_paths.push(JSON.parse(path));267buffers.push(buffer);268} catch (err) {269console.log(`skipping ${model_id}, ${path} due to ${err}`);270}271};272// Run f in parallel on all of the keys of value:273await Promise.all(274value275.keySeq()276.toJS()277.filter((path) => path.startsWith("["))278.map(f),279);280return { buffers, buffer_paths };281};282283// This is used on the backend when syncing changes from project nodejs *to*284// the jupyter kernel.285getKnownBuffers = (model_id: string) => {286let value: iMap<string, string> | undefined = this.get(model_id, "buffers");287if (value == null) {288return { buffer_paths: [], buffers: [] };289}290// value is an array from JSON of paths array to array buffers:291const buffer_paths: string[][] = [];292const buffers: ArrayBuffer[] = [];293if (this.buffers[model_id] == null) {294this.buffers[model_id] = {};295}296const f = (path: string) => {297const hash = value?.get(path);298if (!hash) {299return;300}301const cur = this.buffers[model_id][path];302if (cur?.hash == hash) {303buffer_paths.push(JSON.parse(path));304buffers.push(cur.buffer);305return;306}307};308value309.keySeq()310.toJS()311.filter((path) => path.startsWith("["))312.map(f);313return { buffers, buffer_paths };314};315316private clientGetBuffer = async (model_id: string, path: string) => {317// async get of the buffer from backend318if (this.client.ipywidgetsGetBuffer == null) {319throw Error(320"NotImplementedError: frontend client must implement ipywidgetsGetBuffer in order to support binary buffers",321);322}323return await this.client.ipywidgetsGetBuffer(324this.syncdoc.project_id,325auxFileToOriginal(this.syncdoc.path),326model_id,327path,328);329};330331// Used on the backend by the project http server332getBuffer = (333model_id: string,334buffer_path_or_sha1: string,335): Buffer | undefined => {336const dbg = this.dbg("getBuffer");337dbg("getBuffer", model_id, buffer_path_or_sha1);338return this.buffers[model_id]?.[buffer_path_or_sha1]?.buffer;339};340341// returns the sha1 hashes of the buffers342setModelBuffers = (343// model that buffers are associated to:344model_id: string,345// if given, these are buffers with given paths; if not given, we346// store buffer associated to sha1 (which is used for custom messages)347buffer_paths: string[][] | undefined,348// the actual buffers.349buffers: Buffer[],350fire_change_event: boolean = true,351): string[] => {352const dbg = this.dbg("setModelBuffers");353dbg("buffer_paths = ", buffer_paths);354355const data: { [path: string]: boolean } = {};356if (this.buffers[model_id] == null) {357this.buffers[model_id] = {};358}359const hashes: string[] = [];360if (buffer_paths != null) {361for (let i = 0; i < buffer_paths.length; i++) {362const key = JSON.stringify(buffer_paths[i]);363// we set to the sha1 of the buffer not just to make getting364// the buffer easy, but to make it easy to KNOW if we365// even need to get the buffer.366const hash = sha1(buffers[i]);367hashes.push(hash);368data[key] = hash;369this.buffers[model_id][key] = { buffer: buffers[i], hash };370}371} else {372for (const buffer of buffers) {373const hash = sha1(buffer);374hashes.push(hash);375this.buffers[model_id][hash] = { buffer, hash };376data[hash] = hash;377}378}379this.set(model_id, "buffers", data, fire_change_event);380return hashes;381};382383/*384Setting model state and value385386- model state -- gets set once right when model is defined by kernel387- model "value" -- should be called "update"; gets set with changes to388the model state since it was created.389(I think an inefficiency with this approach is the entire updated390"value" gets broadcast each time anything about it is changed.391Fortunately usually value is small. However, it would be much392better to broadcast only the information about what changed, though393that is more difficult to implement given our current simple key:value394store sync layer. This tradeoff may be fully worth it for395our applications, since large data should be in buffers, and those396are efficient.)397*/398399set_model_value = (400model_id: string,401value: Value,402fire_change_event: boolean = true,403): void => {404this.set(model_id, "value", value, fire_change_event);405};406407set_model_state = (408model_id: string,409state: any,410fire_change_event: boolean = true,411): void => {412this.set(model_id, "state", state, fire_change_event);413};414415// Do any setting of the underlying table through this function.416set = (417model_id: string,418type: "value" | "state" | "buffers" | "message",419data: any,420fire_change_event: boolean = true,421merge?: "none" | "shallow" | "deep",422): void => {423const dbg = this.dbg("set");424const string_id = this.syncdoc.get_string_id();425if (typeof data != "object") {426throw Error("TypeError -- data must be a map");427}428let defaultMerge: "none" | "shallow" | "deep";429if (type == "value") {430//defaultMerge = "shallow";431// we manually do the shallow merge only on the data field.432const current = this.get_model_value(model_id);433dbg("value: before", { data, current });434if (current != null) {435for (const k in data) {436if (is_object(data[k]) && is_object(current[k])) {437current[k] = { ...current[k], ...data[k] };438} else {439current[k] = data[k];440}441}442data = current;443}444dbg("value -- after", { merged: data });445defaultMerge = "none";446} else if (type == "buffers") {447// it's critical to not throw away existing buffers when448// new ones come or current ones change. With shallow merge,449// the existing ones go away, which is very broken, e.g.,450// see this with this example:451/*452import bqplot.pyplot as plt453import numpy as np454x, y = np.random.rand(2, 10)455fig = plt.figure(animation_duration=3000)456scat = plt.scatter(x=x, y=y)457fig458---459scat.x, scat.y = np.random.rand(2, 50)460461# now close and open it, and it breaks with shallow merge,462# since the second cell caused the opacity buffer to be463# deleted, which breaks everything.464*/465defaultMerge = "deep";466} else if (type == "message") {467defaultMerge = "none";468} else {469defaultMerge = "deep";470}471if (merge == null) {472merge = defaultMerge;473}474this.table.set(475{ string_id, type, model_id, data },476merge,477fire_change_event,478);479};480481save = async (): Promise<void> => {482this.gc();483await this.table.save();484};485486close = async (): Promise<void> => {487if (this.table != null) {488await this.table.close();489}490close(this);491this.set_state("closed");492};493494private dbg = (_f): Function => {495if (this.client.is_project()) {496return this.client.dbg(`IpywidgetsState.${_f}`);497} else {498return (..._) => {};499}500};501502clear = async (): Promise<void> => {503// This is used when we restart the kernel -- we reset504// things so no information about any models is known505// and delete all Buffers.506this.assert_state("ready");507const dbg = this.dbg("clear");508dbg();509510this.buffers = {};511// There's no implemented delete for tables yet, so instead we set the data512// for everything to null. All other code related to widgets needs to handle513// such data appropriately and ignore it. (An advantage of this over trying to514// implement a genuine delete is that delete is tricky when clients reconnect515// and sync...). This table is in memory only anyways, so the table will get properly516// fully flushed from existence at some point.517const keys = this.table?.get()?.keySeq()?.toJS();518if (keys == null) return; // nothing to do.519for (const key of keys) {520const [string_id, model_id, type] = JSON.parse(key);521this.table.set({ string_id, type, model_id, data: null }, "none", false);522}523await this.table.save();524};525526values = () => {527const x = this.table.get();528if (x == null) {529return [];530}531return Object.values(x.toJS()).filter((obj) => obj.data);532};533534// Clean up all data in the table about models that are not535// referenced (directly or indirectly) in any cell in the notebook.536// There is also a comm:close event/message somewhere, which537// could also be useful....?538deleteUnused = async (): Promise<void> => {539this.assert_state("ready");540const dbg = this.dbg("deleteUnused");541dbg();542// See comment in the "clear" function above about no delete for tables,543// which is why we just set the data to null.544const activeIds = this.getActiveModelIds();545this.table.get()?.forEach((val, key) => {546if (key == null || val?.get("data") == null) {547// already deleted548return;549}550const [string_id, model_id, type] = JSON.parse(key);551if (!activeIds.has(model_id)) {552// Delete this model from the table (or as close to delete as we have).553// This removes the last message, state, buffer info, and value,554// depending on type.555this.table.set(556{ string_id, type, model_id, data: null },557"none",558false,559);560561// Also delete buffers for this model, which are stored in memory, and562// won't be requested again.563delete this.buffers[model_id];564}565});566await this.table.save();567};568569// For each model in init, we add in all the ids of models570// that it explicitly references, e.g., by IPY_MODEL_[model_id] fields571// and by output messages and other things we learn about (e.g., k3d572// has its own custom references).573getReferencedModelIds = (init: string | Set<string>): Set<string> => {574const modelIds =575typeof init == "string" ? new Set([init]) : new Set<string>(init);576let before = 0;577let after = modelIds.size;578while (before < after) {579before = modelIds.size;580for (const model_id of modelIds) {581for (const type of ["state", "value"]) {582const data = this.get(model_id, type);583if (data == null) continue;584for (const id of getModelIds(data)) {585modelIds.add(id);586}587}588}589after = modelIds.size;590}591// Also any custom ways of doing referencing -- e.g., k3d does this.592this.includeThirdPartyReferences(modelIds);593594// Also anything that references any modelIds595this.includeReferenceTo(modelIds);596597return modelIds;598};599600// We find the ids of all models that are explicitly referenced601// in the current version of the Jupyter notebook by iterating through602// the output of all cells, then expanding the result to everything603// that these models reference. This is used as a foundation for604// garbage collection.605private getActiveModelIds = (): Set<string> => {606const modelIds: Set<string> = new Set();607this.syncdoc.get({ type: "cell" }).forEach((cell) => {608const output = cell.get("output");609if (output != null) {610output.forEach((mesg) => {611const model_id = mesg.getIn([612"data",613"application/vnd.jupyter.widget-view+json",614"model_id",615]);616if (model_id != null) {617// same id could of course appear in multiple cells618// if there are multiple view of the same model.619modelIds.add(model_id);620}621});622}623});624return this.getReferencedModelIds(modelIds);625};626627private includeReferenceTo = (modelIds: Set<string>) => {628// This example is extra tricky and one version of our GC broke it:629// from ipywidgets import VBox, jsdlink, IntSlider, Button; s1 = IntSlider(max=200, value=100); s2 = IntSlider(value=40); jsdlink((s1, 'value'), (s2, 'max')); VBox([s1, s2])630// What happens here is that this jsdlink model ends up referencing live widgets,631// but is not referenced by any cell, so it would get garbage collected.632633let before = -1;634let after = modelIds.size;635while (before < after) {636before = modelIds.size;637this.table.get()?.forEach((val) => {638const data = val?.get("data");639if (data != null) {640for (const model_id of getModelIds(data)) {641if (modelIds.has(model_id)) {642modelIds.add(val.get("model_id"));643}644}645}646});647after = modelIds.size;648}649};650651private includeThirdPartyReferences = (modelIds: Set<string>) => {652/*653Motivation (RANT):654It seems to me that third party widgets can just invent their own655ways of referencing each other, and there's no way to know what they are656doing. The only possible way to do garbage collection is by reading657and understanding their code or reverse engineering their data.658It's not unlikely that any nontrivial third659party widget has invented it's own custom way to do object references,660and for every single one we may need to write custom code for garbage661collection, which can randomly break if they change.662<sarcasm>Yeah.</sarcasm>663/*664665/* k3d:666We handle k3d here, which creates models with667{_model_module:'k3d', _model_name:'ObjectModel', id:number}668where the id is in the object_ids attribute of some model found above:669{_model_module:'k3d', object_ids:[..., id, ...]}670But note that this format is something that was entirely just invented671arbitrarily by the k3d dev.672*/673// First get all object_ids of all active models:674// We're not explicitly restricting to k3d here, since maybe other widgets use675// this same approach, and the worst case scenario is just insufficient garbage collection.676const object_ids = new Set<number>([]);677for (const model_id of modelIds) {678for (const type of ["state", "value"]) {679this.get(model_id, type)680?.get("object_ids")681?.forEach((id) => {682object_ids.add(id);683});684}685}686if (object_ids.size == 0) {687// nothing to do -- no such object_ids in any current models.688return;689}690// let's find the models with these id's as id attribute and include them.691this.table.get()?.forEach((val) => {692if (object_ids.has(val?.getIn(["data", "id"]))) {693const model_id = val.get("model_id");694modelIds.add(model_id);695}696});697};698699// The finite state machine state, e.g., 'init' --> 'ready' --> 'close'700private set_state = (state: State): void => {701this.state = state;702this.emit(state);703};704705get_state = (): State => {706return this.state;707};708709private assert_state = (state: string): void => {710if (this.state != state) {711throw Error(`state must be "${state}" but it is "${this.state}"`);712}713};714715/*716process_comm_message_from_kernel gets called whenever the717kernel emits a comm message related to widgets. This updates718the state of the table, which results in frontends creating widgets719or updating state of widgets.720*/721process_comm_message_from_kernel = async (722msg: CommMessage,723): Promise<void> => {724const dbg = this.dbg("process_comm_message_from_kernel");725// WARNING: serializing any msg could cause huge server load, e.g., it could contain726// a 20MB buffer in it.727//dbg(JSON.stringify(msg)); // EXTREME DANGER!728dbg(JSON.stringify(msg.header));729this.assert_state("ready");730731const { content } = msg;732733if (content == null) {734dbg("content is null -- ignoring message");735return;736}737738let { comm_id } = content;739if (comm_id == null) {740if (msg.header != null) {741comm_id = msg.header.msg_id;742}743if (comm_id == null) {744dbg("comm_id is null -- ignoring message");745return;746}747}748const model_id: string = comm_id;749dbg({ model_id, comm_id });750751const { data } = content;752if (data == null) {753dbg("content.data is null -- ignoring message");754return;755}756757const { state } = data;758if (state != null) {759delete_null_fields(state);760}761762// It is critical to send any buffers data before763// the other data; otherwise, deserialization on764// the client side can't work, since it is missing765// the data it needs.766// This happens with method "update". With method="custom",767// there is just an array of buffers and no buffer_paths at all.768if (content.data.buffer_paths?.length > 0) {769// Deal with binary buffers:770dbg("setting binary buffers");771this.setModelBuffers(772model_id,773content.data.buffer_paths,774msg.buffers,775false,776);777}778779switch (content.data.method) {780case "custom":781const message = content.data.content;782const { buffers } = msg;783dbg("custom message", {784message,785buffers: `${buffers?.length ?? "no"} buffers`,786});787let buffer_hashes: string[];788if (789buffers != null &&790buffers.length > 0 &&791content.data.buffer_paths == null792) {793// TODO794dbg("custom message -- there are BUFFERS -- saving them");795buffer_hashes = this.setModelBuffers(796model_id,797undefined,798buffers,799false,800);801} else {802buffer_hashes = [];803}804// We now send the message.805this.sendCustomMessage(model_id, message, buffer_hashes, false);806break;807808case "echo_update":809// just ignore echo_update -- it's a new ipywidgets 8 mechanism810// for some level of RTC sync between clients -- we don't need that811// since we have our own, obviously. Setting the env var812// JUPYTER_WIDGETS_ECHO to 0 will disable these messages to slightly813// reduce traffic.814return;815816case "update":817if (state == null) {818return;819}820dbg("method -- update");821if (this.clear_output[model_id] && state.outputs != null) {822// we are supposed to clear the output before inserting823// the next output.824dbg("clearing outputs");825if (state.outputs.length > 0) {826state.outputs = [state.outputs[state.outputs.length - 1]];827} else {828state.outputs = [];829}830delete this.clear_output[model_id];831}832833const last_changed =834(this.get(model_id, "value")?.get("last_changed") ?? 0) + 1;835this.set_model_value(model_id, { ...state, last_changed }, false);836837if (state.msg_id != null) {838const { msg_id } = state;839if (typeof msg_id === "string" && msg_id.length > 0) {840dbg("enabling capture output", msg_id, model_id);841if (this.capture_output[msg_id] == null) {842this.capture_output[msg_id] = [model_id];843} else {844// pushing onto stack845this.capture_output[msg_id].push(model_id);846}847} else {848const parent_msg_id = msg.parent_header.msg_id;849dbg("disabling capture output", parent_msg_id, model_id);850if (this.capture_output[parent_msg_id] != null) {851const v: string[] = [];852const w: string[] = this.capture_output[parent_msg_id];853for (const m of w) {854if (m != model_id) {855v.push(m);856}857}858if (v.length == 0) {859delete this.capture_output[parent_msg_id];860} else {861this.capture_output[parent_msg_id] = v;862}863}864}865delete state.msg_id;866}867break;868case undefined:869if (state == null) return;870dbg("method -- undefined (=set_model_state)", { model_id, state });871this.set_model_state(model_id, state, false);872break;873default:874// TODO: Implement other methods, e.g., 'display' -- see875// https://github.com/jupyter-widgets/ipywidgets/blob/master/packages/schema/messages.md876dbg(`not implemented method '${content.data.method}' -- ignoring`);877}878879await this.save();880};881882/*883process_comm_message_from_browser gets called whenever a884browser client emits a comm message related to widgets.885This updates the state of the table, which results in886other frontends updating their widget state, *AND* the backend887kernel changing the value of variables (and possibly888updating other widgets).889*/890process_comm_message_from_browser = async (891msg: CommMessage,892): Promise<void> => {893const dbg = this.dbg("process_comm_message_from_browser");894dbg(msg);895this.assert_state("ready");896// TODO: not implemented!897};898899// The mesg here is exactly what came over the IOPUB channel900// from the kernel.901902// TODO: deal with buffers903capture_output_message = (mesg: any): boolean => {904const msg_id = mesg.parent_header.msg_id;905if (this.capture_output[msg_id] == null) {906return false;907}908const dbg = this.dbg("capture_output_message");909dbg(JSON.stringify(mesg));910const model_id =911this.capture_output[msg_id][this.capture_output[msg_id].length - 1];912if (model_id == null) return false; // should not happen.913914if (mesg.header.msg_type == "clear_output") {915if (mesg.content?.wait) {916this.clear_output[model_id] = true;917} else {918delete this.clear_output[model_id];919this.clearOutputBuffers(model_id);920this.set_model_value(model_id, { outputs: null });921}922return true;923}924925if (mesg.content == null || len(mesg.content) == 0) {926// no actual content.927return false;928}929930let outputs: any;931if (this.clear_output[model_id]) {932delete this.clear_output[model_id];933this.clearOutputBuffers(model_id);934outputs = [];935} else {936outputs = this.get_model_value(model_id).outputs;937if (outputs == null) {938outputs = [];939}940}941outputs.push(mesg.content);942this.set_model_value(model_id, { outputs });943return true;944};945946private clearOutputBuffers = (model_id: string) => {947// TODO: need to clear all output buffers.948/* Example where if you do not properly clear buffers, then broken output re-appears:949950import ipywidgets as widgets951from IPython.display import YouTubeVideo952out = widgets.Output(layout={'border': '1px solid black'})953out.append_stdout('Output appended with append_stdout')954out.append_display_data(YouTubeVideo('eWzY2nGfkXk'))955out956957---958959out.clear_output()960961---962963with out:964print('hi')965*/966// TODO!!!!967968const y: any = {};969let n = 0;970for (const jsonPath of this.get(model_id, "buffers")?.keySeq() ?? []) {971const path = JSON.parse(jsonPath);972console.log("path = ", path);973if (path[0] == "outputs") {974y[jsonPath] = "";975n += 1;976}977}978console.log("y = ", y);979if (n > 0) {980this.set(model_id, "buffers", y, true, "shallow");981}982};983984private sendCustomMessage = async (985model_id: string,986message: object,987buffer_hashes: string[],988fire_change_event: boolean = true,989): Promise<void> => {990/*991Send a custom message.992993It's not at all clear what this should even mean in the context of994realtime collaboration, and there will likely be clients where995this is bad. But for now, we just make the last message sent996available via the table, and each successive message overwrites the previous997one. Any clients that are connected while we do this can react,998and any that aren't just don't get the message (which is presumably fine).9991000Some widgets like ipympl use this to initialize state, so when a new1001client connects, it requests a message describing the plot, and everybody1002receives it.1003*/10041005this.set(1006model_id,1007"message",1008{ message, buffer_hashes, time: Date.now() },1009fire_change_event,1010);1011};10121013// Return the most recent message for the given model.1014getMessage = async (1015model_id: string,1016): Promise<{ message: object; buffers: ArrayBuffer[] } | undefined> => {1017const x = this.get(model_id, "message")?.toJS();1018if (x == null) {1019return undefined;1020}1021if (Date.now() - (x.time ?? 0) >= MAX_MESSAGE_TIME_MS) {1022return undefined;1023}1024const { message, buffer_hashes } = x;1025let buffers: ArrayBuffer[] = [];1026for (const hash of buffer_hashes) {1027buffers.push(await this.clientGetBuffer(model_id, hash));1028}1029return { message, buffers };1030};1031}10321033// Get model id's that appear either as serialized references1034// of the form IPY_MODEL_....1035// or in output messages.1036function getModelIds(x): Set<string> {1037const ids: Set<string> = new Set();1038x?.forEach((val, key) => {1039if (key == "application/vnd.jupyter.widget-view+json") {1040const model_id = val.get("model_id");1041if (model_id) {1042ids.add(model_id);1043}1044} else if (typeof val == "string") {1045if (val.startsWith("IPY_MODEL_")) {1046ids.add(val.slice("IPY_MODEL_".length));1047}1048} else if (val.forEach != null) {1049for (const z of getModelIds(val)) {1050ids.add(z);1051}1052}1053});1054return ids;1055}105610571058