Path: blob/master/src/packages/sync/editor/generic/ipywidgets-state.ts
1450 views
/*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,19sha1,20} from "@cocalc/util/misc";21import { SyncDoc } from "./sync-doc";22import { SyncTable } from "@cocalc/sync/table/synctable";23import { Client } from "./types";24import { debounce } from "lodash";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;104this.table_options = [{ ephemeral: true }];105this.gc =106!DISABLE_GC && client.is_project() // no-op if not project or DISABLE_GC107? debounce(() => {108// return; // temporarily disabled since it is still too aggressive109if (this.state == "ready") {110this.deleteUnused();111}112}, GC_DEBOUNCE_MS)113: () => {};114}115116init = async (): Promise<void> => {117const query = {118ipywidgets: [119{120string_id: this.syncdoc.get_string_id(),121model_id: null,122type: null,123data: null,124},125],126};127this.table = await this.create_synctable(query, this.table_options, 0);128129// TODO: here the project should clear the table.130131this.set_state("ready");132133this.table.on("change", (keys) => {134this.emit("change", keys);135});136};137138keys = (): { model_id: string; type: "value" | "state" | "buffer" }[] => {139// return type is arrow of s140this.assert_state("ready");141const x = this.table.get();142if (x == null) {143return [];144}145const keys: { model_id: string; type: "value" | "state" | "buffer" }[] = [];146x.forEach((val, key) => {147if (val.get("data") != null && key != null) {148const [, model_id, type] = JSON.parse(key);149keys.push({ model_id, type });150}151});152return keys;153};154155get = (model_id: string, type: string): iMap<string, any> | undefined => {156const key: string = JSON.stringify([157this.syncdoc.get_string_id(),158model_id,159type,160]);161const record = this.table.get(key);162if (record == null) {163return undefined;164}165return record.get("data");166};167168// assembles together state we know about the widget with given model_id169// from info in the table, and returns it as a Javascript object.170getSerializedModelState = (171model_id: string,172): SerializedModelState | undefined => {173this.assert_state("ready");174const state = this.get(model_id, "state");175if (state == null) {176return undefined;177}178const state_js = state.toJS();179let value: any = this.get(model_id, "value");180if (value != null) {181value = value.toJS();182if (value == null) {183throw Error("value must be a map");184}185for (const key in value) {186state_js[key] = value[key];187}188}189return state_js;190};191192get_model_value = (model_id: string): Value => {193this.assert_state("ready");194let value: any = this.get(model_id, "value");195if (value == null) {196return {};197}198value = value.toJS();199if (value == null) {200return {};201}202return value;203};204205/*206Setting and getting buffers.207208- Setting the model buffers only happens on the backend project.209This is done in response to a comm message from the kernel210that has content.data.buffer_paths set.211212- Getting the model buffers only happens in the frontend browser.213This happens when creating models that support widgets, and often214happens in conjunction with deserialization.215216Getting a model buffer for a given path can happen217*at any time* after the buffer is created, not just right when218it is created like in JupyterLab! The reason is because a browser219can connect or get refreshed at any time, and then they need the220buffer to reconstitue the model. Moreover, a user might only221scroll the widget into view in their (virtualized) notebook at any222point, and it is only then that point the model gets created.223This means that we have to store and garbage collect model224buffers, which is a problem I don't think upstream ipywidgets225has to solve.226*/227getModelBuffers = async (228model_id: string,229): Promise<{230buffer_paths: string[][];231buffers: ArrayBuffer[];232}> => {233let value: iMap<string, string> | undefined = this.get(model_id, "buffers");234if (value == null) {235return { buffer_paths: [], buffers: [] };236}237// value is an array from JSON of paths array to array buffers:238const buffer_paths: string[][] = [];239const buffers: ArrayBuffer[] = [];240if (this.arrayBuffers[model_id] == null) {241this.arrayBuffers[model_id] = {};242}243const f = async (path: string) => {244const hash = value?.get(path);245if (!hash) {246// It is important to look for !hash, since we use hash='' as a sentinel (in this.clearOutputBuffers)247// to indicate that we want to consider a buffer as having been deleted. This is very important248// to do since large outputs are often buffers in output widgets, and clear_output249// then needs to delete those buffers, or output never goes away.250return;251}252const cur = this.arrayBuffers[model_id][path];253if (cur?.hash == hash) {254buffer_paths.push(JSON.parse(path));255buffers.push(cur.buffer);256return;257}258try {259const buffer = await this.clientGetBuffer(model_id, path);260this.arrayBuffers[model_id][path] = { buffer, hash };261buffer_paths.push(JSON.parse(path));262buffers.push(buffer);263} catch (err) {264console.log(`skipping ${model_id}, ${path} due to ${err}`);265}266};267// Run f in parallel on all of the keys of value:268await Promise.all(269value270.keySeq()271.toJS()272.filter((path) => path.startsWith("["))273.map(f),274);275return { buffers, buffer_paths };276};277278// This is used on the backend when syncing changes from project nodejs *to*279// the jupyter kernel.280getKnownBuffers = (model_id: string) => {281let value: iMap<string, string> | undefined = this.get(model_id, "buffers");282if (value == null) {283return { buffer_paths: [], buffers: [] };284}285// value is an array from JSON of paths array to array buffers:286const buffer_paths: string[][] = [];287const buffers: ArrayBuffer[] = [];288if (this.buffers[model_id] == null) {289this.buffers[model_id] = {};290}291const f = (path: string) => {292const hash = value?.get(path);293if (!hash) {294return;295}296const cur = this.buffers[model_id][path];297if (cur?.hash == hash) {298buffer_paths.push(JSON.parse(path));299buffers.push(new Uint8Array(cur.buffer).buffer);300return;301}302};303value304.keySeq()305.toJS()306.filter((path) => path.startsWith("["))307.map(f);308return { buffers, buffer_paths };309};310311private clientGetBuffer = async (model_id: string, path: string) => {312// async get of the buffer from backend313if (this.client.ipywidgetsGetBuffer == null) {314throw Error(315"NotImplementedError: frontend client must implement ipywidgetsGetBuffer in order to support binary buffers",316);317}318const b = await this.client.ipywidgetsGetBuffer(319this.syncdoc.project_id,320auxFileToOriginal(this.syncdoc.path),321model_id,322path,323);324return b;325};326327// Used on the backend by the project http server328getBuffer = (329model_id: string,330buffer_path_or_sha1: string,331): Buffer | undefined => {332const dbg = this.dbg("getBuffer");333dbg("getBuffer", model_id, buffer_path_or_sha1);334return this.buffers[model_id]?.[buffer_path_or_sha1]?.buffer;335};336337// returns the sha1 hashes of the buffers338setModelBuffers = (339// model that buffers are associated to:340model_id: string,341// if given, these are buffers with given paths; if not given, we342// store buffer associated to sha1 (which is used for custom messages)343buffer_paths: string[][] | undefined,344// the actual buffers.345buffers: Buffer[],346fire_change_event: boolean = true,347): string[] => {348const dbg = this.dbg("setModelBuffers");349dbg("buffer_paths = ", buffer_paths);350351const data: { [path: string]: boolean } = {};352if (this.buffers[model_id] == null) {353this.buffers[model_id] = {};354}355const hashes: string[] = [];356if (buffer_paths != null) {357for (let i = 0; i < buffer_paths.length; i++) {358const key = JSON.stringify(buffer_paths[i]);359// we set to the sha1 of the buffer not just to make getting360// the buffer easy, but to make it easy to KNOW if we361// even need to get the buffer.362const hash = sha1(buffers[i]);363hashes.push(hash);364data[key] = hash;365this.buffers[model_id][key] = { buffer: buffers[i], hash };366}367} else {368for (const buffer of buffers) {369const hash = sha1(buffer);370hashes.push(hash);371this.buffers[model_id][hash] = { buffer, hash };372data[hash] = hash;373}374}375this.set(model_id, "buffers", data, fire_change_event);376return hashes;377};378379/*380Setting model state and value381382- model state -- gets set once right when model is defined by kernel383- model "value" -- should be called "update"; gets set with changes to384the model state since it was created.385(I think an inefficiency with this approach is the entire updated386"value" gets broadcast each time anything about it is changed.387Fortunately usually value is small. However, it would be much388better to broadcast only the information about what changed, though389that is more difficult to implement given our current simple key:value390store sync layer. This tradeoff may be fully worth it for391our applications, since large data should be in buffers, and those392are efficient.)393*/394395set_model_value = (396model_id: string,397value: Value,398fire_change_event: boolean = true,399): void => {400this.set(model_id, "value", value, fire_change_event);401};402403set_model_state = (404model_id: string,405state: any,406fire_change_event: boolean = true,407): void => {408this.set(model_id, "state", state, fire_change_event);409};410411// Do any setting of the underlying table through this function.412set = (413model_id: string,414type: "value" | "state" | "buffers" | "message",415data: any,416fire_change_event: boolean = true,417merge?: "none" | "shallow" | "deep",418): void => {419//const dbg = this.dbg("set");420const string_id = this.syncdoc.get_string_id();421if (typeof data != "object") {422throw Error("TypeError -- data must be a map");423}424let defaultMerge: "none" | "shallow" | "deep";425if (type == "value") {426//defaultMerge = "shallow";427// we manually do the shallow merge only on the data field.428const current = this.get_model_value(model_id);429// this can be HUGE:430// dbg("value: before", { data, current });431if (current != null) {432for (const k in data) {433if (is_object(data[k]) && is_object(current[k])) {434current[k] = { ...current[k], ...data[k] };435} else {436current[k] = data[k];437}438}439data = current;440}441// dbg("value -- after", { merged: data });442defaultMerge = "none";443} else if (type == "buffers") {444// it's critical to not throw away existing buffers when445// new ones come or current ones change. With shallow merge,446// the existing ones go away, which is very broken, e.g.,447// see this with this example:448/*449import bqplot.pyplot as plt450import numpy as np451x, y = np.random.rand(2, 10)452fig = plt.figure(animation_duration=3000)453scat = plt.scatter(x=x, y=y)454fig455---456scat.x, scat.y = np.random.rand(2, 50)457458# now close and open it, and it breaks with shallow merge,459# since the second cell caused the opacity buffer to be460# deleted, which breaks everything.461*/462defaultMerge = "deep";463} else if (type == "message") {464defaultMerge = "none";465} else {466defaultMerge = "deep";467}468if (merge == null) {469merge = defaultMerge;470}471this.table.set(472{ string_id, type, model_id, data },473merge,474fire_change_event,475);476};477478save = async (): Promise<void> => {479this.gc();480await this.table.save();481};482483close = async (): Promise<void> => {484if (this.table != null) {485await this.table.close();486}487close(this);488this.set_state("closed");489};490491private dbg = (_f): Function => {492if (this.client.is_project()) {493return this.client.dbg(`IpywidgetsState.${_f}`);494} else {495return (..._) => {};496}497};498499clear = async (): Promise<void> => {500// This is used when we restart the kernel -- we reset501// things so no information about any models is known502// and delete all Buffers.503this.assert_state("ready");504const dbg = this.dbg("clear");505dbg();506507this.buffers = {};508// There's no implemented delete for tables yet, so instead we set the data509// for everything to null. All other code related to widgets needs to handle510// such data appropriately and ignore it. (An advantage of this over trying to511// implement a genuine delete is that delete is tricky when clients reconnect512// and sync...). This table is in memory only anyways, so the table will get properly513// fully flushed from existence at some point.514const keys = this.table?.get()?.keySeq()?.toJS();515if (keys == null) return; // nothing to do.516for (const key of keys) {517const [string_id, model_id, type] = JSON.parse(key);518this.table.set({ string_id, type, model_id, data: null }, "none", false);519}520await this.table.save();521};522523values = () => {524const x = this.table.get();525if (x == null) {526return [];527}528return Object.values(x.toJS()).filter((obj) => obj.data);529};530531// Clean up all data in the table about models that are not532// referenced (directly or indirectly) in any cell in the notebook.533// There is also a comm:close event/message somewhere, which534// could also be useful....?535deleteUnused = async (): Promise<void> => {536this.assert_state("ready");537const dbg = this.dbg("deleteUnused");538dbg();539// See comment in the "clear" function above about no delete for tables,540// which is why we just set the data to null.541const activeIds = this.getActiveModelIds();542this.table.get()?.forEach((val, key) => {543if (key == null || val?.get("data") == null) {544// already deleted545return;546}547const [string_id, model_id, type] = JSON.parse(key);548if (!activeIds.has(model_id)) {549// Delete this model from the table (or as close to delete as we have).550// This removes the last message, state, buffer info, and value,551// depending on type.552this.table.set(553{ string_id, type, model_id, data: null },554"none",555false,556);557558// Also delete buffers for this model, which are stored in memory, and559// won't be requested again.560delete this.buffers[model_id];561}562});563await this.table.save();564};565566// For each model in init, we add in all the ids of models567// that it explicitly references, e.g., by IPY_MODEL_[model_id] fields568// and by output messages and other things we learn about (e.g., k3d569// has its own custom references).570getReferencedModelIds = (init: string | Set<string>): Set<string> => {571const modelIds =572typeof init == "string" ? new Set([init]) : new Set<string>(init);573let before = 0;574let after = modelIds.size;575while (before < after) {576before = modelIds.size;577for (const model_id of modelIds) {578for (const type of ["state", "value"]) {579const data = this.get(model_id, type);580if (data == null) continue;581for (const id of getModelIds(data)) {582modelIds.add(id);583}584}585}586after = modelIds.size;587}588// Also any custom ways of doing referencing -- e.g., k3d does this.589this.includeThirdPartyReferences(modelIds);590591// Also anything that references any modelIds592this.includeReferenceTo(modelIds);593594return modelIds;595};596597// We find the ids of all models that are explicitly referenced598// in the current version of the Jupyter notebook by iterating through599// the output of all cells, then expanding the result to everything600// that these models reference. This is used as a foundation for601// garbage collection.602private getActiveModelIds = (): Set<string> => {603const modelIds: Set<string> = new Set();604this.syncdoc.get({ type: "cell" }).forEach((cell) => {605const output = cell.get("output");606if (output != null) {607output.forEach((mesg) => {608const model_id = mesg.getIn([609"data",610"application/vnd.jupyter.widget-view+json",611"model_id",612]);613if (model_id != null) {614// same id could of course appear in multiple cells615// if there are multiple view of the same model.616modelIds.add(model_id);617}618});619}620});621return this.getReferencedModelIds(modelIds);622};623624private includeReferenceTo = (modelIds: Set<string>) => {625// This example is extra tricky and one version of our GC broke it:626// 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])627// What happens here is that this jsdlink model ends up referencing live widgets,628// but is not referenced by any cell, so it would get garbage collected.629630let before = -1;631let after = modelIds.size;632while (before < after) {633before = modelIds.size;634this.table.get()?.forEach((val) => {635const data = val?.get("data");636if (data != null) {637for (const model_id of getModelIds(data)) {638if (modelIds.has(model_id)) {639modelIds.add(val.get("model_id"));640}641}642}643});644after = modelIds.size;645}646};647648private includeThirdPartyReferences = (modelIds: Set<string>) => {649/*650Motivation (RANT):651It seems to me that third party widgets can just invent their own652ways of referencing each other, and there's no way to know what they are653doing. The only possible way to do garbage collection is by reading654and understanding their code or reverse engineering their data.655It's not unlikely that any nontrivial third656party widget has invented it's own custom way to do object references,657and for every single one we may need to write custom code for garbage658collection, which can randomly break if they change.659<sarcasm>Yeah.</sarcasm>660/*661662/* k3d:663We handle k3d here, which creates models with664{_model_module:'k3d', _model_name:'ObjectModel', id:number}665where the id is in the object_ids attribute of some model found above:666{_model_module:'k3d', object_ids:[..., id, ...]}667But note that this format is something that was entirely just invented668arbitrarily by the k3d dev.669*/670// First get all object_ids of all active models:671// We're not explicitly restricting to k3d here, since maybe other widgets use672// this same approach, and the worst case scenario is just insufficient garbage collection.673const object_ids = new Set<number>([]);674for (const model_id of modelIds) {675for (const type of ["state", "value"]) {676this.get(model_id, type)677?.get("object_ids")678?.forEach((id) => {679object_ids.add(id);680});681}682}683if (object_ids.size == 0) {684// nothing to do -- no such object_ids in any current models.685return;686}687// let's find the models with these id's as id attribute and include them.688this.table.get()?.forEach((val) => {689if (object_ids.has(val?.getIn(["data", "id"]))) {690const model_id = val.get("model_id");691modelIds.add(model_id);692}693});694};695696// The finite state machine state, e.g., 'init' --> 'ready' --> 'close'697private set_state = (state: State): void => {698this.state = state;699this.emit(state);700};701702get_state = (): State => {703return this.state;704};705706private assert_state = (state: string): void => {707if (this.state != state) {708throw Error(`state must be "${state}" but it is "${this.state}"`);709}710};711712/*713process_comm_message_from_kernel gets called whenever the714kernel emits a comm message related to widgets. This updates715the state of the table, which results in frontends creating widgets716or updating state of widgets.717*/718process_comm_message_from_kernel = async (719msg: CommMessage,720): Promise<void> => {721const dbg = this.dbg("process_comm_message_from_kernel");722// WARNING: serializing any msg could cause huge server load, e.g., it could contain723// a 20MB buffer in it.724//dbg(JSON.stringify(msg)); // EXTREME DANGER!725//console.log("process_comm_message_from_kernel", msg);726dbg(JSON.stringify(msg.header));727this.assert_state("ready");728729const { content } = msg;730731if (content == null) {732dbg("content is null -- ignoring message");733return;734}735736if (content.data.method == "echo_update") {737// just ignore echo_update -- it's a new ipywidgets 8 mechanism738// for some level of RTC sync between clients -- we don't need that739// since we have our own, obviously. Setting the env var740// JUPYTER_WIDGETS_ECHO to 0 will disable these messages to slightly741// reduce traffic.742// NOTE: this check was lower which wrecked the buffers,743// which was a bug for a long time. :-(744return;745}746747let { comm_id } = content;748if (comm_id == null) {749if (msg.header != null) {750comm_id = msg.header.msg_id;751}752if (comm_id == null) {753dbg("comm_id is null -- ignoring message");754return;755}756}757const model_id: string = comm_id;758dbg({ model_id, comm_id });759760const { data } = content;761if (data == null) {762dbg("content.data is null -- ignoring message");763return;764}765766const { state } = data;767if (state != null) {768delete_null_fields(state);769}770771// It is critical to send any buffers data before772// the other data; otherwise, deserialization on773// the client side can't work, since it is missing774// the data it needs.775// This happens with method "update". With method="custom",776// there is just an array of buffers and no buffer_paths at all.777if (content.data.buffer_paths?.length > 0) {778// Deal with binary buffers:779dbg("setting binary buffers");780this.setModelBuffers(781model_id,782content.data.buffer_paths,783msg.buffers,784false,785);786}787788switch (content.data.method) {789case "custom":790const message = content.data.content;791const { buffers } = msg;792dbg("custom message", {793message,794buffers: `${buffers?.length ?? "no"} buffers`,795});796let buffer_hashes: string[];797if (798buffers != null &&799buffers.length > 0 &&800content.data.buffer_paths == null801) {802// TODO803dbg("custom message -- there are BUFFERS -- saving them");804buffer_hashes = this.setModelBuffers(805model_id,806undefined,807buffers,808false,809);810} else {811buffer_hashes = [];812}813// We now send the message.814this.sendCustomMessage(model_id, message, buffer_hashes, false);815break;816817case "echo_update":818return;819820case "update":821if (state == null) {822return;823}824dbg("method -- update");825if (this.clear_output[model_id] && state.outputs != null) {826// we are supposed to clear the output before inserting827// the next output.828dbg("clearing outputs");829if (state.outputs.length > 0) {830state.outputs = [state.outputs[state.outputs.length - 1]];831} else {832state.outputs = [];833}834delete this.clear_output[model_id];835}836837const last_changed =838(this.get(model_id, "value")?.get("last_changed") ?? 0) + 1;839this.set_model_value(model_id, { ...state, last_changed }, false);840841if (state.msg_id != null) {842const { msg_id } = state;843if (typeof msg_id === "string" && msg_id.length > 0) {844dbg("enabling capture output", msg_id, model_id);845if (this.capture_output[msg_id] == null) {846this.capture_output[msg_id] = [model_id];847} else {848// pushing onto stack849this.capture_output[msg_id].push(model_id);850}851} else {852const parent_msg_id = msg.parent_header.msg_id;853dbg("disabling capture output", parent_msg_id, model_id);854if (this.capture_output[parent_msg_id] != null) {855const v: string[] = [];856const w: string[] = this.capture_output[parent_msg_id];857for (const m of w) {858if (m != model_id) {859v.push(m);860}861}862if (v.length == 0) {863delete this.capture_output[parent_msg_id];864} else {865this.capture_output[parent_msg_id] = v;866}867}868}869delete state.msg_id;870}871break;872case undefined:873if (state == null) return;874dbg("method -- undefined (=set_model_state)", { model_id, state });875this.set_model_state(model_id, state, false);876break;877default:878// TODO: Implement other methods, e.g., 'display' -- see879// https://github.com/jupyter-widgets/ipywidgets/blob/master/packages/schema/messages.md880dbg(`not implemented method '${content.data.method}' -- ignoring`);881}882883await this.save();884};885886/*887process_comm_message_from_browser gets called whenever a888browser client emits a comm message related to widgets.889This updates the state of the table, which results in890other frontends updating their widget state, *AND* the backend891kernel changing the value of variables (and possibly892updating other widgets).893*/894process_comm_message_from_browser = async (895msg: CommMessage,896): Promise<void> => {897const dbg = this.dbg("process_comm_message_from_browser");898dbg(msg);899this.assert_state("ready");900// TODO: not implemented!901};902903// The mesg here is exactly what came over the IOPUB channel904// from the kernel.905906// TODO: deal with buffers907capture_output_message = (mesg: any): boolean => {908const msg_id = mesg.parent_header.msg_id;909if (this.capture_output[msg_id] == null) {910return false;911}912const dbg = this.dbg("capture_output_message");913dbg(JSON.stringify(mesg));914const model_id =915this.capture_output[msg_id][this.capture_output[msg_id].length - 1];916if (model_id == null) return false; // should not happen.917918if (mesg.header.msg_type == "clear_output") {919if (mesg.content?.wait) {920this.clear_output[model_id] = true;921} else {922delete this.clear_output[model_id];923this.clearOutputBuffers(model_id);924this.set_model_value(model_id, { outputs: null });925}926return true;927}928929if (mesg.content == null || len(mesg.content) == 0) {930// no actual content.931return false;932}933934let outputs: any;935if (this.clear_output[model_id]) {936delete this.clear_output[model_id];937this.clearOutputBuffers(model_id);938outputs = [];939} else {940outputs = this.get_model_value(model_id).outputs;941if (outputs == null) {942outputs = [];943}944}945outputs.push(mesg.content);946this.set_model_value(model_id, { outputs });947return true;948};949950private clearOutputBuffers = (model_id: string) => {951// TODO: need to clear all output buffers.952/* Example where if you do not properly clear buffers, then broken output re-appears:953954import ipywidgets as widgets955from IPython.display import YouTubeVideo956out = widgets.Output(layout={'border': '1px solid black'})957out.append_stdout('Output appended with append_stdout')958out.append_display_data(YouTubeVideo('eWzY2nGfkXk'))959out960961---962963out.clear_output()964965---966967with out:968print('hi')969*/970// TODO!!!!971972const y: any = {};973let n = 0;974for (const jsonPath of this.get(model_id, "buffers")?.keySeq() ?? []) {975const path = JSON.parse(jsonPath);976if (path[0] == "outputs") {977y[jsonPath] = "";978n += 1;979}980}981if (n > 0) {982this.set(model_id, "buffers", y, true, "shallow");983}984};985986private sendCustomMessage = async (987model_id: string,988message: object,989buffer_hashes: string[],990fire_change_event: boolean = true,991): Promise<void> => {992/*993Send a custom message.994995It's not at all clear what this should even mean in the context of996realtime collaboration, and there will likely be clients where997this is bad. But for now, we just make the last message sent998available via the table, and each successive message overwrites the previous999one. Any clients that are connected while we do this can react,1000and any that aren't just don't get the message (which is presumably fine).10011002Some widgets like ipympl use this to initialize state, so when a new1003client connects, it requests a message describing the plot, and everybody1004receives it.1005*/10061007this.set(1008model_id,1009"message",1010{ message, buffer_hashes, time: Date.now() },1011fire_change_event,1012);1013};10141015// Return the most recent message for the given model.1016getMessage = async (1017model_id: string,1018): Promise<{ message: object; buffers: ArrayBuffer[] } | undefined> => {1019const x = this.get(model_id, "message")?.toJS();1020if (x == null) {1021return undefined;1022}1023if (Date.now() - (x.time ?? 0) >= MAX_MESSAGE_TIME_MS) {1024return undefined;1025}1026const { message, buffer_hashes } = x;1027let buffers: ArrayBuffer[] = [];1028for (const hash of buffer_hashes) {1029buffers.push(await this.clientGetBuffer(model_id, hash));1030}1031return { message, buffers };1032};1033}10341035// Get model id's that appear either as serialized references1036// of the form IPY_MODEL_....1037// or in output messages.1038function getModelIds(x): Set<string> {1039const ids: Set<string> = new Set();1040x?.forEach((val, key) => {1041if (key == "application/vnd.jupyter.widget-view+json") {1042const model_id = val.get("model_id");1043if (model_id) {1044ids.add(model_id);1045}1046} else if (typeof val == "string") {1047if (val.startsWith("IPY_MODEL_")) {1048ids.add(val.slice("IPY_MODEL_".length));1049}1050} else if (val.forEach != null) {1051for (const z of getModelIds(val)) {1052ids.add(z);1053}1054}1055});1056return ids;1057}105810591060