Skip to content

Conversation

adiholden
Copy link
Contributor

The bug:
calling lua_error does not return, instead it unwinds the Lua call stack until an error handler is found or the
script exits. This lead to memory leak on object that should release memory in destructor.
Specific example is the absl::FixedArray<string_view, 4> args(argc); which allocates on heap if argc > 4. The free was not called leading to memory leak.
The fix:
Add scoping to RedisGenericCommand and call RaiseError which calls lua_error after goto after destructors are called

Signed-off-by: adi_holden <[email protected]>
@adiholden adiholden requested review from romange and chakaz December 1, 2024 20:36
@romange
Copy link
Collaborator

romange commented Dec 1, 2024

Good catch, Adi! What do you mean by scoping? how this change makes sure that args are released now?

@@ -467,7 +469,7 @@ int RedisLogCommand(lua_State* lua) {
int argc = lua_gettop(lua);
if (argc < 2) {
PushError(lua, "redis.log() requires two arguments or more.");
return RaiseError(lua);
RaiseError(lua);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that keeping the return part here makes sense, like the lua API docs mention (this way readers can't be mislead to think that the flow will continue)
Unless you rename it like I suggested above, then I think it's ok as is

@chakaz
Copy link
Contributor

chakaz commented Dec 2, 2024

And indeed, very nice catch!

Signed-off-by: adi_holden <[email protected]>
@adiholden adiholden requested review from chakaz and romange December 2, 2024 12:17
@@ -993,7 +969,11 @@ int Interpreter::RedisGenericCommand(bool raise_error, bool async, ObjectExplore
/* Pop all arguments from the stack, we do not need them anymore
* and this way we guaranty we will have room on the stack for the result. */
lua_pop(lua_, argc);
return std::make_optional(args);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

args reference name_buffer, which is on stack 🤷🏼


std::optional<int> Interpreter::CallRedisFunction(bool* raise_error, bool async,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the interface here is too much. It has optional that wraps int that based on the code can return only 0 and 1, and we have raise_error that is another output argument?

  1. CallRedisFunction should return number of arguments it put on stack. Does CallRedisFunction ever return 0? Is lua stack really on 0 when it happens ?
  2. raise_error seems to be relevant only if CallRedisFunction returns nullopt.

Maybe introduce CallResult = variant<int, bool> so if the first argument is defined then it's number of results and the second is raise_error?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

simplified the flow

@@ -1017,9 +997,9 @@ int Interpreter::RedisGenericCommand(bool raise_error, bool async, ObjectExplore
return 0;

// Raise error for regular 'call' command if needed.
if (raise_error && translator->HasError()) {
if (*raise_error && translator->HasError()) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does it mean

if (!translator)
    return 0;

above ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually dont know.. this is not code that I changed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see we use it inside DragonflyHashCommand, were we define a custom explorer StringCollectorTranslator
but why do we return here 0 I dont know

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed it to return 1 and it looks like everything works

Signed-off-by: adi_holden <[email protected]>
Signed-off-by: adi_holden <[email protected]>
Signed-off-by: adi_holden <[email protected]>

// Calls redis function
// return true if error needs to be raised in case api returns error.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's kinda backwards to return true if there's an error, and false on success, don't you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I flipped it though I do find it convenient to return true if we need to raise an error

return 1;
}

// IMPORTANT! all allocations withing this funciton must be freed
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// IMPORTANT! all allocations withing this funciton must be freed
// IMPORTANT! all allocations within this funciton must be freed

std::optional<absl::FixedArray<std::string_view, 4>> args = PrepareArgs();
if (args.has_value()) {
raise_error = CallRedisFunction(raise_error, async, explorer, SliceSpan{*args});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need an else here to force raise_error to be true?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so the idea is that raise error is a param to function RedisGenericCommand
and if we need to raise error in case of an error it will be true when this function is called
the call to CallRedisFunction will overide this param if the invocation of the command was successful

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I don't have the full context here..
What's the purpose of PushError() without calling RaiseErrorAndAbort()? Where is this error later read?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when calling RaiseErrorAndAbort the script execution will abort
when pushing and error and not calling RaiseErrorAndAbort the script will not abort, so the error will be returned for the executed command and the script writer can decide how to handle this error

Signed-off-by: adi_holden <[email protected]>
Copy link
Collaborator

@romange romange left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@adiholden adiholden merged commit 7a23ec2 into main Dec 3, 2024
9 checks passed
@adiholden adiholden deleted the fix_memory_leak branch December 3, 2024 14:47
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 this pull request may close these issues.

3 participants