diff --git a/frontend/common/Feedback.js b/frontend/common/Feedback.js index d33b961733..61761871ce 100644 --- a/frontend/common/Feedback.js +++ b/frontend/common/Feedback.js @@ -1,14 +1,5 @@ import { code_differs } from "../components/Cell.js" - -const timeout_promise = (promise, time_ms) => - Promise.race([ - promise, - new Promise((res, rej) => { - setTimeout(() => { - rej(new Error("Promise timed out.")) - }, time_ms) - }), - ]) +import { timeout_promise } from "./PlutoConnection.js" export const create_counter_statistics = () => { return { diff --git a/frontend/common/PlutoConnection.js b/frontend/common/PlutoConnection.js index 8889fbe9e7..80e9e1faaf 100644 --- a/frontend/common/PlutoConnection.js +++ b/frontend/common/PlutoConnection.js @@ -1,14 +1,40 @@ import { pack, unpack } from "./MsgPack.js" import "./Polyfill.js" -const do_next = async (queue) => { - const next = queue[0] - await next() - queue.shift() - if (queue.length > 0) { - await do_next(queue) - } -} +// https://github.com/denysdovhan/wtfjs/issues/61 +const different_Infinity_because_js_is_yuck = 2147483646 + +/** + * Return a promise that resolves to: + * - the resolved value of `promise` + * - an error after `time_ms` milliseconds + * whichever comes first. + * @template T + * @param {Promise} promise + * @param {number} time_ms + * @returns {Promise} + */ +export const timeout_promise = (promise, time_ms) => + Promise.race([ + promise, + new Promise((res, rej) => { + setTimeout(() => { + rej(new Error("Promise timed out.")) + }, time_ms) + }), + ]) + +/** + * Keep calling @see f until it resolves, with a delay before each try. + * @param {Function} f Function that returns a promise + * @param {Number} time_ms Timeout for each call to @see f + */ +const retry_until_resolved = (f, time_ms) => + timeout_promise(f(), time_ms).catch((e) => { + console.error(e) + console.error("godverdomme") + return retry_until_resolved(f, time_ms) + }) /** * @returns {{current: Promise, resolve: Function}} @@ -24,59 +50,191 @@ export const resolvable_promise = () => { } } -export class PlutoConnection { - async ping() { - const response = await ( - await fetch("ping", { - method: "GET", - cache: "no-cache", - redirect: "follow", - referrerPolicy: "no-referrer", - }) - ).text() - if (response == "OK!") { - return response +const do_all = async (queue) => { + const next = queue[0] + await next() + queue.shift() + if (queue.length > 0) { + await do_all(queue) + } +} + +/** + * @returns {string} + */ +const get_unique_short_id = () => crypto.getRandomValues(new Uint32Array(1))[0].toString(36) + +const socket_is_alright = (socket) => socket.readyState == WebSocket.OPEN || socket.readyState == WebSocket.CONNECTING + +const socket_is_alright_with_grace_period = (socket) => + new Promise((res) => { + if (socket_is_alright(socket)) { + res(true) } else { - throw response + setTimeout(() => { + res(socket_is_alright(socket)) + }, 1000) } - } + }) - wait_for_online() { - this.on_connection_status(false) - - setTimeout(() => { - this.ping() - .then(() => { - if (this.psocket.readyState !== WebSocket.OPEN) { - this.wait_for_online() - } else { - this.on_connection_status(true) - } - }) - .catch(() => { - this.wait_for_online() - }) - }, 1000) +const try_close_socket_connection = (socket) => { + socket.onopen = () => { + try_close_socket_connection(socket) } + socket.onmessage = socket.onclose = socket.onerror = undefined + try { + socket.close(1000, "byebye") + } catch (ex) {} +} + +/** + * We append this after every message to say that the message is complete. This is necessary for sending WS messages larger than 1MB or something, since HTTP.jl splits those into multiple messages :( + */ +const MSG_DELIM = new TextEncoder().encode("IUUQ.km jt ejggjdvmu vhi") + +/** + * Open a 'raw' websocket connection to an API with MessagePack serialization. The method is asynchonous, and resolves to a @see WebsocketConnection when the connection is established. + * @typedef {{socket: WebSocket, send: Function, kill: Function}} WebsocketConnection + * @param {string} address The WebSocket URL + * @param {{on_message: Function, on_socket_close:Function}} callbacks + * @return {Promise} + */ +const create_ws_connection = (address, { on_message, on_socket_close }, timeout_ms = 60000) => { + return new Promise((resolve, reject) => { + const socket = new WebSocket(address) + const task_queue = [] + + var has_been_open = false + + const timeout_handle = setTimeout(() => { + console.warn("Creating websocket timed out", new Date().toLocaleTimeString()) + try_close_socket_connection(socket) + reject("Socket timeout") + }, timeout_ms) - get_short_unqiue_id() { - return crypto.getRandomValues(new Uint32Array(1))[0].toString(36) + const send_encoded = (message) => { + const encoded = pack(message) + const to_send = new Uint8Array(encoded.length + MSG_DELIM.length) + to_send.set(encoded, 0) + to_send.set(MSG_DELIM, encoded.length) + socket.send(to_send) + } + socket.onmessage = (event) => { + // we read and deserialize the incoming messages asynchronously + // they arrive in order (WS guarantees this), i.e. this socket.onmessage event gets fired with the message events in the right order + // but some message are read and deserialized much faster than others, because of varying sizes, so _after_ async read & deserialization, messages are no longer guaranteed to be in order + // + // the solution is a task queue, where each task includes the deserialization and the update handler + task_queue.push(async () => { + try { + const buffer = await event.data.arrayBuffer() + const buffer_sliced = buffer.slice(0, buffer.byteLength - MSG_DELIM.length) + const update = unpack(new Uint8Array(buffer_sliced)) + + on_message(update) + } catch (ex) { + console.error("Failed to process update!", ex) + console.log(event) + + alert( + `Something went wrong!\n\nPlease open an issue on https://github.com/fonsp/Pluto.jl with this info:\n\nFailed to process update\n${ex}\n\n${event}` + ) + } + }) + if (task_queue.length == 1) { + do_all(task_queue) + } + } + socket.onerror = async (e) => { + console.error(`SOCKET DID AN OOPSIE - ${e.type}`, new Date().toLocaleTimeString()) + console.error(e) + + if (await socket_is_alright_with_grace_period(socket)) { + console.log("The socket somehow recovered from an error?! Onbegrijpelijk") + console.log(socket) + console.log(socket.readyState) + } else { + if (has_been_open) { + on_socket_close() + try_close_socket_connection(socket) + } else { + reject(e) + } + } + } + socket.onclose = async (e) => { + console.error(`SOCKET DID AN OOPSIE - ${e.type}`, new Date().toLocaleTimeString()) + console.error(e) + console.assert(has_been_open) + + if (has_been_open) { + on_socket_close() + try_close_socket_connection(socket) + } else { + reject(e) + } + } + socket.onopen = () => { + console.log("Socket opened", new Date().toLocaleTimeString()) + clearInterval(timeout_handle) + has_been_open = true + resolve({ + socket: socket, + send: send_encoded, + kill: () => { + socket.onclose = undefined + try_close_socket_connection(socket) + }, + }) + } + console.log("Waiting for socket to open...", new Date().toLocaleTimeString()) + }) +} + +/** + * Open a connection with Pluto, that supports a question-response mechanism. The method is asynchonous, and resolves to a @see PlutoConnection when the connection is established. + * + * The server can also send messages to all clients, without being requested by them. These end up in the @see on_unrequested_update callback. + * + * @typedef {{plutoENV: Object, send: Function, kill: Function, pluto_version: String, julia_version: String}} PlutoConnection + * @param {{on_unrequested_update: Function, on_reconnect: Function, on_connection_status: Function, connect_metadata?: Object}} callbacks + * @return {Promise} + */ +export const create_pluto_connection = async ({ on_unrequested_update, on_reconnect, on_connection_status, connect_metadata = {} }) => { + var ws_connection = null // will be defined later i promise + + const client_id = get_unique_short_id() + const sent_requests = {} + + const handle_update = (update) => { + const by_me = "initiator_id" in update && update.initiator_id == client_id + const request_id = update.request_id + + if (by_me && request_id) { + const request = sent_requests[request_id] + if (request) { + request(update) + delete sent_requests[request_id] + return + } + } + on_unrequested_update(update, by_me) } /** - * + * Send a message to the Pluto backend, and return a promise that resolves when the backend sends a response. Not all messages receive a response. * @param {string} message_type * @param {Object} body * @param {{notebook_id?: string, cell_id?: string}} metadata * @param {boolean} create_promise If true, returns a Promise that resolves with the server response. If false, the response will go through the on_update method of this instance. * @returns {(undefined|Promise)} */ - send(message_type, body = {}, metadata = {}, create_promise = true) { - const request_id = this.get_short_unqiue_id() + const send = (message_type, body = {}, metadata = {}, create_promise = true) => { + const request_id = get_unique_short_id() const message = { type: message_type, - client_id: this.client_id, + client_id: client_id, request_id: request_id, body: body, ...metadata, @@ -88,166 +246,107 @@ export class PlutoConnection { const rp = resolvable_promise() p = rp.current - this.sent_requests[request_id] = rp.resolve + sent_requests[request_id] = rp.resolve } - const encoded = pack(message) - const to_send = new Uint8Array(encoded.length + this.MSG_DELIM.length) - to_send.set(encoded, 0) - to_send.set(this.MSG_DELIM, encoded.length) - this.psocket.send(to_send) - + ws_connection.send(message) return p } - async handle_message(event) { + const connect = async () => { + const secret = await ( + await fetch("websocket_url_please", { + method: "GET", + cache: "no-cache", + redirect: "follow", + referrerPolicy: "no-referrer", + }) + ).text() + const ws_address = + document.location.protocol.replace("http", "ws") + "//" + document.location.host + document.location.pathname.replace("/edit", "/") + secret + try { - const buffer = await event.data.arrayBuffer() - const buffer_sliced = buffer.slice(0, buffer.byteLength - this.MSG_DELIM.length) - const update = unpack(new Uint8Array(buffer_sliced)) - const by_me = "initiator_id" in update && update.initiator_id == this.client_id - const request_id = update.request_id - - if (by_me && request_id) { - const request = this.sent_requests[request_id] - if (request) { - request(update) - delete this.sent_requests[request_id] - return - } - } - if (update.type === "reload") { - document.location = document.location - } - this.on_update(update, by_me) - } catch (ex) { - console.error("Failed to get update!", ex) - console.log(event) + ws_connection = await create_ws_connection( + ws_address, + { + on_message: handle_update, + on_socket_close: async () => { + on_connection_status(false) - this.wait_for_online() - } - } + console.log(`Starting new websocket`, new Date().toLocaleTimeString()) + await connect() // reconnect! - start_socket_connection(connect_metadata) { - return new Promise(async (res) => { - const secret = await ( - await fetch("websocket_url_please", { - method: "GET", - cache: "no-cache", - redirect: "follow", - referrerPolicy: "no-referrer", - }) - ).text() - this.psocket = new WebSocket( - document.location.protocol.replace("http", "ws") + "//" + document.location.host + document.location.pathname.replace("/edit", "/") + secret + console.log(`Starting state sync`, new Date().toLocaleTimeString()) + const accept = on_reconnect() + console.log(`State sync ${accept ? "" : "not "}succesful`, new Date().toLocaleTimeString()) + on_connection_status(accept) + if (!accept) { + alert("Connection out of sync 😥\n\nRefresh the page to continue") + } + }, + }, + 10000 ) - this.psocket.onmessage = (e) => { - this.task_queue.push(async () => { - await this.handle_message(e) - }) - if (this.task_queue.length == 1) { - do_next(this.task_queue) - } - } - this.psocket.onerror = (e) => { - console.error("SOCKET ERROR", e) - if (this.psocket.readyState != WebSocket.OPEN && this.psocket.readyState != WebSocket.CONNECTING) { - this.wait_for_online() - setTimeout(() => { - if (this.psocket.readyState != WebSocket.OPEN) { - this.try_close_socket_connection() + console.log(ws_connection) - res(this.start_socket_connection(connect_metadata)) - } - }, 500) - } - } - this.psocket.onclose = (e) => { - console.warn("SOCKET CLOSED") - console.log(e) + // let's say hello + console.log("Hello?") + const u = await send("connect", {}, connect_metadata) + console.log("Hello!") - this.wait_for_online() - } - this.psocket.onopen = () => { - console.log("socket opened") - this.send("connect", {}, connect_metadata).then((u) => { - this.plutoENV = u.message.ENV - // TODO: don't check this here - if (connect_metadata.notebook_id && !u.message.notebook_exists) { - // https://github.com/fonsp/Pluto.jl/issues/55 - document.location.href = "./" - return - } - this.on_connection_status(true) - res(this) - }) + if (connect_metadata.notebook_id != null && !u.message.notebook_exists) { + // https://github.com/fonsp/Pluto.jl/issues/55 + if (confirm("A new server was started - this notebook session is no longer running.\n\nWould you like to go back to the main menu?")) { + document.location.href = "./" + } + on_connection_status(false) + return {} } - console.log("waiting...") - }) - } - - try_close_socket_connection() { - this.psocket.close(1000, "byebye") - } - - initialize(on_establish_connection, connect_metadata = {}) { - this.ping() - .then(async () => { - await this.start_socket_connection(connect_metadata) - on_establish_connection(this) - }) - .catch(() => { - this.on_connection_status(false) - }) + on_connection_status(true) - window.addEventListener("beforeunload", (e) => { - console.warn("unloading 👉 disconnecting websocket") - this.psocket.onclose = undefined - this.try_close_socket_connection() - }) + return u.message + } catch (ex) { + console.error("connect() failed") + console.error(ex) + return await connect() + } } + const connection_message = await connect() + const plutoENV = connection_message.ENV - constructor(on_update, on_connection_status) { - this.on_update = on_update - this.on_connection_status = on_connection_status - - this.task_queue = [] - this.psocket = null - this.MSG_DELIM = new TextEncoder().encode("IUUQ.km jt ejggjdvmu vhi") - this.client_id = this.get_short_unqiue_id() - this.sent_requests = {} - this.pluto_version = "unknown" - this.julia_version = "unknown" + return { + plutoENV: plutoENV, + send: send, + kill: ws_connection.kill, + pluto_version: "unknown", + julia_version: "unknown", } +} - fetch_pluto_versions() { - const github_promise = fetch("https://api.github.com/repos/fonsp/Pluto.jl/releases", { - method: "GET", - mode: "cors", - cache: "no-cache", - headers: { - "Content-Type": "application/json", - }, - redirect: "follow", - referrerPolicy: "no-referrer", +export const fetch_pluto_versions = (client) => { + const github_promise = fetch("https://api.github.com/repos/fonsp/Pluto.jl/releases", { + method: "GET", + mode: "cors", + cache: "no-cache", + headers: { + "Content-Type": "application/json", + }, + redirect: "follow", + referrerPolicy: "no-referrer", + }) + .then((response) => { + return response.json() }) - .then((response) => { - return response.json() - }) - .then((response) => { - return response[0].tag_name - }) - - const pluto_promise = this.send("get_version").then((u) => { - this.pluto_version = u.message.pluto - this.julia_version = u.message.julia - return this.pluto_version + .then((response) => { + return response[0].tag_name }) - return Promise.all([github_promise, pluto_promise]) - } + const pluto_promise = client.send("get_version").then((u) => { + client.pluto_version = u.message.pluto + client.julia_version = u.message.julia + return client.pluto_version + }) - // TODO: reconnect with a delay if the last request went poorly - // this would avoid hanging UI when the connection is lost maybe? + return Promise.all([github_promise, pluto_promise]) } diff --git a/frontend/components/CellInput.js b/frontend/components/CellInput.js index 4d5faaf9b3..7bb933b769 100644 --- a/frontend/components/CellInput.js +++ b/frontend/components/CellInput.js @@ -71,8 +71,6 @@ export const CellInput = ({ on_add_after() const new_value = cm.getValue() - console.log(new_value) - console.log(remote_code_ref.current.body) if (new_value !== remote_code_ref.current.body) { on_submit(new_value) } @@ -96,9 +94,7 @@ export const CellInput = ({ } else { const cursor = cm.getCursor() const token = cm.getTokenAt(cursor) - console.log(cursor) cm.setSelection({ line: cursor.line, ch: token.start }, { line: cursor.line, ch: token.end }) - console.log(token) } } keys[mac_keyboard ? "Cmd-/" : "Ctrl-/"] = () => { diff --git a/frontend/components/Editor.js b/frontend/components/Editor.js index 4c0f3c4c8d..3bfa34ab94 100644 --- a/frontend/components/Editor.js +++ b/frontend/components/Editor.js @@ -1,6 +1,6 @@ import { html, Component } from "../common/Preact.js" -import { PlutoConnection, resolvable_promise } from "../common/PlutoConnection.js" +import { create_pluto_connection, fetch_pluto_versions, resolvable_promise } from "../common/PlutoConnection.js" import { create_counter_statistics, send_statistics_if_enabled, store_statistics_sample, finalize_statistics, init_feedback } from "../common/Feedback.js" import { FilePicker } from "./FilePicker.js" @@ -172,15 +172,15 @@ export class Editor extends Component { if (cell != null) { set_cell_state(update.cell_id, { running: false, - queued: true + queued: true, }) } - break + break case "cell_running": if (cell != null) { set_cell_state(update.cell_id, { running: true, - queued: false + queued: false, }) } break @@ -207,7 +207,7 @@ export class Editor extends Component { break case "cell_added": const new_cell = empty_cell_data(update.cell_id) - new_cell.running = false + new_cell.queued = new_cell.running = false new_cell.output.body = "" this.actions.add_local_cell(new_cell, message.index) break @@ -224,7 +224,10 @@ export class Editor extends Component { } } - const on_establish_connection = () => { + const on_establish_connection = (client) => { + // nasty + Object.assign(this.client, client) + const run_all = this.client.plutoENV["PLUTO_RUN_NOTEBOOK_ON_LOAD"] === "true" // on socket success this.client.send("get_all_notebooks", {}, {}).then(on_remote_notebooks) @@ -293,67 +296,33 @@ export class Editor extends Component { this.setState({ loading: false, }) - console.info("Workspace initialized") + console.info("All cells loaded! 🚂 enjoy the ride") }) } ) }) .catch(console.error) - this.client.fetch_pluto_versions().then((versions) => { - const remote = versions[0] - const local = versions[1] - - window.pluto_version = local - - const base1 = (n) => "1".repeat(n) - - console.log(local) - if (remote != local) { - const rs = remote.slice(1).split(".").map(Number) - const ls = local.slice(1).split(".").map(Number) - - // if the semver can't be parsed correctly, we always show it to the user - if (rs.length == 3 && ls.length == 3) { - if (!rs.some(isNaN) && !ls.some(isNaN)) { - // JS orders string arrays lexicographically, which - in base 1 - is exactly what we want - if (rs.map(base1) <= ls.map(base1)) { - return - } - } - } - alert( - "A new version of Pluto.jl is available! 🎉\n\n You have " + - local + - ", the latest is " + - remote + - ".\n\nYou can update Pluto.jl using the julia package manager.\nAfterwards, exit Pluto.jl and restart julia." - ) - } + fetch_pluto_versions(this.client).then((versions) => { + window.pluto_version = versions[1] }) } const on_connection_status = (val) => this.setState({ connected: val }) - // add me _before_ intializing client - it also attaches a listener to beforeunload - window.addEventListener("beforeunload", (event) => { - const first_unsaved = this.state.notebook.cells.find((cell) => code_differs(cell)) - if (first_unsaved != null) { - window.dispatchEvent(new CustomEvent("cell_focus", { detail: { cell_id: first_unsaved.cell_id } })) - // } else if (this.state.notebook.in_temp_dir) { - // window.scrollTo(0, 0) - // // TODO: focus file picker - } else { - return // and don't prevent the unload - } - console.log("preventing unload") - event.stopImmediatePropagation() - event.preventDefault() - event.returnValue = "" - }) + const on_reconnect = () => { + console.warn("Reconnected! Checking states") + + return true + } - this.client = new PlutoConnection(on_update, on_connection_status) - this.client.initialize(on_establish_connection, { notebook_id: this.state.notebook.notebook_id }) + this.client = {} + create_pluto_connection({ + on_unrequested_update: on_update, + on_connection_status: on_connection_status, + on_reconnect: on_reconnect, + connect_metadata: { notebook_id: this.state.notebook.notebook_id }, + }).then(on_establish_connection) // these are things that can be done to the remote notebook this.requests = { @@ -706,6 +675,24 @@ export class Editor extends Component { } }) + window.addEventListener("beforeunload", (event) => { + const first_unsaved = this.state.notebook.cells.find((cell) => code_differs(cell)) + if (first_unsaved != null) { + window.dispatchEvent(new CustomEvent("cell_focus", { detail: { cell_id: first_unsaved.cell_id } })) + // } else if (this.state.notebook.in_temp_dir) { + // window.scrollTo(0, 0) + // // TODO: focus file picker + } else { + console.warn("unloading 👉 disconnecting websocket") + this.client.kill() + return // and don't prevent the unload + } + console.log("Preventing unload") + event.stopImmediatePropagation() + event.preventDefault() + event.returnValue = "" + }) + setTimeout(() => { init_feedback() finalize_statistics(this.state, this.client, this.counter_statistics).then(store_statistics_sample) @@ -776,7 +763,6 @@ export class Editor extends Component { class="export_card" onClick=${(e) => { const a = e.composedPath().find((el) => el.tagName === "A") - console.log(a) a.download = this.state.notebook.shortpath + ".html" a.href = URL.createObjectURL( new Blob([offline_html({ pluto_version: window.pluto_version, head: document.head, body: document.body })], { diff --git a/frontend/components/ErrorMessage.js b/frontend/components/ErrorMessage.js index 5b0d7e8e15..c606130308 100644 --- a/frontend/components/ErrorMessage.js +++ b/frontend/components/ErrorMessage.js @@ -40,7 +40,6 @@ export const ErrorMessage = ({ msg, stacktrace, cell_id, requests }) => { { pattern: /syntax: extra token after end of expression/, display: (x) => { - console.log(x) const begin_hint = html` { @@ -51,7 +50,6 @@ export const ErrorMessage = ({ msg, stacktrace, cell_id, requests }) => { >` if (x.includes("\n\nBoundaries: ")) { const boundaries = JSON.parse(x.split("\n\nBoundaries: ")[1]).map((x) => x - 1) // Julia to JS index - console.log(boundaries) const split_hint = html`

{ const matched_rewriter = rewriters.find(({ pattern }) => pattern.test(msg)) return html` -

- ${matched_rewriter.display(msg)} -
+
${matched_rewriter.display(msg)}
${stacktrace.length == 0 ? null : html`
diff --git a/frontend/components/FilePicker.js b/frontend/components/FilePicker.js index 98dc2e7381..ce770677d0 100644 --- a/frontend/components/FilePicker.js +++ b/frontend/components/FilePicker.js @@ -70,9 +70,6 @@ export class FilePicker extends Component { } ) - // YAY (dit kan weg als Editor ook een react component is) - window.filePickerCodeMirror = this.cm - this.cm.setOption("extraKeys", { "Ctrl-Enter": this.on_submit, "Ctrl-Shift-Enter": this.on_submit, @@ -131,6 +128,7 @@ export class FilePicker extends Component { } } } + const pathhints = (cm, options) => { const cursor = cm.getCursor() const oldLine = cm.getLine(cursor.line) diff --git a/frontend/components/Welcome.js b/frontend/components/Welcome.js index d7776d5713..a92e67fe13 100644 --- a/frontend/components/Welcome.js +++ b/frontend/components/Welcome.js @@ -1,7 +1,7 @@ import { html, Component } from "../common/Preact.js" import { FilePicker } from "./FilePicker.js" -import { PlutoConnection } from "../common/PlutoConnection.js" +import { create_pluto_connection, fetch_pluto_versions } from "../common/PlutoConnection.js" import { cl } from "../common/ClassTable.js" const create_empty_notebook = (path, notebook_id = null) => { @@ -125,7 +125,74 @@ export class Welcome extends Component { } const on_connection_status = (val) => this.setState({ connected: val }) - this.client = new PlutoConnection(on_update, on_connection_status) + + this.client = {} + create_pluto_connection({ + on_unrequested_update: on_update, + on_connection_status: on_connection_status, + on_reconnect: () => true, + }).then((client) => { + Object.assign(this.client, client) + + this.client.send("get_all_notebooks", {}, {}).then(({ message }) => { + const running = message.notebooks.map((nb) => create_empty_notebook(nb.path, nb.notebook_id)) + + // we are going to construct the combined list: + const combined_notebooks = [...running] // shallow copy but that's okay + get_stored_recent_notebooks().forEach((stored) => { + if (!running.some((nb) => nb.path === stored.path)) { + // if not already in the list... + combined_notebooks.push(stored) // ...add it. + } + }) + + this.setState({ combined_notebooks: combined_notebooks }) + }) + + fetch_pluto_versions(this.client).then((versions) => { + const remote = versions[0] + const local = versions[1] + + window.pluto_version = local + + const base1 = (n) => "1".repeat(n) + + console.log(`Pluto version ${local}`) + if (remote != local) { + const rs = remote.slice(1).split(".").map(Number) + const ls = local.slice(1).split(".").map(Number) + + // if the semver can't be parsed correctly, we always show it to the user + if (rs.length == 3 && ls.length == 3) { + if (!rs.some(isNaN) && !ls.some(isNaN)) { + // JS orders string arrays lexicographically, which - in base 1 - is exactly what we want + if (rs.map(base1) <= ls.map(base1)) { + return + } + } + } + console.log(`Newer version ${remote} is available`) + alert( + "A new version of Pluto.jl is available! 🎉\n\n You have " + + local + + ", the latest is " + + remote + + '.\n\nYou can update Pluto.jl using the julia package manager:\n\nimport Pkg; Pkg.update("Pluto")\n\nAfterwards, exit Pluto.jl and restart julia.' + ) + } + }) + + // to start JIT'ting + this.client.send( + "completepath", + { + query: "nothinginparticular", + }, + {} + ) + + document.body.classList.remove("loading") + }) this.on_open_path = async (new_path) => { window.location.href = await link_open_auto(new_path) @@ -179,33 +246,6 @@ export class Welcome extends Component { componentDidMount() { this.componentDidUpdate() - this.client.initialize(() => { - this.client.send("get_all_notebooks", {}, {}).then(({ message }) => { - const running = message.notebooks.map((nb) => create_empty_notebook(nb.path, nb.notebook_id)) - - // we are going to construct the combined list: - const combined_notebooks = [...running] // shallow copy but that's okay - get_stored_recent_notebooks().forEach((stored) => { - if (!running.some((nb) => nb.path === stored.path)) { - // if not already in the list... - combined_notebooks.push(stored) // ...add it. - } - }) - - this.setState({ combined_notebooks: combined_notebooks }) - }) - - // to start JIT'ting - this.client.send( - "completepath", - { - query: "nothinginparticular", - }, - {} - ) - - document.body.classList.remove("loading") - }) } componentDidUpdate() { diff --git a/frontend/editor.css b/frontend/editor.css index b24f7843d8..26fa3859c1 100644 --- a/frontend/editor.css +++ b/frontend/editor.css @@ -211,35 +211,36 @@ pluto-output div.admonition .admonition-title ~ * { pluto-output div.admonition { background: rgba(68, 149, 28, 0.2); - border: 5px solid rgba(68, 149, 28, 0.4); + border: 5px solid rgb(158, 200, 137); } pluto-output div.admonition .admonition-title { - background: rgba(68, 149, 28, 0.4); + background: rgb(158, 200, 137); + margin: -1px; /* Fixes a rendering glitch in Chrome */ } pluto-output div.admonition.note, pluto-output div.admonition.info, pluto-output div.admonition.hint { background: rgba(50, 115, 200, 0.2); - border: 5px solid rgba(50, 115, 200, 0.4); + border: 5px solid rgb(148, 182, 226); } pluto-output div.admonition.note .admonition-title, pluto-output div.admonition.info .admonition-title, pluto-output div.admonition.hint .admonition-title { - background: rgba(50, 115, 200, 0.4); + background: rgb(148, 182, 226); } pluto-output div.admonition.warning { background: rgba(162, 148, 30, 0.2); - border: 5px solid rgba(162, 148, 30, 0.4); + border: 5px solid rgb(207, 199, 138); } pluto-output div.admonition.warning .admonition-title { - background: rgba(162, 148, 30, 0.4); + background: rgb(207, 199, 138); } pluto-output div.admonition.danger { background: rgba(200, 67, 50, 0.2); - border: 5px solid rgba(200, 67, 50, 0.4); + border: 5px solid rgb(226, 157, 148); } pluto-output div.admonition.danger .admonition-title { - background: rgba(200, 67, 50, 0.4); + background: rgb(226, 157, 148); } pluto-output div.admonition.hint .admonition-title ~ * { @@ -557,7 +558,7 @@ nav:after { } body.disconnected > header > nav#at_the_top:after { - content: "DISCONNECTED"; + content: "Reconnecting..."; } body.loading > header > nav:after { @@ -843,24 +844,43 @@ pluto-cell.errored > pluto-trafficlight { } pluto-cell.queued > pluto-trafficlight { - background: repeating-linear-gradient(-45deg, hsla(20, 20%, 85%, 1), hsla(20, 20%, 85%, 1) 8px, hsla(20, 20%, 85%, 0.1) 8px, hsla(20, 20%, 85%, 0.1) 16px); + background: repeating-linear-gradient( + -45deg, + hsla(20, 20%, 75%, 0.6), + hsla(20, 20%, 75%, 0.6) 8px, + hsla(20, 20%, 75%, 0.06) 8px, + hsla(20, 20%, 75%, 0.06) 16px + ); background-size: 4px 22.62741699797px; /* 16 * sqrt(2) */ animation: 3000s linear 0s infinite running scrollbackground; } pluto-cell.running > pluto-trafficlight { - background: repeating-linear-gradient(-45deg, hsla(20, 20%, 75%, 1), hsla(20, 20%, 75%, 1) 8px, hsla(20, 20%, 75%, 0.1) 8px, hsla(20, 20%, 75%, 0.1) 16px); + background: repeating-linear-gradient(-45deg, hsla(20, 20%, 70%, 1), hsla(20, 20%, 70%, 1) 8px, hsla(20, 20%, 70%, 0.1) 8px, hsla(20, 20%, 70%, 0.1) 16px); background-size: 4px 22.62741699797px; /* 16 * sqrt(2) */ - animation: 700s linear 0s infinite running scrollbackground; + animation: 1500s linear 0s infinite running scrollbackground; +} + +pluto-cell.queued.errored > pluto-trafficlight { + background: repeating-linear-gradient( + -45deg, + hsla(0, 100%, 71%, 0.7), + hsla(0, 100%, 71%, 0.7) 8px, + hsla(12, 71%, 47%, 0.2) 8px, + hsla(12, 71%, 47%, 0.2) 16px + ); + background-size: 4px 22.62741699797px; + /* 16 * sqrt(2) */ + animation: 3000s linear 0s infinite running scrollbackground; } pluto-cell.running.errored > pluto-trafficlight { background: repeating-linear-gradient(-45deg, hsl(0, 100%, 71%), hsl(0, 100%, 71%) 8px, hsla(12, 71%, 47%, 0.33) 8px, hsla(12, 71%, 47%, 0.33) 16px); background-size: 4px 22.62741699797px; /* 16 * sqrt(2) */ - animation: 2000s linear 0s infinite running scrollbackground; + animation: 1500s linear 0s infinite running scrollbackground; } @keyframes scrollbackground { diff --git a/frontend/sw.js b/frontend/sw.js index 9db9966cb4..3805939bd8 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -3,7 +3,7 @@ var CACHE_NAME = "pluto-cache-v1" self.addEventListener("install", function (event) { - console.log("hello from sw") + console.log("Hello from service worker 👋") // Perform install steps event.waitUntil( caches.open(CACHE_NAME).then(function (cache) { diff --git a/src/evaluation/Run.jl b/src/evaluation/Run.jl index b51d31b8e8..ed942a85cb 100644 --- a/src/evaluation/Run.jl +++ b/src/evaluation/Run.jl @@ -35,15 +35,18 @@ function run_reactive!(session::ServerSession, notebook::Notebook, old_topology: to_run = setdiff(union(new_order.runnable, old_order.runnable), keys(new_order.errable))::Array{Cell,1} # TODO: think if old error cell order matters # change the bar on the sides of cells to "queued" + local listeners = ClientSession[] for cell in to_run cell.queued = true - putnotebookupdates!(session, notebook, clientupdate_cell_queued(notebook, cell)) + listeners = putnotebookupdates!(session, notebook, clientupdate_cell_queued(notebook, cell); flush=false) end for (cell, error) in new_order.errable cell.running = false relay_reactivity_error!(cell, error) - putnotebookupdates!(session, notebook, clientupdate_cell_output(notebook, cell)) + listeners = putnotebookupdates!(session, notebook, clientupdate_cell_output(notebook, cell); flush=false) end + flushallclients(session, listeners) + # delete new variables that will be defined by a cell new_runnable = new_order.runnable diff --git a/src/webserver/MsgPack.jl b/src/webserver/MsgPack.jl index cbd1a67181..b2ba744e96 100644 --- a/src/webserver/MsgPack.jl +++ b/src/webserver/MsgPack.jl @@ -4,7 +4,7 @@ import UUIDs: UUID import MsgPack -# MsgPack.jl doesn't define a serialization method for MIME and UUID objects, so we these ourselves: +# MsgPack.jl doesn't define a serialization method for MIME and UUID objects, so we write these ourselves: MsgPack.msgpack_type(m::Type{<:MIME}) = MsgPack.StringType() MsgPack.msgpack_type(u::Type{UUID}) = MsgPack.StringType() MsgPack.to_msgpack(::MsgPack.StringType, m::MIME) = string(m) diff --git a/src/webserver/PutUpdates.jl b/src/webserver/PutUpdates.jl index 887d48dec1..cbefd5c0a9 100644 --- a/src/webserver/PutUpdates.jl +++ b/src/webserver/PutUpdates.jl @@ -23,7 +23,7 @@ end const MSG_DELIM = Vector{UInt8}(codeunits("IUUQ.km jt ejggjdvmu vhi")) # riddle me this, Julius "Send `messages` to all clients connected to the `notebook`." -function putnotebookupdates!(session::ServerSession, notebook::Notebook, messages::UpdateMessage...) +function putnotebookupdates!(session::ServerSession, notebook::Notebook, messages::UpdateMessage...; flush::Bool=true) listeners = filter(collect(values(session.connected_clients))) do c c.connected_notebook !== nothing && c.connected_notebook.notebook_id == notebook.notebook_id @@ -31,17 +31,17 @@ function putnotebookupdates!(session::ServerSession, notebook::Notebook, message for next_to_send in messages, client in listeners put!(client.pendingupdates, next_to_send) end - flushallclients(session, listeners) + flush && flushallclients(session, listeners) listeners end "Send `messages` to all connected clients." -function putplutoupdates!(session::ServerSession, messages::UpdateMessage...) +function putplutoupdates!(session::ServerSession, messages::UpdateMessage...; flush::Bool=true) listeners = collect(values(session.connected_clients)) for next_to_send in messages, client in listeners put!(client.pendingupdates, next_to_send) end - flushallclients(session, listeners) + flush && flushallclients(session, listeners) listeners end @@ -51,6 +51,7 @@ function putclientupdates!(client::ClientSession, messages::UpdateMessage...) put!(client.pendingupdates, next_to_send) end flushclient(client) + client end "Send `messages` to the `ClientSession` who initiated." diff --git a/src/webserver/WebServer.jl b/src/webserver/WebServer.jl index 68d2aca4a2..a78a572ba5 100644 --- a/src/webserver/WebServer.jl +++ b/src/webserver/WebServer.jl @@ -50,6 +50,8 @@ function run(host, port::Union{Nothing,Integer}=nothing; launchbrowser::Bool=fal end end + kill_server = Ref{Function}(identity) + servertask = @async HTTP.serve(hostIP, UInt16(port), stream=true, server=serversocket) do http::HTTP.Stream # messy messy code so that we can use the websocket on the same port as the HTTP server @@ -62,6 +64,7 @@ function run(host, port::Union{Nothing,Integer}=nothing; launchbrowser::Bool=fal if !isopen(clientstream) return end + try while !eof(clientstream) # This stream contains data received over the WebSocket. # It is formatted and MsgPack-encoded by send(...) in PlutoConnection.js @@ -86,8 +89,7 @@ function run(host, port::Union{Nothing,Integer}=nothing; launchbrowser::Bool=fal process_ws_message(session, parentbody, clientstream) catch ex if ex isa InterruptException - rethrow(ex) - # TODO: this won't work, `upgrade` wraps our function in a try without catch + kill_server[]() elseif ex isa HTTP.WebSockets.WebSocketError # that's fine! elseif ex isa InexactError @@ -99,10 +101,18 @@ function run(host, port::Union{Nothing,Integer}=nothing; launchbrowser::Bool=fal end end end + catch ex + if ex isa InterruptException + kill_server[]() + else + bt = stacktrace(catch_backtrace()) + @warn "Reading WebSocket client stream failed for unknown reason:" exception = (ex, bt) + end + end end catch ex if ex isa InterruptException - rethrow(ex) + kill_server[]() elseif ex isa Base.IOError # that's fine! elseif ex isa ArgumentError && occursin("stream is closed", ex.msg) @@ -154,25 +164,28 @@ function run(host, port::Union{Nothing,Integer}=nothing; launchbrowser::Bool=fal launchbrowser && @warn "Not implemented yet" - # create blocking call: + kill_server[] = () -> @sync begin + println("\n\nClosing Pluto... Restart Julia for a fresh session. \n\nHave a nice day! 🎈") + @async close(serversocket) + # TODO: HTTP has a kill signal? + # TODO: put do_work tokens back + for client in values(session.connected_clients) + @async close(client.stream) + end + empty!(session.connected_clients) + for (notebook_id, ws) in WorkspaceManager.workspaces + @async WorkspaceManager.unmake_workspace(ws) + end + end + try + # create blocking call and switch the scheduler back to the server task, so that interrupts land there wait(servertask) catch e - if isa(e, InterruptException) - @sync begin - println("\n\nClosing Pluto... Restart Julia for a fresh session. \n\nHave a nice day! 🎈") - @async close(serversocket) - # TODO: HTTP has a kill signal? - # TODO: put do_work tokens back - empty!(session.notebooks) - for client in values(session.connected_clients) - @async close(client.stream) - end - empty!(session.connected_clients) - for (notebook_id, ws) in WorkspaceManager.workspaces - @async WorkspaceManager.unmake_workspace(ws) - end - end + if e isa InterruptException + kill_server[]() + elseif e isa TaskFailedException + # nice! else rethrow(e) end @@ -185,6 +198,7 @@ run(port::Union{Nothing,Integer}=nothing; kwargs...) = run("127.0.0.1", port; kw function process_ws_message(session::ServerSession, parentbody::Dict, clientstream::IO) client_id = Symbol(parentbody["client_id"]) client = get!(session.connected_clients, client_id, ClientSession(client_id, clientstream)) + client.stream = clientstream # it might change when the same client reconnects messagetype = Symbol(parentbody["type"]) request_id = Symbol(parentbody["request_id"])