Skip to content

Debugpy with Webassembly (proposal)

Graham Wheeler edited this page Sep 7, 2022 · 34 revisions

This page seeks to describe WebAssembly and how Debugpy might be modified to support debugging CPython running with WebAssembly. It's in the wiki for now as it made it convenient to have a document viewable by everyone.

Table of Contents

What is WebAssembly?

How is WebAssembly code loaded into the browser?

Do we have to implement all of posix?

Emscripten is the likely choice for WASM

What does Debugpy/pydevd need?

Debugpy and how it connects

Solution: Implementing our own sockets

Other potential solutions

What is WebAssembly?

WebAssembly (abbreviated Wasm) is a binary instruction format for a stack-based virtual machine. Wasm is designed as a portable compilation target for programming languages, enabling deployment on the web for client and server applications.

source: https://megaease.com/blog/2021/09/17/extend-backend-application-with-webassembly/

(Note: for Python, there are no plans to compile Python code to WebAssembly; instead, the CPython interpreter is compiled to WebAssembly).

How is webassembly code loaded into the browser?

How does this code:

#include <stdio.h>

int main()
{
    printf("Hello World\n");
    return 0;
}

get turned into something like so?

image

WebAssembly.instantiate

The first step is something called WebAssembly.instantiate.

Javascript code loads the 'wasm' module and calls WebAssembly.instantiate (or WebAssembly.instantiateStreaming) on it.

const instance = await WebAssembly.instantiate(wasmModule, imports);

This call loads the WASM into the web page.

Javascript can now do things like so:

instance.export.main();

Which would call the 'main' function on the wasm.

How does the 'print' work?

When the C code is built, it had dependencies on different libs. WASM externalizes these dependencies by creating an import table. Something like so:

  (import "wasi_snapshot_preview1" "proc_exit" (func $wasi_snapshot_preview1.proc_exit (type $t4)))
  (import "wasi_snapshot_preview1" "fd_write" (func $wasi_snapshot_preview1.fd_write (type $t11)))
  (import "wasi_snapshot_preview1" "fd_close" (func $wasi_snapshot_preview1.fd_close (type $t1)))
  (import "wasi_snapshot_preview1" "fd_seek" (func $wasi_snapshot_preview1.fd_seek (type $t12)))

This is the list of imports required by the simple hello world. These imports are what print uses to actually print to stdout.

  • proc_exit to be called for cleanup
  • fd_write to write to stdout
  • fd_close to finish using stdout
  • fd_seek to seek to the beginning of stdout

Implementing the imports

During the instantiate, the javascript code has to provide this 'table' of imports.

Here's an example that gets 'Hello World' to print into the console:

        var heapu32;
        var heapu8;
        var stdout = console.log.bind(console);
        var stderr = console.warn.bind(console);
        var streams = ['', '', ''];
        function printChar(stream, curr) {
            var dest = stream === 1 ? stdout : stderr;
            if (curr === 0 || curr === 10) {
                var str = streams[stream];
                dest(str);
                streams[stream] = '';
            } else {
                streams[stream] += String.fromCharCode(curr);
            }
        }
        function _fd_write(fd, iov, iovcnt, pnum) {
            var num = 0;
            for (var i = 0; i < iovcnt; i++) {
                var ptr = heapu32[((iov) >> 2)];
                var len = heapu32[(((iov) + (4)) >> 2)];
                iov += 8;
                for (var j = 0; j < len; j++) {
                    printChar(fd, heapu8[ptr + j]);
                }
                num += len;
            }
            heapu32[((pnum) >> 2)] = num;
            return 0;
        }
        function _fd_close(fd) {
            return 0;
        }
        function _fd_fdstat_get(fd, iov) {
            return 0;
        }
        function _fd_seek(fd, offset, where) {
            return 0;
        }
        function _proc_exit() {
            return 0;
        }
        const imports = {};
        imports.wasi_snapshot_preview1 = {};
        imports.wasi_snapshot_preview1.fd_write = _fd_write;
        imports.wasi_snapshot_preview1.fd_close = _fd_close;
        imports.wasi_snapshot_preview1.fd_fdstat_get = _fd_fdstat_get;
        imports.wasi_snapshot_preview1.fd_seek = _fd_seek;
        imports.wasi_snapshot_preview1.proc_exit = _proc_exit;

        fetch("hello_world_wasi.wasm")
            .then(resp => WebAssembly.instantiateStreaming(resp, imports))
            .then(result => {
                console.log(`Starting wasm`);
                heapu32 = new Uint32Array(result.instance.exports.memory.buffer);
                heapu8 = new Uint8Array(result.instance.exports.memory.buffer);
                result.instance.exports._start();
            })

There's some interesting things to note here:

  • fd_write needs to treat things as pointers to memory, reading one byte at a time. There is no string that's passed through, it's the raw bytes of the data written to stdout. Basically implementing the writev from POSIX.
  • The data in fd_write, are just pointers to the memory. They're not the actual buffers. Meaning just addresses (offsets) into the C program's heap.
  • The memory export allows the Javascript code to read the heap from the C code.

Do we have to implement all of posix?

That depends. There are a number of tools that pregenerate the javascript glue code that binds the WASM to something usable in javascript:

Tool Description Threads Memory allocation Dynamic Linking Builtin File IO Sockets Easy to override imports
Emscripten Custom compiler and linker for C/C++/Rust code that can auto generate javascript and/or html output. Most widely used WASM build/packager ✔️ ✔️ ✔️
(sort of, broken right now)
✔️ ✔️
(client only)
wasm-pack Compiler add on for Rust code that generates javascript glue code ✔️ ✔️
(sort of, using custom async api)
✔️
wasi sdk Custom version of clang for C/C++/Rust. Only really supports memory management. Everything else must be passed into the import table ✔️ ✔️

Emscripten is the likely choice for WASM

CPython uses sockets, threads, custom memory allocation and dynamic linking. So basically all of the features listed in the table above. As of right now, the emscripten build of CPython sort of works with sockets, dynamic linking, and threads.

The WASI sdk is easier to override things (like file io) and the VS code team has been using the WASI sdk as the basis for providing a file system. However the WASI sdk does not support threads, networking, or a lot of the bits needed to get CPython running.

What does Debugpy/pydevd need?

Debugpy requires:

  • Sockets - for connecting to the debuggee at least. The debuggee uses sockets to communicate debugger messages in/out
  • Dynamic linking - imports used by pydevd require dynamic linking. This could be removed though. Dynamic modules used are for attach to PID and speeding up tracing.
  • File IO - Debugpy needs to load imports (usually from disk). This could also be removed with a custom importer. However, if we're implementing File IO for the VS code workspace, it would simpler to just load from disk.
  • Threads - debugpy handles the socket communication on worker threads.

This means we could potentially use the CPython thread enabled build for Emscripten and not worry about the dynamic linking problems with pthreads.

Debugpy and how it connects

This diagram shows the two ways debugpy currently connects to VS code:

Connect mode:

Listen mode:

In both of these modes there has to be a socket server somewhere. Either debugpy creates one or an external source creates one.

Websockets are client only

When running in the web, this presents a problem. Websockets are client only.

Meaning this picture doesn't work:

At least when using Websockets (which is the default for Emscripten when building with socket support).

Solution: Implementing our own sockets

In the connect picture:

Pydevd is connecting through websockets back to a server sending DAP messages. What if instead of using websockets, it was just sending messages out of a webworker?

This would be possible by overriding the socket implementation that Emscripten generates when evaling the python.js file that is created for running CPython.

To do this, we can provide a --js-library command line when building CPython or patch the javascript generated for CPython. The patched javascript would provide new imports for the following:

  (import "wasi_snapshot_preview1" "sock_accept" (func $__imported_wasi_snapshot_preview1_sock_accept (type $t3)))
  (import "wasi_snapshot_preview1" "sock_recv" (func $__imported_wasi_snapshot_preview1_sock_recv (type $t16)))
  (import "wasi_snapshot_preview1" "sock_send" (func $__imported_wasi_snapshot_preview1_sock_send (type $t8)))
  (import "wasi_snapshot_preview1" "sock_shutdown" (func $__imported_wasi_snapshot_preview1_sock_shutdown (type $t1)))

More details

The full blown picture of this solution looks more like so:

image

Where:

  • Debugger extension implements a DebugAdapterInlineImplementation
  • The DebugAdapterInlineImplementation registers the server side of a sync-api.
  • The DebugAdapterInlineImplementation starts the debugger by creating a webworker with the CPython.js and passing it the command line for debugpy
  • Debugpy is on disk next to the CPython.js lib files so it can be found
  • The CPython.js is patched to send socket requests back over the sync-api.
  • The DebugAdapterInlineImplementation translates those socket requests into DAP messages

Other potential solutions

If custom sockets proves to be too complicated, what else might we do?

File IO instead of sockets

Instead of using a socket to communicate with the DAP server, the DAP server might write to a file instead. You could imagine the debugpy code's COMM layer would be changed to instead spawn threads that just sit and read from the file.

The problem with this approach is that linux file IO is non blocking. Socket IO can be blocking, but file IO can't. It will ALWAYS attempt a read and return immediately. If there's no data available, it just returns zero bytes read. This might work but the IO threads would have to poll (sleep for so much time, check read, and then go back to sleep).

What about aio_read?

Linux supports async io on files with the aio_read API. Unfortunately this isn't supported in Emscripten or the WASI sdk yet.

What about Atomics.wait?

Instead of chewing up a core with a sleep/read cycle in python, we might implement a custom import for pydevd that uses Atomics.wait.

The downside to this option is we end up with having to implement the C Wrapper around CPython so we can import a custom module into Pydevd.

Async python loop

Instead of using a socket to communicate with the DAP server, the DAP server might write to a file and a python async event loop could be used to read from the file on a timer.

Something like so:

import asyncio
import pydevd
import threading
import customer_module

async def polling_loop(loop, file)
   with open(file) as f:
      while (True):
         yield from asyncio.sleep(1, loop=loop)
         msg = readlines()
         pydevd.handlemsg(msg)

def thread_handler(file):
   loop = asyncio.new_event_loop()
   polling_loop(loop, file)

polling_thread = threading.Thread(target = thread_handler, args)
polling_thread.start()

pydevd.start(customer_module)

This would require changes to pydevd to separate out its message handling. Essentially this would implement ASYNC io for files. It is rather hacky and requires a bunch of changes to pydevd. The other problem is that asyncio is currently crashing in the Emscripten build of CPython (bug in the socket implementation)

Py_AddPendingCall

Python has something called Py_AddPendingCall. This is similar to the Async loop idea, where pydevd would be modified to add pending calls that would read from a file at different intervals.

This would require a number of changes to pydevd and has the added disadvantage of not being multithreaded. Meaning this solution might timeout trying to respond to VS code's DAP requests.

This solution does have the added benefit of not needing threads, so if we go down the WASI sdk path instead of using Emscripten, we could use this solution.

C wrapper around python with custom imports/exports

The CPython runtime could be hosted in another C application with this C application itself being built with emscripten.

image

This would work by providing an alternate module for pydevd to use for communication. This module (implemented in the custom C code) would use custom imports in the javascript to send messages.

Essentially this is the same thing as the custom socket implementation but instead of monkey patching CPython's python.js file, pydevd is given a new 'socket' implementation. This might be necessary if it proves too unstable to patch CPython's javascript.

C 'DLL' called by pydevd for comm

(Thanks @int19h for the idea)

CPython supports loading dynamic code modules to be called by python code. This could be utilized to load a WASM module that implements functions to send and receive messages:

image

This works using dynamic linking in Emscripten. The difficult part here is that "C" module would need custom imports. If CPython is the main module, the imports from the "C" module have to be included in the linking step for the CPython.js file or it will fail to load.

This means shipping a custom CPython.js file that includes these imports.

There's also the caveat that dynamic linking and pthreads support is broken right now (crashes) in Emscripten.

Custom import to 'invoke' just for the web

(Thanks @int19h for the idea)

Instead of providing a custom C dll loaded at runtime, the CPython code itself (when built for WASM) could create a dynamic invoke method that could be pulled into a python module.

Something like so:

image

This would require changes to CPython itself as it would be providing the invoke module when running in WASM.

Loaders of the CPython.js would then provide the 'invoke' import. For Debugpy/pydevd, we'd know that Pydevd was using it to send messages.

This wouldn't work for listening to messages though. It would just be a way to execute a method in javascript.