Path: blob/master/src/packages/jupyter/redux/project-actions.ts
1447 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6project-actions: additional actions that are only available in the7backend/project, which "manages" everything.89This code should not *explicitly* require anything that is only10available in the project or requires node to run, so that we can11fully unit test it via mocking of components.1213NOTE: this is also now the actions used by remote compute servers as well.14*/1516import { get_kernel_data } from "@cocalc/jupyter/kernel/kernel-data";17import * as immutable from "immutable";18import json_stable from "json-stable-stringify";19import { debounce } from "lodash";20import {21JupyterActions as JupyterActions0,22MAX_OUTPUT_MESSAGES,23} from "@cocalc/jupyter/redux/actions";24import { callback2, once } from "@cocalc/util/async-utils";25import * as misc from "@cocalc/util/misc";26import { OutputHandler } from "@cocalc/jupyter/execute/output-handler";27import { RunAllLoop } from "./run-all-loop";28import nbconvertChange from "./handle-nbconvert-change";29import type { ClientFs } from "@cocalc/sync/client/types";30import { kernel as createJupyterKernel } from "@cocalc/jupyter/kernel";31import { removeJupyterRedux } from "@cocalc/jupyter/kernel";32import { initConatService } from "@cocalc/jupyter/kernel/conat-service";33import { type DKV, dkv } from "@cocalc/conat/sync/dkv";34import { computeServerManager } from "@cocalc/conat/compute/manager";35import { reuseInFlight } from "@cocalc/util/reuse-in-flight";3637// see https://github.com/sagemathinc/cocalc/issues/806038const MAX_OUTPUT_SAVE_DELAY = 30000;3940// refuse to open an ipynb that is bigger than this:41const MAX_SIZE_IPYNB_MB = 150;4243type BackendState = "init" | "ready" | "spawning" | "starting" | "running";4445export class JupyterActions extends JupyterActions0 {46private _backend_state: BackendState = "init";47private lastSavedBackendState?: BackendState;48private _initialize_manager_already_done: any;49private _kernel_state: any;50private _manager_run_cell_queue: any;51private _running_cells: { [id: string]: string };52private _throttled_ensure_positions_are_unique: any;53private run_all_loop?: RunAllLoop;54private clear_kernel_error?: any;55private running_manager_run_cell_process_queue: boolean = false;56private last_ipynb_save: number = 0;57protected _client: ClientFs; // this has filesystem access, etc.58public blobs: DKV;59private computeServers?;6061private initBlobStore = async () => {62this.blobs = await dkv(this.blobStoreOptions());63};6465// uncomment for verbose logging of everything here to the console.66// dbg(f: string) {67// return (...args) => console.log(f, args);68// }6970public run_cell(71id: string,72save: boolean = true,73no_halt: boolean = false,74): void {75if (this.store.get("read_only")) {76return;77}78const cell = this.store.getIn(["cells", id]);79if (cell == null) {80// it is trivial to run a cell that does not exist -- nothing needs to be done.81return;82}83const cell_type = cell.get("cell_type", "code");84if (cell_type == "code") {85// when the backend is running code, just don't worry about86// trying to parse things like "foo?" out. We can't do87// it without CodeMirror, and it isn't worth it for that88// application.89this.run_code_cell(id, save, no_halt);90}91if (save) {92this.save_asap();93}94}9596private set_backend_state(backend_state: BackendState): void {97this.dbg("set_backend_state")(backend_state);9899/*100The backend states, which are put in the syncdb so clients101can display this:102103- 'init' -- the backend is checking the file on disk, etc.104- 'ready' -- the backend is setup and ready to use; kernel isn't running though105- 'starting' -- the kernel itself is actived and currently starting up (e.g., Sage is starting up)106- 'running' -- the kernel is running and ready to evaluate code107108109'init' --> 'ready' --> 'spawning' --> 'starting' --> 'running'110/|\ |111|-----------------------------------------|112113Going from ready to starting happens first when a code execution is requested.114*/115116// Check just in case Typescript doesn't catch something:117if (118["init", "ready", "spawning", "starting", "running"].indexOf(119backend_state,120) === -1121) {122throw Error(`invalid backend state '${backend_state}'`);123}124if (backend_state == "init" && this._backend_state != "init") {125// Do NOT allow changing the state to init from any other state.126throw Error(127`illegal state change '${this._backend_state}' --> '${backend_state}'`,128);129}130this._backend_state = backend_state;131132if (this.lastSavedBackendState != backend_state) {133this._set({134type: "settings",135backend_state,136last_backend_state: Date.now(),137});138this.save_asap();139this.lastSavedBackendState = backend_state;140}141142// The following is to clear kernel_error if things are working only.143if (backend_state == "running") {144// clear kernel error if kernel successfully starts and stays145// in running state for a while.146this.clear_kernel_error = setTimeout(() => {147this._set({148type: "settings",149kernel_error: "",150});151}, 3000);152} else {153// change to a different state; cancel attempt to clear kernel error154if (this.clear_kernel_error) {155clearTimeout(this.clear_kernel_error);156delete this.clear_kernel_error;157}158}159}160161set_kernel_state = (state: any, save = false) => {162this._kernel_state = state;163this._set({ type: "settings", kernel_state: state }, save);164};165166// Called exactly once when the manager first starts up after the store is initialized.167// Here we ensure everything is in a consistent state so that we can react168// to changes later.169async initialize_manager() {170if (this._initialize_manager_already_done) {171return;172}173const dbg = this.dbg("initialize_manager");174dbg();175this._initialize_manager_already_done = true;176177dbg("initialize Jupyter Conat api handler");178await this.initConatApi();179180dbg("initializing blob store");181await this.initBlobStore();182183this.sync_exec_state = debounce(this.sync_exec_state, 2000);184this._throttled_ensure_positions_are_unique = debounce(185this.ensure_positions_are_unique,1865000,187);188// Listen for changes...189this.syncdb.on("change", this.backendSyncdbChange);190191this.setState({192// used by the kernel_info function of this.jupyter_kernel193start_time: this._client.server_time().valueOf(),194});195196// clear nbconvert start on init, since no nbconvert can be running yet197this.syncdb.delete({ type: "nbconvert" });198199// Initialize info about available kernels, which is used e.g., for200// saving to ipynb format.201this.init_kernel_info();202203// We try once to load from disk. If it fails, then204// a record with type:'fatal'205// is created in the database; if it succeeds, that record is deleted.206// Try again only when the file changes.207await this._first_load();208209// Listen for model state changes...210if (this.syncdb.ipywidgets_state == null) {211throw Error("syncdb's ipywidgets_state must be defined!");212}213this.syncdb.ipywidgets_state.on(214"change",215this.handle_ipywidgets_state_change,216);217}218219private conatService?;220private initConatApi = reuseInFlight(async () => {221if (this.conatService != null) {222this.conatService.close();223this.conatService = null;224}225const service = (this.conatService = await initConatService({226project_id: this.project_id,227path: this.path,228}));229this.syncdb.on("closed", () => {230service.close();231});232});233234private _first_load = async () => {235const dbg = this.dbg("_first_load");236dbg("doing load");237if (this.is_closed()) {238throw Error("actions must not be closed");239}240try {241await this.loadFromDiskIfNewer();242} catch (err) {243dbg(`load failed -- ${err}; wait for file change and try again`);244const path = this.store.get("path");245const watcher = this._client.watch_file({ path });246await once(watcher, "change");247dbg("file changed");248watcher.close();249await this._first_load();250return;251}252dbg("loading worked");253this._init_after_first_load();254};255256private _init_after_first_load = () => {257const dbg = this.dbg("_init_after_first_load");258259dbg("initializing");260// this may change the syncdb.261this.ensure_backend_kernel_setup();262263this.init_file_watcher();264265this._state = "ready";266};267268private backendSyncdbChange = (changes: any) => {269if (this.is_closed()) {270return;271}272const dbg = this.dbg("backendSyncdbChange");273if (changes != null) {274changes.forEach((key) => {275switch (key.get("type")) {276case "settings":277dbg("settings change");278var record = this.syncdb.get_one(key);279if (record != null) {280// ensure kernel is properly configured281this.ensure_backend_kernel_setup();282// only the backend should change kernel and backend state;283// however, our security model allows otherwise (e.g., via TimeTravel).284if (285record.get("kernel_state") !== this._kernel_state &&286this._kernel_state != null287) {288this.set_kernel_state(this._kernel_state, true);289}290if (record.get("backend_state") !== this._backend_state) {291this.set_backend_state(this._backend_state);292}293294if (record.get("run_all_loop_s")) {295if (this.run_all_loop == null) {296this.run_all_loop = new RunAllLoop(297this,298record.get("run_all_loop_s"),299);300} else {301// ensure interval is correct302this.run_all_loop.set_interval(record.get("run_all_loop_s"));303}304} else if (305!record.get("run_all_loop_s") &&306this.run_all_loop != null307) {308// stop it.309this.run_all_loop.close();310delete this.run_all_loop;311}312}313break;314}315});316}317318this.ensure_there_is_a_cell();319this._throttled_ensure_positions_are_unique();320this.sync_exec_state();321};322323// ensure_backend_kernel_setup ensures that we have a connection324// to the selected Jupyter kernel, if any.325ensure_backend_kernel_setup = () => {326const dbg = this.dbg("ensure_backend_kernel_setup");327if (this.isDeleted()) {328dbg("file is deleted");329return;330}331332const kernel = this.store.get("kernel");333dbg("ensure_backend_kernel_setup", { kernel });334335let current: string | undefined = undefined;336if (this.jupyter_kernel != null) {337current = this.jupyter_kernel.name;338if (current == kernel) {339const state = this.jupyter_kernel.get_state();340if (state == "error") {341dbg("kernel is broken");342// nothing to do -- let user ponder the error they should see.343return;344}345if (state != "closed") {346dbg("everything is properly setup and working");347return;348}349}350}351352dbg(`kernel='${kernel}', current='${current}'`);353if (354this.jupyter_kernel != null &&355this.jupyter_kernel.get_state() != "closed"356) {357if (current != kernel) {358dbg("kernel changed -- kill running kernel to trigger switch");359this.jupyter_kernel.close();360return;361} else {362dbg("nothing to do");363return;364}365}366367dbg("make a new kernel");368369// No kernel wrapper object setup at all. Make one.370this.jupyter_kernel = createJupyterKernel({371name: kernel,372path: this.store.get("path"),373actions: this,374});375376if (this.syncdb.ipywidgets_state == null) {377throw Error("syncdb's ipywidgets_state must be defined!");378}379this.syncdb.ipywidgets_state.clear();380381if (this.jupyter_kernel == null) {382// to satisfy typescript.383throw Error("jupyter_kernel must be defined");384}385dbg("kernel created -- installing handlers");386387// save so gets reported to frontend, and surfaced to user:388// https://github.com/sagemathinc/cocalc/issues/4847389this.jupyter_kernel.on("kernel_error", (error) => {390this.set_kernel_error(error);391});392393// Since we just made a new kernel, clearly no cells are running on the backend:394this._running_cells = {};395396const toStart: string[] = [];397this.store?.get_cell_list().forEach((id) => {398if (this.store.getIn(["cells", id, "state"]) == "start") {399toStart.push(id);400}401});402403dbg("clear cell run state");404this.clear_all_cell_run_state();405406this.restartKernelOnClose = () => {407// When the kernel closes, make sure a new kernel gets setup.408if (this.store == null || this._state !== "ready") {409// This event can also happen when this actions is being closed,410// in which case obviously we shouldn't make a new kernel.411return;412}413dbg("kernel closed -- make new one.");414this.ensure_backend_kernel_setup();415};416417this.jupyter_kernel.once("closed", this.restartKernelOnClose);418419// Track backend state changes other than closing, so they420// are visible to user etc.421// TODO: Maybe all these need to move to ephemeral table?422// There's a good argument that recording these is useful though, so when423// looking at time travel or debugging, you know what was going on.424this.jupyter_kernel.on("state", (state) => {425dbg("jupyter_kernel state --> ", state);426switch (state) {427case "off":428case "closed":429// things went wrong.430this._running_cells = {};431this.clear_all_cell_run_state();432this.set_backend_state("ready");433this.jupyter_kernel?.close();434this.running_manager_run_cell_process_queue = false;435delete this.jupyter_kernel;436return;437case "spawning":438case "starting":439this.set_connection_file(); // yes, fall through440case "running":441this.set_backend_state(state);442}443});444445this.jupyter_kernel.on("execution_state", this.set_kernel_state);446447this.handle_all_cell_attachments();448dbg("ready");449this.set_backend_state("ready");450451// Run cells that the user explicitly set to be running before the452// kernel actually had finished starting up.453// This must be done after the state is ready.454if (toStart.length > 0) {455for (const id of toStart) {456this.run_cell(id);457}458}459};460461set_connection_file = () => {462const connection_file = this.jupyter_kernel?.get_connection_file() ?? "";463this._set({464type: "settings",465connection_file,466});467};468469init_kernel_info = async () => {470let kernels0 = this.store.get("kernels");471if (kernels0 != null) {472return;473}474const dbg = this.dbg("init_kernel_info");475dbg("getting");476let kernels;477try {478kernels = await get_kernel_data();479dbg("success");480} catch (err) {481dbg(`FAILED to get kernel info: ${err}`);482// TODO: what to do?? Saving will be broken...483return;484}485this.setState({486kernels: immutable.fromJS(kernels),487});488};489490async ensure_backend_kernel_is_running() {491const dbg = this.dbg("ensure_backend_kernel_is_running");492if (this._backend_state == "ready") {493dbg("in state 'ready', so kick it into gear");494await this.set_backend_kernel_info();495dbg("done getting kernel info");496}497const is_running = (s): boolean => {498if (this._state === "closed") {499return true;500}501const t = s.get_one({ type: "settings" });502if (t == null) {503dbg("no settings");504return false;505} else {506const state = t.get("backend_state");507dbg(`state = ${state}`);508return state == "running";509}510};511await this.syncdb.wait(is_running, 60);512}513514// onCellChange is called after a cell change has been515// incorporated into the store after the syncdb change event.516// - If we are responsible for running cells, then it ensures517// that cell gets computed.518// - We also handle attachments for markdown cells.519protected onCellChange(id: string, new_cell: any, old_cell: any) {520const dbg = this.dbg(`onCellChange(id='${id}')`);521dbg();522// this logging could be expensive due to toJS, so only uncomment523// if really needed524// dbg("new_cell=", new_cell?.toJS(), "old_cell", old_cell?.toJS());525526if (527new_cell?.get("state") === "start" &&528old_cell?.get("state") !== "start"529) {530this.manager_run_cell_enqueue(id);531// attachments below only happen for markdown cells, which don't get run,532// we can return here:533return;534}535536const attachments = new_cell?.get("attachments");537if (attachments != null && attachments !== old_cell?.get("attachments")) {538this.handle_cell_attachments(new_cell);539}540}541542protected __syncdb_change_post_hook(doInit: boolean) {543if (doInit) {544// Since just opening the actions in the project, definitely the kernel545// isn't running so set this fact in the shared database. It will make546// things always be in the right initial state.547this.syncdb.set({548type: "settings",549backend_state: "init",550kernel_state: "idle",551kernel_usage: { memory: 0, cpu: 0 },552});553this.syncdb.commit();554555// Also initialize the execution manager, which runs cells that have been556// requested to run.557this.initialize_manager();558}559if (this.store.get("kernel")) {560this.manager_run_cell_process_queue();561}562}563564// Ensure that the cells listed as running *are* exactly the565// ones actually running or queued up to run.566sync_exec_state = () => {567// sync_exec_state is debounced, so it is *expected* to get called568// after actions have been closed.569if (this.store == null || this._state !== "ready") {570// not initialized, so we better not571// mess with cell state (that is somebody else's responsibility).572return;573}574575const dbg = this.dbg("sync_exec_state");576let change = false;577const cells = this.store.get("cells");578// First verify that all actual cells that are said to be running579// (according to the store) are in fact running.580if (cells != null) {581cells.forEach((cell, id) => {582const state = cell.get("state");583if (584state != null &&585state != "done" &&586state != "start" && // regarding "start", see https://github.com/sagemathinc/cocalc/issues/5467587!this._running_cells?.[id]588) {589dbg(`set cell ${id} with state "${state}" to done`);590this._set({ type: "cell", id, state: "done" }, false);591change = true;592}593});594}595if (this._running_cells != null) {596const cells = this.store.get("cells");597// Next verify that every cell actually running is still in the document598// and listed as running. TimeTravel, deleting cells, etc., can599// certainly lead to this being necessary.600for (const id in this._running_cells) {601const state = cells.getIn([id, "state"]);602if (state == null || state === "done") {603// cell no longer exists or isn't in a running state604dbg(`tell kernel to not run ${id}`);605this._cancel_run(id);606}607}608}609if (change) {610return this._sync();611}612};613614_cancel_run = (id: any) => {615const dbg = this.dbg(`_cancel_run ${id}`);616// All these checks are so we only cancel if it is actually running617// with the current kernel...618if (this._running_cells == null || this.jupyter_kernel == null) return;619const identity = this._running_cells[id];620if (identity == null) return;621if (this.jupyter_kernel.identity == identity) {622dbg("canceling");623this.jupyter_kernel.cancel_execute(id);624} else {625dbg("not canceling since wrong identity");626}627};628629// Note that there is a request to run a given cell.630// You must call manager_run_cell_process_queue for them to actually start running.631protected manager_run_cell_enqueue(id: string) {632if (this._running_cells?.[id]) {633return;634}635if (this._manager_run_cell_queue == null) {636this._manager_run_cell_queue = {};637}638this._manager_run_cell_queue[id] = true;639}640641// properly start running -- in order -- the cells that have been requested to run642protected async manager_run_cell_process_queue() {643if (this.running_manager_run_cell_process_queue) {644return;645}646this.running_manager_run_cell_process_queue = true;647try {648const dbg = this.dbg("manager_run_cell_process_queue");649const queue = this._manager_run_cell_queue;650if (queue == null) {651//dbg("queue is null");652return;653}654delete this._manager_run_cell_queue;655const v: any[] = [];656for (const id in queue) {657if (!this._running_cells?.[id]) {658v.push(this.store.getIn(["cells", id]));659}660}661662if (v.length == 0) {663dbg("no non-running cells");664return; // nothing to do665}666667v.sort((a, b) =>668misc.cmp(669a != null ? a.get("start") : undefined,670b != null ? b.get("start") : undefined,671),672);673674dbg(675`found ${v.length} non-running cell that should be running, so ensuring kernel is running...`,676);677this.ensure_backend_kernel_setup();678try {679await this.ensure_backend_kernel_is_running();680if (this._state == "closed") return;681} catch (err) {682// if this fails, give up on evaluation.683return;684}685686dbg(687`kernel is now running; requesting that each ${v.length} cell gets executed`,688);689for (const cell of v) {690if (cell != null) {691this.manager_run_cell(cell.get("id"));692}693}694695if (this._manager_run_cell_queue != null) {696// run it again to process additional entries.697setTimeout(this.manager_run_cell_process_queue, 1);698}699} finally {700this.running_manager_run_cell_process_queue = false;701}702}703704// returns new output handler for this cell.705protected _output_handler(cell) {706const dbg = this.dbg(`_output_handler(id='${cell.id}')`);707if (708this.jupyter_kernel == null ||709this.jupyter_kernel.get_state() == "closed"710) {711throw Error("jupyter kernel must exist and not be closed");712}713this.reset_more_output(cell.id);714715const handler = new OutputHandler({716cell,717max_output_length: this.store.get("max_output_length"),718max_output_messages: MAX_OUTPUT_MESSAGES,719report_started_ms: 250,720dbg,721});722723dbg("setting up jupyter_kernel.once('closed', ...) handler");724const handleKernelClose = () => {725dbg("output handler -- closing due to jupyter kernel closed");726handler.close();727};728this.jupyter_kernel.once("closed", handleKernelClose);729// remove the "closed" handler we just defined above once730// we are done waiting for output from this cell.731// The output handler removes all listeners whenever it is732// finished, so we don't have to remove this listener for done.733handler.once("done", () =>734this.jupyter_kernel?.removeListener("closed", handleKernelClose),735);736737handler.on("more_output", (mesg, mesg_length) => {738this.set_more_output(cell.id, mesg, mesg_length);739});740741handler.on("process", (mesg) => {742// Do not enable -- mesg often very large!743// dbg("handler.on('process')", mesg);744if (745this.jupyter_kernel == null ||746this.jupyter_kernel.get_state() == "closed"747) {748return;749}750this.jupyter_kernel.process_output(mesg);751// dbg("handler -- after processing ", mesg);752});753754return handler;755}756757manager_run_cell = (id: string) => {758const dbg = this.dbg(`manager_run_cell(id='${id}')`);759dbg(JSON.stringify(misc.keys(this._running_cells)));760761if (this._running_cells == null) {762this._running_cells = {};763}764765if (this._running_cells[id]) {766dbg("cell already queued to run in kernel");767return;768}769770// It's important to set this._running_cells[id] to be true so that771// sync_exec_state doesn't declare this cell done. The kernel identity772// will get set properly below in case it changes.773this._running_cells[id] = this.jupyter_kernel?.identity ?? "none";774775const orig_cell = this.store.get("cells").get(id);776if (orig_cell == null) {777// nothing to do -- cell deleted778return;779}780781let input: string | undefined = orig_cell.get("input", "");782if (input == null) {783input = "";784} else {785input = input.trim();786}787788const halt_on_error: boolean = !orig_cell.get("no_halt", false);789790if (this.jupyter_kernel == null) {791throw Error("bug -- this is guaranteed by the above");792}793this._running_cells[id] = this.jupyter_kernel.identity;794795const cell: any = {796id,797type: "cell",798kernel: this.store.get("kernel"),799};800801dbg(`using max_output_length=${this.store.get("max_output_length")}`);802const handler = this._output_handler(cell);803804// exponentiallyThrottledSaved calls this.syncdb?.save, but805// it throttles the calls, and does so using exponential backoff806// up to MAX_OUTPUT_SAVE_DELAY milliseconds. Basically every807// time exponentiallyThrottledSaved is called it increases the808// interval used for throttling by multiplying saveThrottleMs by 1.3809// until saveThrottleMs gets to MAX_OUTPUT_SAVE_DELAY. There is no810// need at all to do a trailing call, since other code handles that.811let saveThrottleMs = 1;812let lastCall = 0;813const exponentiallyThrottledSaved = () => {814const now = Date.now();815if (now - lastCall < saveThrottleMs) {816return;817}818lastCall = now;819saveThrottleMs = Math.min(1.3 * saveThrottleMs, MAX_OUTPUT_SAVE_DELAY);820this.syncdb?.save();821};822823handler.on("change", (save) => {824if (!this.store.getIn(["cells", id])) {825// The cell was deleted, but we just got some output826// NOTE: client shouldn't allow deleting running or queued827// cells, but we still want to do something useful/sensible.828// We put cell back where it was with same input.829cell.input = orig_cell.get("input");830cell.pos = orig_cell.get("pos");831}832this.syncdb.set(cell);833// This is potentially very verbose -- don't due it unless834// doing low level debugging:835//dbg(`change (save=${save}): cell='${JSON.stringify(cell)}'`);836if (save) {837exponentiallyThrottledSaved();838}839});840841handler.once("done", () => {842dbg("handler is done");843this.store.removeListener("cell_change", cell_change);844exec.close();845if (this._running_cells != null) {846delete this._running_cells[id];847}848this.syncdb?.save();849setTimeout(() => this.syncdb?.save(), 100);850});851852if (this.jupyter_kernel == null) {853handler.error("Unable to start Jupyter");854return;855}856857const get_password = (): string => {858if (this.jupyter_kernel == null) {859dbg("get_password", id, "no kernel");860return "";861}862const password = this.jupyter_kernel.store.get(id);863dbg("get_password", id, password);864this.jupyter_kernel.store.delete(id);865return password;866};867868// This is used only for stdin right now.869const cell_change = (cell_id, new_cell) => {870if (id === cell_id) {871dbg("cell_change");872handler.cell_changed(new_cell, get_password);873}874};875this.store.on("cell_change", cell_change);876877const exec = this.jupyter_kernel.execute_code({878code: input,879id,880stdin: handler.stdin,881halt_on_error,882});883884exec.on("output", (mesg) => {885// uncomment only for specific low level debugging -- see https://github.com/sagemathinc/cocalc/issues/7022886// dbg(`got mesg='${JSON.stringify(mesg)}'`); // !!!☡ ☡ ☡ -- EXTREME DANGER ☡ ☡ ☡ !!!!887888if (mesg == null) {889// can't possibly happen, of course.890const err = "empty mesg";891dbg(`got error='${err}'`);892handler.error(err);893return;894}895if (mesg.done) {896// done is a special internal cocalc message.897handler.done();898return;899}900if (mesg.content?.transient?.display_id != null) {901// See https://github.com/sagemathinc/cocalc/issues/2132902// We find any other outputs in the document with903// the same transient.display_id, and set their output to904// this mesg's output.905this.handleTransientUpdate(mesg);906if (mesg.msg_type == "update_display_data") {907// don't also create a new output908return;909}910}911912if (mesg.msg_type === "clear_output") {913handler.clear(mesg.content.wait);914return;915}916917if (mesg.content.comm_id != null) {918// ignore any comm/widget related messages919return;920}921922if (mesg.content.execution_state === "idle") {923this.store.removeListener("cell_change", cell_change);924return;925}926if (mesg.content.execution_state === "busy") {927handler.start();928}929if (mesg.content.payload != null) {930if (mesg.content.payload.length > 0) {931// payload shell message:932// Despite https://ipython.org/ipython-doc/3/development/messaging.html#payloads saying933// ""Payloads are considered deprecated, though their replacement is not yet implemented."934// we fully have to implement them, since they are used to implement (crazy, IMHO)935// things like %load in the python2 kernel!936mesg.content.payload.map((p) => handler.payload(p));937return;938}939} else {940// Normal iopub output message941handler.message(mesg.content);942return;943}944});945946exec.on("error", (err) => {947dbg(`got error='${err}'`);948handler.error(err);949});950};951952reset_more_output = (id: string) => {953if (id == null) {954this.store._more_output = {};955}956if (this.store._more_output[id] != null) {957delete this.store._more_output[id];958}959};960961set_more_output = (id: string, mesg: object, length: number): void => {962if (this.store._more_output[id] == null) {963this.store._more_output[id] = {964length: 0,965messages: [],966lengths: [],967discarded: 0,968truncated: 0,969};970}971const output = this.store._more_output[id];972973output.length += length;974output.lengths.push(length);975output.messages.push(mesg);976977const goal_length = 10 * this.store.get("max_output_length");978while (output.length > goal_length) {979let need: any;980let did_truncate = false;981982// check if there is a text field, which we can truncate983let len = output.messages[0].text?.length;984if (len != null) {985need = output.length - goal_length + 50;986if (len > need) {987// Instead of throwing this message away, let's truncate its text part. After988// doing this, the message is at least shorter than it was before.989output.messages[0].text = misc.trunc(990output.messages[0].text,991len - need,992);993did_truncate = true;994}995}996997// check if there is a text/plain field, which we can thus also safely truncate998if (!did_truncate && output.messages[0].data != null) {999for (const field in output.messages[0].data) {1000if (field === "text/plain") {1001const val = output.messages[0].data[field];1002len = val.length;1003if (len != null) {1004need = output.length - goal_length + 50;1005if (len > need) {1006// Instead of throwing this message away, let's truncate its text part. After1007// doing this, the message is at least need shorter than it was before.1008output.messages[0].data[field] = misc.trunc(val, len - need);1009did_truncate = true;1010}1011}1012}1013}1014}10151016if (did_truncate) {1017const new_len = JSON.stringify(output.messages[0]).length;1018output.length -= output.lengths[0] - new_len; // how much we saved1019output.lengths[0] = new_len;1020output.truncated += 1;1021break;1022}10231024const n = output.lengths.shift();1025output.messages.shift();1026output.length -= n;1027output.discarded += 1;1028}1029};10301031private init_file_watcher = () => {1032const dbg = this.dbg("file_watcher");1033dbg();1034this._file_watcher = this._client.watch_file({1035path: this.store.get("path"),1036debounce: 1000,1037});10381039this._file_watcher.on("change", async () => {1040dbg("change");1041try {1042await this.loadFromDiskIfNewer();1043} catch (err) {1044dbg("failed to load on change", err);1045}1046});1047};10481049/*1050* Unfortunately, though I spent two hours on this approach... it just doesn't work,1051* since, e.g., if the sync file doesn't already exist, it can't be created,1052* which breaks everything. So disabling for now and re-opening the issue.1053_sync_file_mode: =>1054dbg = @dbg("_sync_file_mode"); dbg()1055* Make the mode of the syncdb file the same as the mode of the .ipynb file.1056* This is used for read-only status.1057ipynb_file = @store.get('path')1058locals =1059ipynb_file_ro : undefined1060syncdb_file_ro : undefined1061syncdb_file = @syncdb.get_path()1062async.parallel([1063(cb) ->1064fs.access ipynb_file, fs.constants.W_OK, (err) ->1065* Also store in @_ipynb_file_ro to prevent starting kernel in this case.1066@_ipynb_file_ro = locals.ipynb_file_ro = !!err1067cb()1068(cb) ->1069fs.access syncdb_file, fs.constants.W_OK, (err) ->1070locals.syncdb_file_ro = !!err1071cb()1072], ->1073if locals.ipynb_file_ro == locals.syncdb_file_ro1074return1075dbg("mode change")1076async.parallel([1077(cb) ->1078fs.stat ipynb_file, (err, stats) ->1079locals.ipynb_stats = stats1080cb(err)1081(cb) ->1082* error if syncdb_file doesn't exist, which is GOOD, since1083* in that case we do not want to chmod which would create1084* that file as empty and blank it.1085fs.stat(syncdb_file, cb)1086], (err) ->1087if not err1088dbg("changing syncb mode to match ipynb mode")1089fs.chmod(syncdb_file, locals.ipynb_stats.mode)1090else1091dbg("error stating ipynb", err)1092)1093)1094*/10951096// Load file from disk if it is newer than1097// the last we saved to disk.1098private loadFromDiskIfNewer = async () => {1099const dbg = this.dbg("loadFromDiskIfNewer");1100// Get mtime of last .ipynb file that we explicitly saved.11011102// TODO: breaking the syncdb typescript data hiding. The1103// right fix will be to move1104// this info to a new ephemeral state table.1105const last_ipynb_save = await this.get_last_ipynb_save();1106dbg(`syncdb last_ipynb_save=${last_ipynb_save}`);1107let file_changed;1108if (last_ipynb_save == 0) {1109// we MUST load from file the first time, of course.1110file_changed = true;1111dbg("file changed because FIRST TIME");1112} else {1113const path = this.store.get("path");1114let stats;1115try {1116stats = await callback2(this._client.path_stat, { path });1117dbg(`stats.mtime = ${stats.mtime}`);1118} catch (err) {1119// This err just means the file doesn't exist.1120// We set the 'last load' to now in this case, since1121// the frontend clients need to know that we1122// have already scanned the disk.1123this.set_last_load();1124return;1125}1126const mtime = stats.mtime.getTime();1127file_changed = mtime > last_ipynb_save;1128dbg({ mtime, last_ipynb_save });1129}1130if (file_changed) {1131dbg(".ipynb disk file changed ==> loading state from disk");1132try {1133await this.load_ipynb_file();1134} catch (err) {1135dbg("failed to load on change", err);1136}1137} else {1138dbg("disk file NOT changed: NOT loading");1139}1140};11411142// if also set load is true, we also set the "last_ipynb_save" time.1143set_last_load = (alsoSetLoad: boolean = false) => {1144const last_load = new Date().getTime();1145this.syncdb.set({1146type: "file",1147last_load,1148});1149if (alsoSetLoad) {1150// yes, load v save is inconsistent!1151this.syncdb.set({ type: "settings", last_ipynb_save: last_load });1152}1153this.syncdb.commit();1154};11551156/* Determine timestamp of aux .ipynb file, and record it here,1157so we know that we do not have to load exactly that file1158back from disk. */1159private set_last_ipynb_save = async () => {1160let stats;1161try {1162stats = await callback2(this._client.path_stat, {1163path: this.store.get("path"),1164});1165} catch (err) {1166// no-op -- nothing to do.1167this.dbg("set_last_ipynb_save")(`WARNING -- issue in path_stat ${err}`);1168return;1169}11701171// This is ugly (i.e., how we get access), but I need to get this done.1172// This is the RIGHT place to save the info though.1173// TODO: move this state info to new ephemeral table.1174try {1175const last_ipynb_save = stats.mtime.getTime();1176this.last_ipynb_save = last_ipynb_save;1177this._set({1178type: "settings",1179last_ipynb_save,1180});1181this.dbg("stats.mtime.getTime()")(1182`set_last_ipynb_save = ${last_ipynb_save}`,1183);1184} catch (err) {1185this.dbg("set_last_ipynb_save")(1186`WARNING -- issue in set_last_ipynb_save ${err}`,1187);1188return;1189}1190};11911192private get_last_ipynb_save = async () => {1193const x =1194this.syncdb.get_one({ type: "settings" })?.get("last_ipynb_save") ?? 0;1195return Math.max(x, this.last_ipynb_save);1196};11971198load_ipynb_file = async () => {1199/*1200Read the ipynb file from disk. Fully use the ipynb file to1201set the syncdb's state. We do this when opening a new file, or when1202the file changes on disk (e.g., a git checkout or something).1203*/1204const dbg = this.dbg(`load_ipynb_file`);1205dbg("reading file");1206const path = this.store.get("path");1207let content: string;1208try {1209content = await callback2(this._client.path_read, {1210path,1211maxsize_MB: MAX_SIZE_IPYNB_MB,1212});1213} catch (err) {1214// possibly file doesn't exist -- set notebook to empty.1215const exists = await callback2(this._client.path_exists, {1216path,1217});1218if (!exists) {1219content = "";1220} else {1221// It would be better to have a button to push instead of1222// suggesting running a command in the terminal, but1223// adding that took 1 second. Better than both would be1224// making it possible to edit huge files :-).1225const error = `Error reading ipynb file '${path}': ${err.toString()}. Fix this to continue. You can delete all output by typing cc-jupyter-no-output [filename].ipynb in a terminal.`;1226this.syncdb.set({ type: "fatal", error });1227throw Error(error);1228}1229}1230if (content.length === 0) {1231// Blank file, e.g., when creating in CoCalc.1232// This is good, works, etc. -- just clear state, including error.1233this.syncdb.delete();1234this.set_last_load(true);1235return;1236}12371238// File is nontrivial -- parse and load.1239let parsed_content;1240try {1241parsed_content = JSON.parse(content);1242} catch (err) {1243const error = `Error parsing the ipynb file '${path}': ${err}. You must fix the ipynb file somehow before continuing, or use TimeTravel to revert to a recent version.`;1244dbg(error);1245this.syncdb.set({ type: "fatal", error });1246throw Error(error);1247}1248this.syncdb.delete({ type: "fatal" });1249await this.set_to_ipynb(parsed_content);1250this.set_last_load(true);1251};12521253private fetch_jupyter_kernels = async () => {1254const data = await get_kernel_data();1255const kernels = immutable.fromJS(data as any);1256this.setState({ kernels });1257};12581259save_ipynb_file = async ({1260version = 0,1261timeout = 15000,1262}: {1263// if version is given, waits (up to timeout ms) for syncdb to1264// contain that exact version before writing the ipynb to disk.1265// This may be needed to ensure that ipynb saved to disk1266// reflects given frontend state. This comes up, e.g., in1267// generating the nbgrader version of a document.1268version?: number;1269timeout?: number;1270} = {}) => {1271const dbg = this.dbg("save_ipynb_file");1272if (version && !this.syncdb.hasVersion(version)) {1273dbg(`frontend needs ${version}, which we do not yet have`);1274const start = Date.now();1275while (true) {1276if (this.is_closed()) {1277return;1278}1279if (Date.now() - start >= timeout) {1280dbg("timed out waiting");1281break;1282}1283try {1284dbg(`waiting for version ${version}`);1285await once(this.syncdb, "change", timeout - (Date.now() - start));1286} catch {1287dbg("timed out waiting");1288break;1289}1290if (this.syncdb.hasVersion(version)) {1291dbg("now have the version");1292break;1293}1294}1295}1296if (this.is_closed()) {1297return;1298}1299dbg("saving to file");13001301// Check first if file was deleted, in which case instead of saving to disk,1302// we should terminate and clean up everything.1303if (this.isDeleted()) {1304dbg("ipynb file is deleted, so NOT saving to disk and closing");1305this.close({ noSave: true });1306return;1307}13081309if (this.jupyter_kernel == null) {1310// The kernel is needed to get access to the blob store, which1311// may be needed to save to disk.1312this.ensure_backend_kernel_setup();1313if (this.jupyter_kernel == null) {1314// still not null? This would happen if no kernel is set at all,1315// in which case it's OK that saving isn't possible.1316throw Error("no kernel so cannot save");1317}1318}1319if (this.store.get("kernels") == null) {1320await this.init_kernel_info();1321if (this.store.get("kernels") == null) {1322// This should never happen, but maybe could in case of a very1323// messed up compute environment where the kernelspecs can't be listed.1324throw Error(1325"kernel info not known and can't be determined, so can't save",1326);1327}1328}1329dbg("going to try to save: getting ipynb object...");1330const blob_store = this.jupyter_kernel.get_blob_store();1331let ipynb = this.store.get_ipynb(blob_store);1332if (this.store.get("kernel")) {1333// if a kernel is set, check that it was sufficiently known that1334// we can fill in data about it --1335// see https://github.com/sagemathinc/cocalc/issues/72861336if (ipynb?.metadata?.kernelspec?.name == null) {1337dbg("kernelspec not known -- try loading kernels again");1338await this.fetch_jupyter_kernels();1339// and again grab the ipynb1340ipynb = this.store.get_ipynb(blob_store);1341if (ipynb?.metadata?.kernelspec?.name == null) {1342dbg("kernelspec STILL not known: metadata will be incomplete");1343}1344}1345}1346dbg("got ipynb object");1347// We use json_stable (and indent 1) to be more diff friendly to user,1348// and more consistent with official Jupyter.1349const data = json_stable(ipynb, { space: 1 });1350if (data == null) {1351dbg("failed -- ipynb not defined yet");1352throw Error("ipynb not defined yet; can't save");1353}1354dbg("converted ipynb to stable JSON string", data?.length);1355//dbg(`got string version '${data}'`)1356try {1357dbg("writing to disk...");1358await callback2(this._client.write_file, {1359path: this.store.get("path"),1360data,1361});1362dbg("succeeded at saving");1363await this.set_last_ipynb_save();1364} catch (err) {1365const e = `error writing file: ${err}`;1366dbg(e);1367throw Error(e);1368}1369};13701371ensure_there_is_a_cell = () => {1372if (this._state !== "ready") {1373return;1374}1375const cells = this.store.get("cells");1376if (cells == null || cells.size === 0) {1377this._set({1378type: "cell",1379id: this.new_id(),1380pos: 0,1381input: "",1382});1383// We are obviously contributing content to this (empty!) notebook.1384return this.set_trust_notebook(true);1385}1386};13871388private handle_all_cell_attachments() {1389// Check if any cell attachments need to be loaded.1390const cells = this.store.get("cells");1391cells?.forEach((cell) => {1392this.handle_cell_attachments(cell);1393});1394}13951396private handle_cell_attachments(cell) {1397if (this.jupyter_kernel == null) {1398// can't do anything1399return;1400}1401const dbg = this.dbg(`handle_cell_attachments(id=${cell.get("id")})`);1402dbg();14031404const attachments = cell.get("attachments");1405if (attachments == null) return; // nothing to do1406attachments.forEach(async (x, name) => {1407if (x == null) return;1408if (x.get("type") === "load") {1409if (this.jupyter_kernel == null) return; // try later1410// need to load from disk1411this.set_cell_attachment(cell.get("id"), name, {1412type: "loading",1413value: null,1414});1415let sha1: string;1416try {1417sha1 = await this.jupyter_kernel.load_attachment(x.get("value"));1418} catch (err) {1419this.set_cell_attachment(cell.get("id"), name, {1420type: "error",1421value: `${err}`,1422});1423return;1424}1425this.set_cell_attachment(cell.get("id"), name, {1426type: "sha1",1427value: sha1,1428});1429}1430});1431}14321433// handle_ipywidgets_state_change is called when the project ipywidgets_state1434// object changes, e.g., in response to a user moving a slider in the browser.1435// It crafts a comm message that is sent to the running Jupyter kernel telling1436// it about this change by calling send_comm_message_to_kernel.1437private handle_ipywidgets_state_change = (keys): void => {1438if (this.is_closed()) {1439return;1440}1441const dbg = this.dbg("handle_ipywidgets_state_change");1442dbg(keys);1443if (this.jupyter_kernel == null) {1444dbg("no kernel, so ignoring changes to ipywidgets");1445return;1446}1447if (this.syncdb.ipywidgets_state == null) {1448throw Error("syncdb's ipywidgets_state must be defined!");1449}1450for (const key of keys) {1451const [, model_id, type] = JSON.parse(key);1452dbg({ key, model_id, type });1453let data: any;1454if (type === "value") {1455const state = this.syncdb.ipywidgets_state.get_model_value(model_id);1456// Saving the buffers on change is critical since otherwise this breaks:1457// https://ipywidgets.readthedocs.io/en/latest/examples/Widget%20List.html#file-upload1458// Note that stupidly the buffer (e.g., image upload) gets sent to the kernel twice.1459// But it does work robustly, and the kernel and nodejs server processes next to each1460// other so this isn't so bad.1461const { buffer_paths, buffers } =1462this.syncdb.ipywidgets_state.getKnownBuffers(model_id);1463data = { method: "update", state, buffer_paths };1464this.jupyter_kernel.send_comm_message_to_kernel({1465msg_id: misc.uuid(),1466target_name: "jupyter.widget",1467comm_id: model_id,1468data,1469buffers,1470});1471} else if (type === "buffers") {1472// TODO: we MIGHT need implement this... but MAYBE NOT. An example where this seems like it might be1473// required is by the file upload widget, but actually that just uses the value type above, since1474// we explicitly fill in the widgets there; also there is an explicit comm upload message that1475// the widget sends out that updates the buffer, and in send_comm_message_to_kernel in jupyter/kernel/kernel.ts1476// when processing that message, we saves those buffers and make sure they are set in the1477// value case above (otherwise they would get removed).1478// https://ipywidgets.readthedocs.io/en/latest/examples/Widget%20List.html#file-upload1479// which creates a buffer from the content of the file, then sends it to the backend,1480// which sees a change and has to write that buffer to the kernel (here) so that1481// the running python process can actually do something with the file contents (e.g.,1482// process data, save file to disk, etc).1483// We need to be careful though to not send buffers to the kernel that the kernel sent us,1484// since that would be a waste.1485} else if (type === "state") {1486// TODO: currently ignoring this, since it seems chatty and pointless,1487// and could lead to race conditions probably with multiple users, etc.1488// It happens right when the widget is created.1489/*1490const state = this.syncdb.ipywidgets_state.getModelSerializedState(model_id);1491data = { method: "update", state };1492this.jupyter_kernel.send_comm_message_to_kernel(1493misc.uuid(),1494model_id,1495data1496);1497*/1498} else {1499const m = `Jupyter: unknown type '${type}'`;1500console.warn(m);1501dbg(m);1502}1503}1504};15051506async process_comm_message_from_kernel(mesg: any): Promise<void> {1507const dbg = this.dbg("process_comm_message_from_kernel");1508// serializing the full message could cause enormous load on the server, since1509// the mesg may contain large buffers. Only do for low level debugging!1510// dbg(mesg); // EXTREME DANGER!1511// This should be safe:1512dbg(JSON.stringify(mesg.header));1513if (this.syncdb.ipywidgets_state == null) {1514throw Error("syncdb's ipywidgets_state must be defined!");1515}1516await this.syncdb.ipywidgets_state.process_comm_message_from_kernel(mesg);1517}15181519capture_output_message(mesg: any): boolean {1520if (this.syncdb.ipywidgets_state == null) {1521throw Error("syncdb's ipywidgets_state must be defined!");1522}1523return this.syncdb.ipywidgets_state.capture_output_message(mesg);1524}15251526close_project_only() {1527const dbg = this.dbg("close_project_only");1528dbg();1529if (this.run_all_loop) {1530this.run_all_loop.close();1531delete this.run_all_loop;1532}1533// this stops the kernel and cleans everything up1534// so no resources are wasted and next time starting1535// is clean1536(async () => {1537try {1538await removeJupyterRedux(this.store.get("path"), this.project_id);1539} catch (err) {1540dbg("WARNING -- issue removing jupyter redux", err);1541}1542})();15431544this.blobs?.close();1545}15461547// not actually async...1548async signal(signal = "SIGINT"): Promise<void> {1549this.jupyter_kernel?.signal(signal);1550}15511552handle_nbconvert_change(oldVal, newVal): void {1553nbconvertChange(this, oldVal?.toJS(), newVal?.toJS());1554}15551556// Handle transient cell messages.1557handleTransientUpdate = (mesg) => {1558const display_id = mesg.content?.transient?.display_id;1559if (!display_id) {1560return false;1561}15621563let matched = false;1564// are there any transient outputs in the entire document that1565// have this display_id? search to find them.1566// TODO: we could use a clever data structure to make1567// this faster and more likely to have bugs.1568const cells = this.syncdb.get({ type: "cell" });1569for (let cell of cells) {1570let output = cell.get("output");1571if (output != null) {1572for (const [n, val] of output) {1573if (val.getIn(["transient", "display_id"]) == display_id) {1574// found a match -- replace it1575output = output.set(n, immutable.fromJS(mesg.content));1576this.syncdb.set({ type: "cell", id: cell.get("id"), output });1577matched = true;1578}1579}1580}1581}1582if (matched) {1583this.syncdb.commit();1584}1585};15861587getComputeServers = () => {1588// we don't bother worrying about freeing this since it is only1589// run in the project or compute server, which needs the underlying1590// dkv for its entire lifetime anyways.1591if (this.computeServers == null) {1592this.computeServers = computeServerManager({1593project_id: this.project_id,1594});1595}1596return this.computeServers;1597};15981599getComputeServerIdSync = (): number => {1600const c = this.getComputeServers();1601return c.get(this.syncdb.path) ?? 0;1602};16031604getComputeServerId = async (): Promise<number> => {1605const c = this.getComputeServers();1606return (await c.getServerIdForPath(this.syncdb.path)) ?? 0;1607};1608}160916101611