CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/sync/editor/generic/ipywidgets-state.ts
Views: 687
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/*
7
NOTE: Like much of our Jupyter-related code in CoCalc,
8
the code in this file is very much run in *both* the
9
frontend web browser and backend project server.
10
*/
11
12
import { EventEmitter } from "events";
13
import { Map as iMap } from "immutable";
14
import {
15
close,
16
delete_null_fields,
17
is_object,
18
len,
19
auxFileToOriginal,
20
} from "@cocalc/util/misc";
21
import { SyncDoc } from "./sync-doc";
22
import { SyncTable } from "@cocalc/sync/table/synctable";
23
import { Client } from "./types";
24
import { debounce } from "lodash";
25
import sha1 from "sha1";
26
27
type State = "init" | "ready" | "closed";
28
29
type Value = { [key: string]: any };
30
31
// When there is no activity for this much time, them we
32
// do some garbage collection. This is only done in the
33
// backend project, and not by frontend browser clients.
34
// The garbage collection is deleting models and related
35
// data when they are not referenced in the notebook.
36
// Also, we don't implement complete object delete yet so instead we
37
// set the data field to null, which clears all state about and
38
// object and makes it easy to know to ignore it.
39
const GC_DEBOUNCE_MS = 10000;
40
41
// If for some reason GC needs to be deleted, e.g., maybe you
42
// suspect a bug, just toggle this flag. In particular, note
43
// includeThirdPartyReferences below that has to deal with a special
44
// case schema that k3d uses for references, which they just made up,
45
// which works with official upstream, since that has no garbage
46
// collection.
47
const DISABLE_GC = false;
48
49
// ignore messages past this age.
50
const MAX_MESSAGE_TIME_MS = 10000;
51
52
interface CommMessage {
53
header: { msg_id: string };
54
parent_header: { msg_id: string };
55
content: any;
56
buffers: any[];
57
}
58
59
export interface Message {
60
// don't know yet...
61
}
62
63
export type SerializedModelState = { [key: string]: any };
64
65
export class IpywidgetsState extends EventEmitter {
66
private syncdoc: SyncDoc;
67
private client: Client;
68
private table: SyncTable;
69
private state: State = "init";
70
private table_options: any[] = [];
71
private create_synctable: Function;
72
private gc: Function;
73
74
// TODO: garbage collect this, both on the frontend and backend.
75
// This should be done in conjunction with the main table (with gc
76
// on backend, and with change to null event on the frontend).
77
private buffers: {
78
[model_id: string]: {
79
[path: string]: { buffer: Buffer; hash: string };
80
};
81
} = {};
82
// Similar but used on frontend
83
private arrayBuffers: {
84
[model_id: string]: {
85
[path: string]: { buffer: ArrayBuffer; hash: string };
86
};
87
} = {};
88
89
// If capture_output[msg_id] is defined, then
90
// all output with that msg_id is captured by the
91
// widget with given model_id. This data structure
92
// is ONLY used in the project, and is not synced
93
// between frontends and project.
94
private capture_output: { [msg_id: string]: string[] } = {};
95
96
// If the next output should be cleared. Use for
97
// clear_output with wait=true.
98
private clear_output: { [model_id: string]: boolean } = {};
99
100
constructor(syncdoc: SyncDoc, client: Client, create_synctable: Function) {
101
super();
102
this.syncdoc = syncdoc;
103
this.client = client;
104
this.create_synctable = create_synctable;
105
if (this.syncdoc.data_server == "project") {
106
// options only supported for project...
107
// ephemeral -- don't store longterm in database
108
// persistent -- doesn't automatically vanish when all browser clients disconnect
109
this.table_options = [{ ephemeral: true, persistent: true }];
110
}
111
this.gc =
112
!DISABLE_GC && client.is_project() // no-op if not project or DISABLE_GC
113
? debounce(() => {
114
// return; // temporarily disabled since it is still too aggressive
115
if (this.state == "ready") {
116
this.deleteUnused();
117
}
118
}, GC_DEBOUNCE_MS)
119
: () => {};
120
}
121
122
init = async (): Promise<void> => {
123
const query = {
124
ipywidgets: [
125
{
126
string_id: this.syncdoc.get_string_id(),
127
model_id: null,
128
type: null,
129
data: null,
130
},
131
],
132
};
133
this.table = await this.create_synctable(query, this.table_options, 0);
134
135
// TODO: here the project should clear the table.
136
137
this.set_state("ready");
138
139
this.table.on("change", (keys) => {
140
this.emit("change", keys);
141
});
142
};
143
144
keys = (): { model_id: string; type: "value" | "state" | "buffer" }[] => {
145
// return type is arrow of s
146
this.assert_state("ready");
147
const x = this.table.get();
148
if (x == null) {
149
return [];
150
}
151
const keys: { model_id: string; type: "value" | "state" | "buffer" }[] = [];
152
x.forEach((val, key) => {
153
if (val.get("data") != null && key != null) {
154
const [, model_id, type] = JSON.parse(key);
155
keys.push({ model_id, type });
156
}
157
});
158
return keys;
159
};
160
161
get = (model_id: string, type: string): iMap<string, any> | undefined => {
162
const key: string = JSON.stringify([
163
this.syncdoc.get_string_id(),
164
model_id,
165
type,
166
]);
167
const record = this.table.get(key);
168
if (record == null) {
169
return undefined;
170
}
171
return record.get("data");
172
};
173
174
// assembles together state we know about the widget with given model_id
175
// from info in the table, and returns it as a Javascript object.
176
getSerializedModelState = (
177
model_id: string,
178
): SerializedModelState | undefined => {
179
this.assert_state("ready");
180
const state = this.get(model_id, "state");
181
if (state == null) {
182
return undefined;
183
}
184
const state_js = state.toJS();
185
let value: any = this.get(model_id, "value");
186
if (value != null) {
187
value = value.toJS();
188
if (value == null) {
189
throw Error("value must be a map");
190
}
191
for (const key in value) {
192
state_js[key] = value[key];
193
}
194
}
195
return state_js;
196
};
197
198
get_model_value = (model_id: string): Value => {
199
this.assert_state("ready");
200
let value: any = this.get(model_id, "value");
201
if (value == null) {
202
return {};
203
}
204
value = value.toJS();
205
if (value == null) {
206
return {};
207
}
208
return value;
209
};
210
211
/*
212
Setting and getting buffers.
213
214
- Setting the model buffers only happens on the backend project.
215
This is done in response to a comm message from the kernel
216
that has content.data.buffer_paths set.
217
218
- Getting the model buffers only happens in the frontend browser.
219
This happens when creating models that support widgets, and often
220
happens in conjunction with deserialization.
221
222
Getting a model buffer for a given path can happen
223
*at any time* after the buffer is created, not just right when
224
it is created like in JupyterLab! The reason is because a browser
225
can connect or get refreshed at any time, and then they need the
226
buffer to reconstitue the model. Moreover, a user might only
227
scroll the widget into view in their (virtualized) notebook at any
228
point, and it is only then that point the model gets created.
229
This means that we have to store and garbage collect model
230
buffers, which is a problem I don't think upstream ipywidgets
231
has to solve.
232
*/
233
getModelBuffers = async (
234
model_id: string,
235
): Promise<{
236
buffer_paths: string[][];
237
buffers: ArrayBuffer[];
238
}> => {
239
let value: iMap<string, string> | undefined = this.get(model_id, "buffers");
240
if (value == null) {
241
return { buffer_paths: [], buffers: [] };
242
}
243
// value is an array from JSON of paths array to array buffers:
244
const buffer_paths: string[][] = [];
245
const buffers: ArrayBuffer[] = [];
246
if (this.arrayBuffers[model_id] == null) {
247
this.arrayBuffers[model_id] = {};
248
}
249
const f = async (path: string) => {
250
const hash = value?.get(path);
251
if (!hash) {
252
// It is important to look for !hash, since we use hash='' as a sentinel (in this.clearOutputBuffers)
253
// to indicate that we want to consider a buffer as having been deleted. This is very important
254
// to do since large outputs are often buffers in output widgets, and clear_output
255
// then needs to delete those buffers, or output never goes away.
256
return;
257
}
258
const cur = this.arrayBuffers[model_id][path];
259
if (cur?.hash == hash) {
260
buffer_paths.push(JSON.parse(path));
261
buffers.push(cur.buffer);
262
return;
263
}
264
try {
265
const buffer = await this.clientGetBuffer(model_id, path);
266
this.arrayBuffers[model_id][path] = { buffer, hash };
267
buffer_paths.push(JSON.parse(path));
268
buffers.push(buffer);
269
} catch (err) {
270
console.log(`skipping ${model_id}, ${path} due to ${err}`);
271
}
272
};
273
// Run f in parallel on all of the keys of value:
274
await Promise.all(
275
value
276
.keySeq()
277
.toJS()
278
.filter((path) => path.startsWith("["))
279
.map(f),
280
);
281
return { buffers, buffer_paths };
282
};
283
284
// This is used on the backend when syncing changes from project nodejs *to*
285
// the jupyter kernel.
286
getKnownBuffers = (model_id: string) => {
287
let value: iMap<string, string> | undefined = this.get(model_id, "buffers");
288
if (value == null) {
289
return { buffer_paths: [], buffers: [] };
290
}
291
// value is an array from JSON of paths array to array buffers:
292
const buffer_paths: string[][] = [];
293
const buffers: ArrayBuffer[] = [];
294
if (this.buffers[model_id] == null) {
295
this.buffers[model_id] = {};
296
}
297
const f = (path: string) => {
298
const hash = value?.get(path);
299
if (!hash) {
300
return;
301
}
302
const cur = this.buffers[model_id][path];
303
if (cur?.hash == hash) {
304
buffer_paths.push(JSON.parse(path));
305
buffers.push(cur.buffer);
306
return;
307
}
308
};
309
value
310
.keySeq()
311
.toJS()
312
.filter((path) => path.startsWith("["))
313
.map(f);
314
return { buffers, buffer_paths };
315
};
316
317
private clientGetBuffer = async (model_id: string, path: string) => {
318
// async get of the buffer from backend
319
if (this.client.ipywidgetsGetBuffer == null) {
320
throw Error(
321
"NotImplementedError: frontend client must implement ipywidgetsGetBuffer in order to support binary buffers",
322
);
323
}
324
return await this.client.ipywidgetsGetBuffer(
325
this.syncdoc.project_id,
326
auxFileToOriginal(this.syncdoc.path),
327
model_id,
328
path,
329
);
330
};
331
332
// Used on the backend by the project http server
333
getBuffer = (
334
model_id: string,
335
buffer_path_or_sha1: string,
336
): Buffer | undefined => {
337
const dbg = this.dbg("getBuffer");
338
dbg("getBuffer", model_id, buffer_path_or_sha1);
339
return this.buffers[model_id]?.[buffer_path_or_sha1]?.buffer;
340
};
341
342
// returns the sha1 hashes of the buffers
343
setModelBuffers = (
344
// model that buffers are associated to:
345
model_id: string,
346
// if given, these are buffers with given paths; if not given, we
347
// store buffer associated to sha1 (which is used for custom messages)
348
buffer_paths: string[][] | undefined,
349
// the actual buffers.
350
buffers: Buffer[],
351
fire_change_event: boolean = true,
352
): string[] => {
353
const dbg = this.dbg("setModelBuffers");
354
dbg("buffer_paths = ", buffer_paths);
355
356
const data: { [path: string]: boolean } = {};
357
if (this.buffers[model_id] == null) {
358
this.buffers[model_id] = {};
359
}
360
const hashes: string[] = [];
361
if (buffer_paths != null) {
362
for (let i = 0; i < buffer_paths.length; i++) {
363
const key = JSON.stringify(buffer_paths[i]);
364
// we set to the sha1 of the buffer not just to make getting
365
// the buffer easy, but to make it easy to KNOW if we
366
// even need to get the buffer.
367
const hash = sha1(buffers[i]);
368
hashes.push(hash);
369
data[key] = hash;
370
this.buffers[model_id][key] = { buffer: buffers[i], hash };
371
}
372
} else {
373
for (const buffer of buffers) {
374
const hash = sha1(buffer);
375
hashes.push(hash);
376
this.buffers[model_id][hash] = { buffer, hash };
377
data[hash] = hash;
378
}
379
}
380
this.set(model_id, "buffers", data, fire_change_event);
381
return hashes;
382
};
383
384
/*
385
Setting model state and value
386
387
- model state -- gets set once right when model is defined by kernel
388
- model "value" -- should be called "update"; gets set with changes to
389
the model state since it was created.
390
(I think an inefficiency with this approach is the entire updated
391
"value" gets broadcast each time anything about it is changed.
392
Fortunately usually value is small. However, it would be much
393
better to broadcast only the information about what changed, though
394
that is more difficult to implement given our current simple key:value
395
store sync layer. This tradeoff may be fully worth it for
396
our applications, since large data should be in buffers, and those
397
are efficient.)
398
*/
399
400
set_model_value = (
401
model_id: string,
402
value: Value,
403
fire_change_event: boolean = true,
404
): void => {
405
this.set(model_id, "value", value, fire_change_event);
406
};
407
408
set_model_state = (
409
model_id: string,
410
state: any,
411
fire_change_event: boolean = true,
412
): void => {
413
this.set(model_id, "state", state, fire_change_event);
414
};
415
416
// Do any setting of the underlying table through this function.
417
set = (
418
model_id: string,
419
type: "value" | "state" | "buffers" | "message",
420
data: any,
421
fire_change_event: boolean = true,
422
merge?: "none" | "shallow" | "deep",
423
): void => {
424
const dbg = this.dbg("set");
425
const string_id = this.syncdoc.get_string_id();
426
if (typeof data != "object") {
427
throw Error("TypeError -- data must be a map");
428
}
429
let defaultMerge: "none" | "shallow" | "deep";
430
if (type == "value") {
431
//defaultMerge = "shallow";
432
// we manually do the shallow merge only on the data field.
433
const current = this.get_model_value(model_id);
434
dbg("value: before", { data, current });
435
if (current != null) {
436
for (const k in data) {
437
if (is_object(data[k]) && is_object(current[k])) {
438
current[k] = { ...current[k], ...data[k] };
439
} else {
440
current[k] = data[k];
441
}
442
}
443
data = current;
444
}
445
dbg("value -- after", { merged: data });
446
defaultMerge = "none";
447
} else if (type == "buffers") {
448
// it's critical to not throw away existing buffers when
449
// new ones come or current ones change. With shallow merge,
450
// the existing ones go away, which is very broken, e.g.,
451
// see this with this example:
452
/*
453
import bqplot.pyplot as plt
454
import numpy as np
455
x, y = np.random.rand(2, 10)
456
fig = plt.figure(animation_duration=3000)
457
scat = plt.scatter(x=x, y=y)
458
fig
459
---
460
scat.x, scat.y = np.random.rand(2, 50)
461
462
# now close and open it, and it breaks with shallow merge,
463
# since the second cell caused the opacity buffer to be
464
# deleted, which breaks everything.
465
*/
466
defaultMerge = "deep";
467
} else if (type == "message") {
468
defaultMerge = "none";
469
} else {
470
defaultMerge = "deep";
471
}
472
if (merge == null) {
473
merge = defaultMerge;
474
}
475
this.table.set(
476
{ string_id, type, model_id, data },
477
merge,
478
fire_change_event,
479
);
480
};
481
482
save = async (): Promise<void> => {
483
this.gc();
484
await this.table.save();
485
};
486
487
close = async (): Promise<void> => {
488
if (this.table != null) {
489
await this.table.close();
490
}
491
close(this);
492
this.set_state("closed");
493
};
494
495
private dbg = (_f): Function => {
496
if (this.client.is_project()) {
497
return this.client.dbg(`IpywidgetsState.${_f}`);
498
} else {
499
return (..._) => {};
500
}
501
};
502
503
clear = async (): Promise<void> => {
504
// This is used when we restart the kernel -- we reset
505
// things so no information about any models is known
506
// and delete all Buffers.
507
this.assert_state("ready");
508
const dbg = this.dbg("clear");
509
dbg();
510
511
this.buffers = {};
512
// There's no implemented delete for tables yet, so instead we set the data
513
// for everything to null. All other code related to widgets needs to handle
514
// such data appropriately and ignore it. (An advantage of this over trying to
515
// implement a genuine delete is that delete is tricky when clients reconnect
516
// and sync...). This table is in memory only anyways, so the table will get properly
517
// fully flushed from existence at some point.
518
const keys = this.table?.get()?.keySeq()?.toJS();
519
if (keys == null) return; // nothing to do.
520
for (const key of keys) {
521
const [string_id, model_id, type] = JSON.parse(key);
522
this.table.set({ string_id, type, model_id, data: null }, "none", false);
523
}
524
await this.table.save();
525
};
526
527
values = () => {
528
const x = this.table.get();
529
if (x == null) {
530
return [];
531
}
532
return Object.values(x.toJS()).filter((obj) => obj.data);
533
};
534
535
// Clean up all data in the table about models that are not
536
// referenced (directly or indirectly) in any cell in the notebook.
537
// There is also a comm:close event/message somewhere, which
538
// could also be useful....?
539
deleteUnused = async (): Promise<void> => {
540
this.assert_state("ready");
541
const dbg = this.dbg("deleteUnused");
542
dbg();
543
// See comment in the "clear" function above about no delete for tables,
544
// which is why we just set the data to null.
545
const activeIds = this.getActiveModelIds();
546
this.table.get()?.forEach((val, key) => {
547
if (key == null || val?.get("data") == null) {
548
// already deleted
549
return;
550
}
551
const [string_id, model_id, type] = JSON.parse(key);
552
if (!activeIds.has(model_id)) {
553
// Delete this model from the table (or as close to delete as we have).
554
// This removes the last message, state, buffer info, and value,
555
// depending on type.
556
this.table.set(
557
{ string_id, type, model_id, data: null },
558
"none",
559
false,
560
);
561
562
// Also delete buffers for this model, which are stored in memory, and
563
// won't be requested again.
564
delete this.buffers[model_id];
565
}
566
});
567
await this.table.save();
568
};
569
570
// For each model in init, we add in all the ids of models
571
// that it explicitly references, e.g., by IPY_MODEL_[model_id] fields
572
// and by output messages and other things we learn about (e.g., k3d
573
// has its own custom references).
574
getReferencedModelIds = (init: string | Set<string>): Set<string> => {
575
const modelIds =
576
typeof init == "string" ? new Set([init]) : new Set<string>(init);
577
let before = 0;
578
let after = modelIds.size;
579
while (before < after) {
580
before = modelIds.size;
581
for (const model_id of modelIds) {
582
for (const type of ["state", "value"]) {
583
const data = this.get(model_id, type);
584
if (data == null) continue;
585
for (const id of getModelIds(data)) {
586
modelIds.add(id);
587
}
588
}
589
}
590
after = modelIds.size;
591
}
592
// Also any custom ways of doing referencing -- e.g., k3d does this.
593
this.includeThirdPartyReferences(modelIds);
594
595
// Also anything that references any modelIds
596
this.includeReferenceTo(modelIds);
597
598
return modelIds;
599
};
600
601
// We find the ids of all models that are explicitly referenced
602
// in the current version of the Jupyter notebook by iterating through
603
// the output of all cells, then expanding the result to everything
604
// that these models reference. This is used as a foundation for
605
// garbage collection.
606
private getActiveModelIds = (): Set<string> => {
607
const modelIds: Set<string> = new Set();
608
this.syncdoc.get({ type: "cell" }).forEach((cell) => {
609
const output = cell.get("output");
610
if (output != null) {
611
output.forEach((mesg) => {
612
const model_id = mesg.getIn([
613
"data",
614
"application/vnd.jupyter.widget-view+json",
615
"model_id",
616
]);
617
if (model_id != null) {
618
// same id could of course appear in multiple cells
619
// if there are multiple view of the same model.
620
modelIds.add(model_id);
621
}
622
});
623
}
624
});
625
return this.getReferencedModelIds(modelIds);
626
};
627
628
private includeReferenceTo = (modelIds: Set<string>) => {
629
// This example is extra tricky and one version of our GC broke it:
630
// 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])
631
// What happens here is that this jsdlink model ends up referencing live widgets,
632
// but is not referenced by any cell, so it would get garbage collected.
633
634
let before = -1;
635
let after = modelIds.size;
636
while (before < after) {
637
before = modelIds.size;
638
this.table.get()?.forEach((val) => {
639
const data = val?.get("data");
640
if (data != null) {
641
for (const model_id of getModelIds(data)) {
642
if (modelIds.has(model_id)) {
643
modelIds.add(val.get("model_id"));
644
}
645
}
646
}
647
});
648
after = modelIds.size;
649
}
650
};
651
652
private includeThirdPartyReferences = (modelIds: Set<string>) => {
653
/*
654
Motivation (RANT):
655
It seems to me that third party widgets can just invent their own
656
ways of referencing each other, and there's no way to know what they are
657
doing. The only possible way to do garbage collection is by reading
658
and understanding their code or reverse engineering their data.
659
It's not unlikely that any nontrivial third
660
party widget has invented it's own custom way to do object references,
661
and for every single one we may need to write custom code for garbage
662
collection, which can randomly break if they change.
663
<sarcasm>Yeah.</sarcasm>
664
/*
665
666
/* k3d:
667
We handle k3d here, which creates models with
668
{_model_module:'k3d', _model_name:'ObjectModel', id:number}
669
where the id is in the object_ids attribute of some model found above:
670
{_model_module:'k3d', object_ids:[..., id, ...]}
671
But note that this format is something that was entirely just invented
672
arbitrarily by the k3d dev.
673
*/
674
// First get all object_ids of all active models:
675
// We're not explicitly restricting to k3d here, since maybe other widgets use
676
// this same approach, and the worst case scenario is just insufficient garbage collection.
677
const object_ids = new Set<number>([]);
678
for (const model_id of modelIds) {
679
for (const type of ["state", "value"]) {
680
this.get(model_id, type)
681
?.get("object_ids")
682
?.forEach((id) => {
683
object_ids.add(id);
684
});
685
}
686
}
687
if (object_ids.size == 0) {
688
// nothing to do -- no such object_ids in any current models.
689
return;
690
}
691
// let's find the models with these id's as id attribute and include them.
692
this.table.get()?.forEach((val) => {
693
if (object_ids.has(val?.getIn(["data", "id"]))) {
694
const model_id = val.get("model_id");
695
modelIds.add(model_id);
696
}
697
});
698
};
699
700
// The finite state machine state, e.g., 'init' --> 'ready' --> 'close'
701
private set_state = (state: State): void => {
702
this.state = state;
703
this.emit(state);
704
};
705
706
get_state = (): State => {
707
return this.state;
708
};
709
710
private assert_state = (state: string): void => {
711
if (this.state != state) {
712
throw Error(`state must be "${state}" but it is "${this.state}"`);
713
}
714
};
715
716
/*
717
process_comm_message_from_kernel gets called whenever the
718
kernel emits a comm message related to widgets. This updates
719
the state of the table, which results in frontends creating widgets
720
or updating state of widgets.
721
*/
722
process_comm_message_from_kernel = async (
723
msg: CommMessage,
724
): Promise<void> => {
725
const dbg = this.dbg("process_comm_message_from_kernel");
726
// WARNING: serializing any msg could cause huge server load, e.g., it could contain
727
// a 20MB buffer in it.
728
//dbg(JSON.stringify(msg)); // EXTREME DANGER!
729
dbg(JSON.stringify(msg.header));
730
this.assert_state("ready");
731
732
const { content } = msg;
733
734
if (content == null) {
735
dbg("content is null -- ignoring message");
736
return;
737
}
738
739
let { comm_id } = content;
740
if (comm_id == null) {
741
if (msg.header != null) {
742
comm_id = msg.header.msg_id;
743
}
744
if (comm_id == null) {
745
dbg("comm_id is null -- ignoring message");
746
return;
747
}
748
}
749
const model_id: string = comm_id;
750
dbg({ model_id, comm_id });
751
752
const { data } = content;
753
if (data == null) {
754
dbg("content.data is null -- ignoring message");
755
return;
756
}
757
758
const { state } = data;
759
if (state != null) {
760
delete_null_fields(state);
761
}
762
763
// It is critical to send any buffers data before
764
// the other data; otherwise, deserialization on
765
// the client side can't work, since it is missing
766
// the data it needs.
767
// This happens with method "update". With method="custom",
768
// there is just an array of buffers and no buffer_paths at all.
769
if (content.data.buffer_paths?.length > 0) {
770
// Deal with binary buffers:
771
dbg("setting binary buffers");
772
this.setModelBuffers(
773
model_id,
774
content.data.buffer_paths,
775
msg.buffers,
776
false,
777
);
778
}
779
780
switch (content.data.method) {
781
case "custom":
782
const message = content.data.content;
783
const { buffers } = msg;
784
dbg("custom message", {
785
message,
786
buffers: `${buffers?.length ?? "no"} buffers`,
787
});
788
let buffer_hashes: string[];
789
if (
790
buffers != null &&
791
buffers.length > 0 &&
792
content.data.buffer_paths == null
793
) {
794
// TODO
795
dbg("custom message -- there are BUFFERS -- saving them");
796
buffer_hashes = this.setModelBuffers(
797
model_id,
798
undefined,
799
buffers,
800
false,
801
);
802
} else {
803
buffer_hashes = [];
804
}
805
// We now send the message.
806
this.sendCustomMessage(model_id, message, buffer_hashes, false);
807
break;
808
809
case "echo_update":
810
// just ignore echo_update -- it's a new ipywidgets 8 mechanism
811
// for some level of RTC sync between clients -- we don't need that
812
// since we have our own, obviously. Setting the env var
813
// JUPYTER_WIDGETS_ECHO to 0 will disable these messages to slightly
814
// reduce traffic.
815
return;
816
817
case "update":
818
if (state == null) {
819
return;
820
}
821
dbg("method -- update");
822
if (this.clear_output[model_id] && state.outputs != null) {
823
// we are supposed to clear the output before inserting
824
// the next output.
825
dbg("clearing outputs");
826
if (state.outputs.length > 0) {
827
state.outputs = [state.outputs[state.outputs.length - 1]];
828
} else {
829
state.outputs = [];
830
}
831
delete this.clear_output[model_id];
832
}
833
834
const last_changed =
835
(this.get(model_id, "value")?.get("last_changed") ?? 0) + 1;
836
this.set_model_value(model_id, { ...state, last_changed }, false);
837
838
if (state.msg_id != null) {
839
const { msg_id } = state;
840
if (typeof msg_id === "string" && msg_id.length > 0) {
841
dbg("enabling capture output", msg_id, model_id);
842
if (this.capture_output[msg_id] == null) {
843
this.capture_output[msg_id] = [model_id];
844
} else {
845
// pushing onto stack
846
this.capture_output[msg_id].push(model_id);
847
}
848
} else {
849
const parent_msg_id = msg.parent_header.msg_id;
850
dbg("disabling capture output", parent_msg_id, model_id);
851
if (this.capture_output[parent_msg_id] != null) {
852
const v: string[] = [];
853
const w: string[] = this.capture_output[parent_msg_id];
854
for (const m of w) {
855
if (m != model_id) {
856
v.push(m);
857
}
858
}
859
if (v.length == 0) {
860
delete this.capture_output[parent_msg_id];
861
} else {
862
this.capture_output[parent_msg_id] = v;
863
}
864
}
865
}
866
delete state.msg_id;
867
}
868
break;
869
case undefined:
870
if (state == null) return;
871
dbg("method -- undefined (=set_model_state)", { model_id, state });
872
this.set_model_state(model_id, state, false);
873
break;
874
default:
875
// TODO: Implement other methods, e.g., 'display' -- see
876
// https://github.com/jupyter-widgets/ipywidgets/blob/master/packages/schema/messages.md
877
dbg(`not implemented method '${content.data.method}' -- ignoring`);
878
}
879
880
await this.save();
881
};
882
883
/*
884
process_comm_message_from_browser gets called whenever a
885
browser client emits a comm message related to widgets.
886
This updates the state of the table, which results in
887
other frontends updating their widget state, *AND* the backend
888
kernel changing the value of variables (and possibly
889
updating other widgets).
890
*/
891
process_comm_message_from_browser = async (
892
msg: CommMessage,
893
): Promise<void> => {
894
const dbg = this.dbg("process_comm_message_from_browser");
895
dbg(msg);
896
this.assert_state("ready");
897
// TODO: not implemented!
898
};
899
900
// The mesg here is exactly what came over the IOPUB channel
901
// from the kernel.
902
903
// TODO: deal with buffers
904
capture_output_message = (mesg: any): boolean => {
905
const msg_id = mesg.parent_header.msg_id;
906
if (this.capture_output[msg_id] == null) {
907
return false;
908
}
909
const dbg = this.dbg("capture_output_message");
910
dbg(JSON.stringify(mesg));
911
const model_id =
912
this.capture_output[msg_id][this.capture_output[msg_id].length - 1];
913
if (model_id == null) return false; // should not happen.
914
915
if (mesg.header.msg_type == "clear_output") {
916
if (mesg.content?.wait) {
917
this.clear_output[model_id] = true;
918
} else {
919
delete this.clear_output[model_id];
920
this.clearOutputBuffers(model_id);
921
this.set_model_value(model_id, { outputs: null });
922
}
923
return true;
924
}
925
926
if (mesg.content == null || len(mesg.content) == 0) {
927
// no actual content.
928
return false;
929
}
930
931
let outputs: any;
932
if (this.clear_output[model_id]) {
933
delete this.clear_output[model_id];
934
this.clearOutputBuffers(model_id);
935
outputs = [];
936
} else {
937
outputs = this.get_model_value(model_id).outputs;
938
if (outputs == null) {
939
outputs = [];
940
}
941
}
942
outputs.push(mesg.content);
943
this.set_model_value(model_id, { outputs });
944
return true;
945
};
946
947
private clearOutputBuffers = (model_id: string) => {
948
// TODO: need to clear all output buffers.
949
/* Example where if you do not properly clear buffers, then broken output re-appears:
950
951
import ipywidgets as widgets
952
from IPython.display import YouTubeVideo
953
out = widgets.Output(layout={'border': '1px solid black'})
954
out.append_stdout('Output appended with append_stdout')
955
out.append_display_data(YouTubeVideo('eWzY2nGfkXk'))
956
out
957
958
---
959
960
out.clear_output()
961
962
---
963
964
with out:
965
print('hi')
966
*/
967
// TODO!!!!
968
969
const y: any = {};
970
let n = 0;
971
for (const jsonPath of this.get(model_id, "buffers")?.keySeq() ?? []) {
972
const path = JSON.parse(jsonPath);
973
console.log("path = ", path);
974
if (path[0] == "outputs") {
975
y[jsonPath] = "";
976
n += 1;
977
}
978
}
979
console.log("y = ", y);
980
if (n > 0) {
981
this.set(model_id, "buffers", y, true, "shallow");
982
}
983
};
984
985
private sendCustomMessage = async (
986
model_id: string,
987
message: object,
988
buffer_hashes: string[],
989
fire_change_event: boolean = true,
990
): Promise<void> => {
991
/*
992
Send a custom message.
993
994
It's not at all clear what this should even mean in the context of
995
realtime collaboration, and there will likely be clients where
996
this is bad. But for now, we just make the last message sent
997
available via the table, and each successive message overwrites the previous
998
one. Any clients that are connected while we do this can react,
999
and any that aren't just don't get the message (which is presumably fine).
1000
1001
Some widgets like ipympl use this to initialize state, so when a new
1002
client connects, it requests a message describing the plot, and everybody
1003
receives it.
1004
*/
1005
1006
this.set(
1007
model_id,
1008
"message",
1009
{ message, buffer_hashes, time: Date.now() },
1010
fire_change_event,
1011
);
1012
};
1013
1014
// Return the most recent message for the given model.
1015
getMessage = async (
1016
model_id: string,
1017
): Promise<{ message: object; buffers: ArrayBuffer[] } | undefined> => {
1018
const x = this.get(model_id, "message")?.toJS();
1019
if (x == null) {
1020
return undefined;
1021
}
1022
if (Date.now() - (x.time ?? 0) >= MAX_MESSAGE_TIME_MS) {
1023
return undefined;
1024
}
1025
const { message, buffer_hashes } = x;
1026
let buffers: ArrayBuffer[] = [];
1027
for (const hash of buffer_hashes) {
1028
buffers.push(await this.clientGetBuffer(model_id, hash));
1029
}
1030
return { message, buffers };
1031
};
1032
}
1033
1034
// Get model id's that appear either as serialized references
1035
// of the form IPY_MODEL_....
1036
// or in output messages.
1037
function getModelIds(x): Set<string> {
1038
const ids: Set<string> = new Set();
1039
x?.forEach((val, key) => {
1040
if (key == "application/vnd.jupyter.widget-view+json") {
1041
const model_id = val.get("model_id");
1042
if (model_id) {
1043
ids.add(model_id);
1044
}
1045
} else if (typeof val == "string") {
1046
if (val.startsWith("IPY_MODEL_")) {
1047
ids.add(val.slice("IPY_MODEL_".length));
1048
}
1049
} else if (val.forEach != null) {
1050
for (const z of getModelIds(val)) {
1051
ids.add(z);
1052
}
1053
}
1054
});
1055
return ids;
1056
}
1057
1058