Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Run computation in isolated context #339

Open
dmsnell opened this issue May 13, 2019 · 22 comments
Open

Run computation in isolated context #339

dmsnell opened this issue May 13, 2019 · 22 comments

Comments

@dmsnell
Copy link

dmsnell commented May 13, 2019

See also #100

Being able to run the compilation/computation inside a WebWorker or in an iframe would make it significantly easier to secure the scripts to guard against abuse and it would enhance the viewing experience by moving expensive or runaway calculations out of the UI thread.

After a conversation with @viebel he recommended trying to prototype by replacing eval() with an async version that channels messages through the frames/contexts. Something like this would do…

function isolatedEval( inputSourceCode, mode, options = { timeout: 1000 } ) {
	return new Promise( ( response, reject ) => {
		const [ port1, port2 ] = new MessageChannel();
		let alreadyFinished = false;
		let handle;

		klipseWorker.postMessage( { inputSourceCode, mode, options }, [ port2 ] );

		handle = setTimeout( () => {
			alreadyFinished = true;
			port1.postMessage( 'cancel' );
			port1.close();
			reject( 'timeout' );
		}, timeout );

		port1.onmessage = ( { data: { error, output, wasSuccess } } ) => {
			clearTimeout( handle );
			port1.close();

			if ( alreadyFinished ) {
				return;
			}

			alreadyFinished = true;

			if ( wasSuccess ) {
				resolve( output );
			} else {
				reject( error );
			}
		};
	} );
}

If running inside an iframe we could support view-only DOM creation by sending .innerHTML across the frame as a string and then stiching it up on the host side.

@dmsnell
Copy link
Author

dmsnell commented May 20, 2019

Here are some random notes from my doodling on this issue:

Attempt 1: Load KLIPSE in a WebWorker

This approach fails pretty quickly. I'd like to load the script from inside the WebWorker and then pass source code and output back and forth through messaging. We can load remote scripts, but KLIPSE fails immediate because of the missing DOM API inside the worker.

// worker.js
importScript( 'https://storage.googleapis.com/app.klipse.tech/plugin_prod/js/klipse_plugin.min.js' )
// `document` missing

Attempt 2: Load KLIPSE in an iframe

iframes have DOM access, so theoretically this approach should be feasible even if less performant than the WebWorker.

I was able to get JavaScript running as I expected and was getting the messaging coordinated as I wanted, though that also came with some awkward accounting - to say the least.

https://gist.github.com/dmsnell/929a33d222af31abbee6173d96a0fd1a

When I got to other languages things started falling apart and I believe that this is based on the fact that while JavaScript runs its code through window.eval I think the other languages have a different mechanism or have a different bound copy of window.eval deep inside their libraries.

before I go too far trying to figure this out I'd like to step back and look at changing the KLIPSE code to try running its evaluation inside a WebWorker by modifying unsecured-eval-in-global-scope

Attempt 3: Replace window.eval

In this attempt I tried a number of different ways to replace the global eval() function.

Some ways were directly replacing window.eval before KLIPSE loaded, other were to modify KLIPSE to load js/asyncLoad (a function I made myself) and I couldn't figure out the right way to connect pieces. My replacement function passes the source code into a WebWorker and returns the value in a Promise

// main-window.js
window.eval = input => new Promise( resolve => {
    const { port1, port2 } = new MessageChannel();

    worker.postMessage( input, [ port2 ] );

    port1.onmessage = event => {
        port1.close();

        const [ value, error ] = event.data;

        if ( value ) {
            resolve( value );
        } else {
            resolve( new Error( error ) );
        }
    }
} );

bootKLIPSE();

Where the worker code is pretty straightforward…

// async-worker.js
onmessage = event => {
    const { data, ports } = event;

    const value = (() => {
        try {
            return [ eval( data ) ];
        } catch ( e ) {
            return [ null, e.message ]
        }
    })();

    ports[ 0 ].postMessage( value );
    ports[ 0 ].close();
}

Here are some observations from the experiment:

The async mode appears to switch execution flow from "print the results of the eval()" into "print whatever gets logged to the console." If we could return a Promise and have KLIPSE async-resolve them then we could keep the first flow, which is to display the results of the evaluation and also capture the console. As it stands, depending on how we do it, we display [object Promise] or Promise {}.

Supposing we could use a Promise interface to update asynchronously-obtained results then we have another problem if we shove the computation into a WebWorker - we lose the global context that ties sequential KLIPSE containers together.


Current summary of work: the iframe approach with proper accounting of messages and input seems like the currently most-viable outside approach to isolating the computation. It threads the wanted context throughout the boxes, uses the normal means of execution as expected by KLIPSE, and on the downside is somewhat tacky.

Further explorations could explore separating the DOM-dependent and non-DOM-dependent parts of KLIPSE. It'd be nice if KLIPSE could run as a kind of text-server: this would cause issues with things like pretty-printing and formatting and would make DOM containers harder to use but it would make for an easier way to embed this, even in something like a SharedWorker.

@dmsnell
Copy link
Author

dmsnell commented Sep 21, 2019

Attempt 4

Having run into worker-dom I wondered if I could revisit running KLIPSE inside a WebWorker by providing the expected DOM API. After some initial wresting with worker-dom (I had to download the project and build locally - the copies of the library I found on a CDN weren't working) I was able to initialize KLIPSE partially inside the worker, but I found the following errors during that initialization process, after registering the modes and printing out the settings from klipse_plugin.js.

TypeError: undefined is not an object (evaluating 'f.staticFns')
TypeError: null is not an object (evaluating 'f.cljs$core$IFn$_invoke$arity$0')

The worker-dom library provides much of the DOM API but not all. I'm suspecting that KLIPSE is attempting to access those parts which aren't provided, though that's mostly a conjecture; I spent too much time simply trying to get things to boot up with worker-dom and ran out of debugging time to see why this might be failing.

@workshub
Copy link

workshub bot commented Jun 2, 2020

This issue is now published on WorksHub. If you would like to work on this issue you can
start work on the WorksHub Issue Details page.

@workshub
Copy link

workshub bot commented Jun 2, 2020

If you successfully complete this Issue via WorskHub there's a $300 reward available.
Start work via WorksHub Issue Details page.

@austinksmith
Copy link

@dmsnell I could help integrate this, but I might propose a more elegant solution that would involve compressing the data behind the scenes using a combination of my Hamsters.js library and my lzwFlossRedux library. lzwFloss does lzw compression on the fly using a background worker thread, and also decompresses the data using a worker thread. This would allow you to keep your dom changes on the main thread, while compressing and obfuscating what you want to be secure. You can limit Hamsters.js on startup to use only 1 thread, or you can take advantage of multiple threads if you'd like to run loops/computations in parallel. Let me know what you think about this idea, if I get some spare time I might work on it myself and collect that $300 reward.

https://github.com/austinksmith/lzwFlossRedux.js
https://github.com/austinksmith/Hamsters.js

@dmsnell
Copy link
Author

dmsnell commented Aug 28, 2020

@austinksmith Sounds good. I would propose getting it working before getting it working with compression and backgrounding for performance reasons.

These I think are the essential bits:

  • compute output value of code input as string
  • capture console log messages from code execution
  • transfer DOM output
  • pass context data from one KLIPSE box to the successive ones

@workshub
Copy link

workshub bot commented Sep 24, 2020

A user started working on this issue via WorksHub.

@workshub
Copy link

workshub bot commented Apr 30, 2021

@Verdinjoshua26 started working on this issue via WorksHub.

@workshub
Copy link

workshub bot commented May 30, 2021

A user started working on this issue via WorksHub.

2 similar comments
@workshub
Copy link

workshub bot commented Dec 2, 2021

A user started working on this issue via WorksHub.

@workshub
Copy link

workshub bot commented Jan 31, 2022

A user started working on this issue via WorksHub.

@austinksmith
Copy link

@dmsnell I've solved this problem for you, how do I go about submitting it to javascript works for the bounty?

What i have is 100% working bi-directional message passing from klipse when typing in the input editor box and getting a response back from the klipse worker matching what is expected.

What isn't working is the following.

  1. Using the returned data from the iframe to actually update the box, because currently I have to call the following to get a response returned to the main page context
                window.parent.postMessage(event.data, "*");
  1. The problem is that the original message is posted from the box channel, but the returned message is coming from the main channel. So all that is left is making sure the channel used to respond is the box channel not the main channel, I think you need to create some sort of object to track which box sent what message. I don't know how to determine from the iframe which box sent which message, that's why currently it's all being responded to with the parent main message port, but this seems like a simple enough problem to solve. Here is an example of me typing and the messages going back and forth between the main window and the iframe in real time.

'Its alive!

Cool beans'

@austinksmith
Copy link

Just some notes by the way, it's typically bad practice to try to overwrite a native function ie. overwriting eval. Because eval is a native method using const might be a slick work around but it seems like the redefining of eval is a code smell, or bad practice ... or maybe it just FEELS wrong but either way it seems to be working in this case. I would look at refactoring possibly to make use of new Function which is just like eval but it creates a copy of the original function which can then be called later, or possibly instead of calling eval directly it could just call a klipse unique method, when then calls the invoke message function directly so the only eval called is within the iframe itself.

I think there is a bunch of room for optimization here but given the bounty is $300 i am not able to invest that much time into it, I actually already thought of a solution to the "which box invoked the iframe" problem too but i'd have to play around with it a bit to see if it works. Either way I think using the Iframe is going to be the ideal solution for this particular piece of software because you are almost 100% dealing with dom manipulation and worker threads were not made to deal with that, its a pain but I've forced myself not to diverge from the official worker specification lays out and hacking workers to make dom manipulation work properly is not something I'm prepared to deal with.

@viebel
Copy link
Owner

viebel commented Feb 6, 2022

Will check it soon

@dmsnell
Copy link
Author

dmsnell commented Feb 7, 2022

@austinksmith since you mentioned me here I'll just note that the bounty isn't mine and I'm not sure how to advise you other than following the instructions on WorksHub. Happy to chat about your approach if you want feedback though.

@austinksmith
Copy link

@dmsnell I'm sure github should be fine if the solution works out.

Here is klipse-frame.js

const waitUntil = ( p ) => new Promise( resolve => {
    const runner = () => {
        const v = p();

        if ( v ) {
            resolve( v );
        }

        requestIdleCallback( runner );
    }

    runner();
} );

const findEditor = wrapper => waitUntil( () => {
    const editorIndex = Object
        .keys( window.klipse_editors )
        .find( 
            i => window.klipse_editors[ i ].display.wrapper.parentNode.parentNode === wrapper 
        );

    if ( null === editorIndex ) {
        return false;
    }

    const editor = window.klipse_editors[ editorIndex ]; 
    if ( null === editor ) {
        return false;
    }

    return editor;
} );

document.addEventListener('DOMContentLoaded', async (event) => {
    //Create new messsage channel to send messages from iframe to main window
    let controlChannel = new MessageChannel();

    //Use port1 as the parentPort for messages
    window.parentPort = controlChannel.port1;
    window.parentEventData = {};
    //Await klipse loading before continuing, seems hacky using waitUntil method..
    await waitUntil(() => !!window.klipse );

    //Get dom element blocks
    let blocks = document.getElementById('blocks');

    //Set parentPort onMessage receive handler
    //Called when creating a new block
    parentPort.onmessage = async (event) => {
        if ( event.data.action === 'new-block' ) {
            //Parse incoming event
            let { data: { mode }, ports } = event;
            let id = Symbol();
            // construct shadow DOM editor
            let wrapper = document.createElement( 'div' );
            let input = document.createElement( 'div' );
            wrapper.appendChild( input );
            blocks.appendChild( wrapper );

            window.klipse.plugin.klipsify( input, window.klipse_settings, mode);

            await waitUntil( () => !! window.klipse_editors );
            window.evalPorts.set( id, ports[ 0 ] );

            //Called when content in a block changes
            ports[ 0 ].onmessage = async (event) => {
                let queue = window.pushQueue.get( event.data ) || new Set();
                queue.add( id );
                window.pushQueue.set( event.data, queue );
                let editor = await findEditor( wrapper );
                console.log("KLIPSE FRAME RECEIVED MESSAGE!! ", event.data);
                // editor.setValue(event.data);
                window.parent.postMessage(event.data, "*");
            }
        }
    }
    //This returns successfully and console logs fine on main window
    window.parent.postMessage( 'klipse-loaded', '*', [ controlChannel.port2 ] );
}, false );

Here is klipse-frame.html

<link rel="stylesheet" href="https://storage.googleapis.com/app.klipse.tech/css/codemirror.css">

<script>
window.klipse_settings = {
    eval_idle_ms: 1000,
    selector_eval_js: '.eval-js',
    selector_eval_markdown: '.eval-markdown',
    selector_eval_ocaml: '.eval-ocaml',
};

const log = console.log.bind( console );
const oldEval = window.eval;

window.evalPorts = new Map();
window.pushQueue = new Map();
window.eval = source => {
    const value = oldEval(source);
    const ports = window.pushQueue.get( source ) || new Set();

    [...ports].map((id) => {
      window.evalPorts.get(id);
    }).forEach((port) => {
      port.postMessage(value);
    });

    window.pushQueue.delete(source);

    return value;
}
</script>

<div id="blocks"></div>

<script src="https://storage.googleapis.com/app.klipse.tech/plugin/js/klipse_plugin.js"></script>
<script src="klipse-frame.js"></script>

Here is index.js

const getPort = () => new Promise( resolve => {
    window.addEventListener( 'message', event => {
        console.log("MAIN CHANNEL RECEIVED RESPONSE FROM KLIPSE FRAME!! ", event.data);
        if ( event.data === 'klipse-loaded' ) {
            resolve( event.ports[ 0 ] );
        }
    } );

    const klipseFrame = document.createElement( 'iframe' );
    klipseFrame.setAttribute( 'style', 'display: none;' );
    klipseFrame.src = 'klipse-frame.html';
    document.body.appendChild( klipseFrame );
} );

document.addEventListener('DOMContentLoaded', async () => {
    const controlPort = await getPort();

    const blocks = document.getElementById( 'blocks' );
    const newBlockButton = document.getElementById( 'newBlockButton' );
    const modeSelector = document.getElementById( 'modeSelector' );
    newBlockButton.addEventListener( 'click', event => {
        // create DOM editor and output
        const container = document.createElement( 'div' );

        const sourcebox = document.createElement( 'textarea' );
        sourcebox.setAttribute( 'style', 'width: 80%; height: 300px; font-size: 16px;');

        const outputBox = document.createElement( 'div' );
        const outputPre = document.createElement( 'pre' );
        const output = document.createElement( 'code' );

        outputPre.appendChild( output );
        outputBox.appendChild( outputPre );

        container.appendChild( sourcebox );
        container.appendChild( outputBox );

        blocks.appendChild( container );

        // connect editor to KLIPSE
        const blockChannel = new MessageChannel();
        //Post new block message to klipse frame
        controlPort.postMessage({
            action: 'new-block',
            mode: modeSelector.value
        }, [ blockChannel.port2 ]);


        //Send new message to klipse frame on input change
        sourcebox.addEventListener( 'input', (event) => {
            blockChannel.port1.postMessage(sourcebox.value);
        });

        //Handle response back from klipse frame
        blockChannel.port1.onmessage = event => {
            console.log("BLOCK CHANNEL RECEIVED RESPONSE FROM KLIPSE FRAME!! ", event.data);
            output.innerHTML = event.data;
        }
    }, false );
}, false );

Here is index.html

<div id="blocks"></div>

<select id="modeSelector">
    <option value="eval-javascript">JavaScript</option>
    <option value="eval-markdown">Markdown</option>
    <option value="eval-ocaml">OCaml</option>
</select>
<button id="newBlockButton">New Block</button>

<script src="index.js"></script>html

As I mentioned previously the only thing I didn't finish implementing was guaranteeing the boxMessagePort is used when responding from the iframe, this should be super simple to fix by just changing the original message to be an object like

  {
     sourceBox: this.sourcebox.id,
     portNumber: 0,
     inputValue: sourceBox.value
  }

Hopefully that makes sense but basically it just needs a little more sugar syntax to tie it all together and it should work

@dmsnell
Copy link
Author

dmsnell commented Feb 8, 2022

thanks for your patience with me @austinksmith. can you help me understand what's different about your posting vs. what I shared in my iframe gist? is it mostly that you're sending back the data over the channel?

  • we should be able to create a new MessageChannel pair every time we add a box and then transfer that to the worker. this could eliminate some accounting when synching the source and response
  • did you assess running other languages besides JavaScript? as noted above I ran into issues that suggested needing Clojure code changes for calling the right eval.

@austinksmith
Copy link

thanks for your patience with me @austinksmith. can you help me understand what's different about your posting vs. what I shared in my iframe gist? is it mostly that you're sending back the data over the channel?

* we should be able to create a new `MessageChannel` pair every time we add a box and then transfer that to the worker. this could eliminate some accounting when synching the source and response

This solution doesn't use a worker its just using an iframe, but yes this is creating a new message channel pair for every new box created, however what your original solution lacks is sending the computed dom back to the main window. So basically you were treating the iframe dom as the main window dom, this won't work because the iframe is an isolated context. What you can do is send things like the dom element id of the box that sent the message, compute the new dom you want, send the new dom back to the main window, then apply the previously computed dom to the new box. Ultimately you need to return something from your iframe, and the one computation you wont be able to get around is the actual

box.innerHTML = returnedComputedDom;

* did you assess running other languages besides JavaScript? [as noted above](https://github.com/viebel/klipse/issues/339#issuecomment-493803913) I ran into issues that suggested needing Clojure code changes for calling the right `eval`.

Well no, that isn't what I set out to do. I'm not going to be able to find a solution for that for you since its kind of outside the scope of the request. Basically if it works for javascript you should be able to adapt it for clojure etc. but I recommend sticking with javascript to get everything working.

The previous solution had a few errors, missing semi colons etc and wasn't handling the incoming message from each boxChannel, it was relying on messagePort 2 which was on the parent channel but not the box Channels, now every box channel is sending their messages to the iframe independently of one another. You just need to make sure you call the required klipse methods, precalculate your final dom result, return that result from the iframe to the original box channel that sent the message, and then update the innerHTML of that box with the result.

@dmsnell
Copy link
Author

dmsnell commented Feb 10, 2022

This solution doesn't use a worker its just using an iframe…what your original solution lacks is sending the computed dom back to the main window…iframe dom as the main window dom

The linked gist is for an iframe, as I mentioned above. I'm having a little trouble following you and I wonder if you are addressing two of my different approaches, since the one I asked about is the iframe and not the WebWorker.

send the new dom back to the main window, then apply the previously computed dom to the new box

In your approach (but not in your shared code?) is the same idea for the WebWorker (send back the DOM updates) but use an iframe so we can grab it from a local DOM node instead of relying on a DOM polyfill?

If so, yeah, I think that's reasonable, but I'd want to play around with it and see how the isolation and communication works to say for sure.

Well no, that isn't what I set out to do. I'm not going to be able to find a solution for that for you since its kind of outside the scope of the request.

JavaScript is the easy part, but this request is entirely that scope - running all the languages in isolation 😆

if it works for javascript you should be able to adapt it for clojure etc…You just need to make sure you call the required klipse methods, precalculate your final dom result, return that result from the iframe to the original box channel that sent the message, and then update the innerHTML of that box with the result.

In my mind we can't single out one part of the issue (the easiest part) and claim it's solved. The hard bits for me when I worked on this and left those notes was making sure the data flow, as you describe here, works from end-to-end. That is the substance of what makes this hard. I agree that it seems like it should be trivial and you "just" have to do all those things, but "just" filling in all those details and getting it to work is something that's still unsolved despite the bounty.

I was working on it for my own benefit and not for the bounty, but had to give it up when I got busier with other work. A big part of making this possible is for someone to dive into the evaluation flow and track down in the Clojure(Script) code where we ultimately pass compiled JS to eval(). It appears like JavaScript takes a shortcut here because it requires no translation but it's the other language support that makes this most novel.

For all the other languages though they go through a compilation process and then the output of that gets passed to an eval() that wasn't replaced in my attempts above.

This was a puzzle to me why the other languages failed and if you can figure that out you should have a big clue on how to solve this. It's possible I overlooked something basic when I tried.

@viebel
Copy link
Owner

viebel commented Mar 2, 2022

@austinksmith Once your PR is approved and merged to master by me, I send an email to Functional Works and you get paid.

@dmsnell
Copy link
Author

dmsnell commented Apr 4, 2022

Will have to wait a bit but the ShadowRealm proposal I think will give us the ability to run everything inside a true sandbox, enforced by the browser.

@texastoland
Copy link

texastoland commented Oct 26, 2022

@austinksmith Did this stall? Happy to help wrap it up!

Will have to wait a bit but the ShadowRealm proposal I think will give us the ability to run everything inside a true sandbox, enforced by the browser.

@dmsnell My impression is they provide a clean environment but would still lock the render thread unlike iframe or worker?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants