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

Support for Result<T, JsValue> can leak stack space #1963

Closed
alexcrichton opened this issue Jan 17, 2020 · 13 comments · Fixed by #2710
Closed

Support for Result<T, JsValue> can leak stack space #1963

alexcrichton opened this issue Jan 17, 2020 · 13 comments · Fixed by #2710

Comments

@alexcrichton
Copy link
Contributor

Given code that looks like this:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn bar(val: JsValue) -> Result<u64, JsValue> {
    foo(&mut 0);
    Err(val)
}

#[inline(never)]
pub fn foo(a: &mut u128) {
    *a = 1;
}

we can build and run it with:

$ wasm-pack build --target nodejs

and run it with:

const { bar } = require('./pkg/wasm_repro.js');

const err = new Error();
for (let j = 0; j < 100000; j++) {
  try {
    bar(err);
  } catch (e) {
    if (e === err) {
      continue;
    }
    console.log('iteration', j);
    throw e;
  }
}

to yield:

iteration 65536
/home/alex/code/wasm-memory-test/index.js:12
    throw e;
    ^

RuntimeError: memory access out of bounds
    at bar (wasm-function[1]:0x94)
    at module.exports.bar (/home/alex/code/wasm-memory-test/pkg/wasm_repro.js:50:10)
    at Object.<anonymous> (/home/alex/code/wasm-memory-test/index.js:6:5)
    at Module._compile (internal/modules/cjs/loader.js:1063:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1103:10)
    at Module.load (internal/modules/cjs/loader.js:914:32)
    at Function.Module._load (internal/modules/cjs/loader.js:822:14)
    at Function.Module.runMain (internal/modules/cjs/loader.js:1143:12)
    at internal/main/run_main_module.js:16:11

The problem here is apparent when we diassemble the wasm output:

(module
  (type (;0;) (func (param i32)))
  (type (;1;) (func (param i32 i32)))
  (import "./wasm_repro.js" "__wbindgen_rethrow" (func $__wbindgen_rethrow (type 0)))
  (func $bar (type 1) (param i32 i32)
    (local i32)
    global.get 0
    i32.const 16
    i32.sub
    local.tee 2
    global.set 0
    local.get 2
    i64.const 0
    i64.store offset=8
    local.get 2
    i64.const 0
    i64.store
    local.get 2
    call $wasm_repro::foo::h2ba1f0de78e4c442
    local.get 1
    call $wasm_bindgen::throw_val::h4d534fc8bf695b07
    unreachable)
  (func $wasm_repro::foo::h2ba1f0de78e4c442 (type 0) (param i32)
    local.get 0
    i64.const 0
    i64.store offset=8
    local.get 0
    i64.const 1
    i64.store)
  (func $wasm_bindgen::throw_val::h4d534fc8bf695b07 (type 0) (param i32)
    local.get 0
    call $__wbindgen_rethrow
    unreachable)
  (memory (;0;) 17)
  (global (;0;) (mut i32) (i32.const 1048576))
  (export "memory" (memory 0))
  (export "bar" (func $bar))
  (data (;0;) (i32.const 1048576) "\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00"))

Here the problem we can see is that the export and function bar allocate 16 bytes of stack space, but this stack space is never released. This means that our global stack pointer is constantly decreasing, and will never get bumped back up.

The tl;dr; is that every time an error is thrown/return from a Rust/wasm function it runs the risk of not reclaiming used stack space, and this is clearly a bug we need to fix!

@alexcrichton
Copy link
Contributor Author

I'm not really sure of the best way to fix this, but my thinking is that we should fix this relatively quickly (ish) and probably do so with these changes:

  • Document (maybe deprecate?) throw_val and throw_str as not restoring the stack pointer, causing leaks.
  • Reimplement Result<T, JsValue> without throw_val. This will probably involve setting some sort of global "here's the last error" setting in the JS glue, and then returning a dummy zero value. All function calls which could throw would then need to check this global value to see if it's set on returning.

Not great all around unfortunately :(

@Pauan
Copy link
Contributor

Pauan commented Jan 17, 2020

Whatever solution we decide on should also work for throw_val, since some other things use it, such as wasm-bindgen-futures and returning Result from async.

@gabenodarse
Copy link
Contributor

I'm just speaking out of my ass here, but if the problem is that the wasm code to restore the stack pointer never runs, would it be possible to configure a function with something named #[no_return], which can tell the calling function to clean up before passing the values to the #[no_return] function?

@cormacrelf
Copy link
Contributor

cormacrelf commented Feb 8, 2020

@gnodarse throw_val already has this; its return type is !, just like std::process::exit. The problem is that the compiler does not consider this an exit from the function. It only figures that code after it is unreachable, so you can get some dead code eliminated and bounds checks removed, but it won't jump to the destructor block first. Notice that a Drop implementation won't be called when std::process::exit is called:

https://play.rust-lang.org/?version=nightly&mode=debug&edition=2018&gist=ee80d0f5821c8b412d775cb75855d6eb

I noticed something odd in the docs for throw_val state: "This function will not return and the wasm stack will be popped until the point of entry of wasm itself." Was this once accurate? If so, what changed?

I thought it might be possible to use LLVM intrinsics to manipulate the stack pointer directly. We don't need to run any destructors for the supported Result types AFAIK, so this might work. It might be awkward for general use of throw_val, since you need to call the intrinsic before throw_val. Similar thread, @llvm.stacksave/@llvm.stackrestore intrinsics. However, it doesn't seem to work.

Code here
extern "C" {
    #[link_name = "llvm.stacksave"]
    fn stacksave() -> *mut i8;
    #[link_name = "llvm.stackrestore"]
    fn stackrestore(saved: *mut i8);
}

pub fn whatever() {
    let x = unsafe { stacksave() };
    foo(&mut [0u8; 2000]);
    unsafe { stackrestore(x) };
    wasm_bindgen::throw_val(unsafe { mem::transmute(0) })
}

#[inline(never)]
pub fn foo(a: &mut [u8]) {
    a[0] = 1;
}
(func $thing::whatever::hafada7663f9ba4bb (type 0)
    (local i32)
    (global.set 0
      (local.tee 0
        (i32.sub
          (global.get 0)
          (i32.const 2000))))
    (call $thing::foo::h9889bff68136a647
      (call $memset
        (local.get 0)
        (i32.const 0)
        (i32.const 2000))
      (i32.const 2000))
    (call $wasm_bindgen::throw_val::h22d553dc64b7a4ba
      (i32.const 0))
    (unreachable))

It's pretty obvious that rustc doesn't care that we called stacksave before using the array, it put the alloca first, so stacksave/restore does nothing and is eliminated. Not sure how to get around this really.

; thing::whatever
; Function Attrs: noreturn nounwind
define hidden void @_ZN5thing8whatever17hafada7663f9ba4bbE() unnamed_addr #1 {
start:
  %_6 = alloca [2000 x i8], align 1
  %_8 = tail call i8* @llvm.stacksave()
  %0 = getelementptr inbounds [2000 x i8], [2000 x i8]* %_6, i32 0, i32 0
  call void @llvm.lifetime.start.p0i8(i64 2000, i8* nonnull %0)
  call void @llvm.memset.p0i8.i32(i8* nonnull align 1 %0, i8 0, i32 2000, i1 false)
  %_3.0 = bitcast [2000 x i8]* %_6 to [0 x i8]*
; call thing::foo
  call void @_ZN5thing3foo17h9889bff68136a647E([0 x i8]* nonnull align 1 %_3.0, i32 2000)
  call void @llvm.lifetime.end.p0i8(i64 2000, i8* nonnull %0)
  tail call void @llvm.stackrestore(i8* %_8)
; call wasm_bindgen::throw_val
  tail call void @_ZN12wasm_bindgen9throw_val17h22d553dc64b7a4baE(i32 0)
  unreachable

Beyond that, you can't get stack size down to zero just by putting #[inline(never)] on the non-wrapped function and using function stack resetting to do it for you, because of reasons. You can use inline(never) to keep stack size pinned to a smaller value (16 here) even if bar's stack is arbitrarily huge though, so it's a very limited workaround if ~65000 calls would be enough for your needs and you're currently only getting a few hundred, like you do with foo(&mut [0u8; 2000]). Essentially, no fancy tricks with the stack that I've found.

@cormacrelf
Copy link
Contributor

Something I think might work is the following:

#[wasm_bindgen]
extern "C" {
    pub type WasmResult;
    #[wasm_bindgen(constructor)] 
    pub fn new(value: JsValue, isError: bool) -> WasmResult;
}

And then in generated glue code for methods that return a Result<T, E> where T: Into<JsValue>, E: Into<JsValue> or whatever bounds you use (IntoWasmABI?):

use js_sys::Error as JsError;
#[wasm_bindgen]
fn get_result() -> Result<String, JsError> {
    let err = JsError::new("message");
    Err(err)
}
// ... transformed into
#[wasm_bindgen(imagine_i_remember_what_glue_signatures_look_like)]
extern "C" fn get_result() -> JsValue {
    match get_result_inner() {
        Ok(val) => WasmResult::new(val.into(), false).into(),
        Err(err) => WasmResult::new(err.into(), true).into(),
    }
}

Note this exported function always returns, and doesn't actually throw anything, because we have an unwrap function to call once we're out of the Wasm stack:

class WasmResult {
    constructor(value, isError) {
        this.value = value;
        this.isError = isError;
    }
    unwrap() {
        if (this.isError) {
            throw this.value;
        }
        return this.value;
    }
}

export function get_result() {
    let result = wasm.get_result(); // WasmResult
    let taken = takeObject(result);
    return taken.unwrap();
}

If you create an actual JsError from Rust, it will get a nice stack trace at creation time, and this will be preserved when it is re-thrown. Regular values like strings won't get that and you'll see a new trace from the unwrap point which is still close enough because it'll have the function name ('get_result') in it.

@cormacrelf
Copy link
Contributor

It won't work for throw_val, unfortunately. Is that still a hard requirement? I could see fn start() -> Result<...> working just as fn main does. And this is a bit of a lob, but maybe returning results from async can use the reject function in Promise::new that's currently unused? I don't know, but even if it doesn't solve throw_val I think it'd be worth it as it doesn't AFAIK change the API at all and solves the -> Result case. This also seems much simpler than what you've been discussing e.g. in the RFC, and it also hits the glue code de-duplication point. Am I missing something?

@alexcrichton
Copy link
Contributor Author

Indeed yeah something like that should work! Somehow we need to get wasm to fully return and then have the JS shim figure out what to throw. There's various permutations on how that could work precisely, but so long as it works seems fine by me!

@cormacrelf
Copy link
Contributor

Cool! Do you want me to put together a PR or would whipping my work into shape be more work than just letting @Pauan do it?

@alexcrichton
Copy link
Contributor Author

I think Pauan isn't working much on wasm-bindgen any more, so feel free to send a PR @cormacrelf!

@koivunej
Copy link

Lucky to have found this issue as I've been suspecting of such. The proposed wrapping returned in WasmResult sounds like a great idea, I wonder if @cormacrelf ever got to implementing this in a PR or still plan to?

For the throw_val and friends, I don't see any comment mention we could just panic with the value in a newtype and catch unwind at the #[wasm_bindgen] "boundary" methods. Has this perhaps been tried and it caused huge binary sizes or something else?

@cormacrelf
Copy link
Contributor

cormacrelf commented Oct 27, 2021

@koivunej PR! #2710

(Edit, also the panic/catch_unwind doesn't work. You need to throw the error from the glue code. Catch_unwind also uses stack space, likely less than a big stack array would, but non zero. There is a WASM proposal to have exception handling, and I haven't read it in any detail but it appears to implement unwinding natively. If LLVM managed to implement rust-unwinding via wasm exceptions in wasm32-unknown-unknown, then maybe you could just panic, uncaught. I don't know, though. And I don't know if it would offer many features like actual error objects. Upon closer inspection, it basically lets you throw actual exceptions from wasm, that bubble up correctly and do not leak stack space. I cannot figure out whether/how the tags mechanism might allow associating e.g. an error message with the exception.)

@koivunej
Copy link

@cormacrelf on a quick browse on mobile the PR is looking great! I'll try to have a proper look and test in the near-ish future.

re: the panic+catch_unwind, I was under the impression that panics were already fully supported in wasm but started doubting this after posting the idea, forgot to actually test, haven't looked the related wasm proposal.

@cormacrelf
Copy link
Contributor

cormacrelf commented Oct 27, 2021

Panicking is supported. No other assembly language LLVM outputs supports exceptions, frankly an incredibly high level feature, so I assume panicking is implemented in WASM just as it is on x86 or ARM. Think of panic unwinding like returning Err but slightly optimised as no conversions are done on the way up, so function calls for which a panic would require dropping the same objects can share a landing pad. I was just speculating about how if exceptions were available, LLVM might be able to avoid implementing panics "manually", but I realise now that doing so would ignore the responsibility to Drop things as a panic unwinds. I don't know if the exceptions proposal is capable of doing that, and if not, then I have no idea what it is useful for outside GC languages compiled to wasm.

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

Successfully merging a pull request may close this issue.

5 participants