Reproducing your example here for posterity:
Isolate::CreateParams create_params;
create_params.array_buffer_allocator = &array_buffer_allocator;
Isolate* isolate = Isolate::New(create_params);
Locker locker(isolate);
Isolate::Scope isolate_scope(isolate);
HandleScope handle_scope(isolate);
Local<Context> ctx = Context::New(isolate);
Context::Scope context_scope(ctx);
Local<Script> script;
Local<String> name = String::NewFromUtf8(isolate, "wtf.js");
Local<String> source = String::NewFromUtf8(isolate, "var a = [];
for(var i = 0; i < 300; i++) a.push(new Array(1000000).join('*'));");
ScriptOrigin origin(String::NewFromUtf8(isolate, "wtf.js"));
script = Script::Compile(ctx, source, &origin).ToLocalChecked();
Handle<Value> result = script->Run(ctx).ToLocalChecked();
The first thing that stands out to me is that |ctx| is scoped to the
lifetime of |handle_scope|, i.e., |ctx| won't be eligible for garbage
collection until |handle_scope| goes out of scope.