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

/rfid endpoint - Serve with chunked response #280

Merged
merged 8 commits into from
Dec 29, 2023

Conversation

tueddy
Copy link
Collaborator

@tueddy tueddy commented Dec 12, 2023

/rfid endpoint:

A static buffer of 8KB is currently used to list the RFID tags. If this buffer is full, the returned JSON is truncated and not all entries are displayed in the web interface. . This happens with me from approx. 60 entries (depends on path length). It could also lead to a low memory situation and, in the worst case, to a crash.

With this PR, a list is first created holding the keys only and later the details such as path and game mode are sent as a chunked response with a smaller buffer.
No changes in the delivered JSON except that it is always complete

src/Web.cpp Outdated
}

static String tagIdToJsonStr(const char *key) {
StaticJsonDocument<512> doc;
Copy link
Contributor

Choose a reason for hiding this comment

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

Isn't 512 bytes a bit large for a single tag id?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

A single tag id looks like:

{ "id": "003108198106", "fileOrUrl": "/Die Schönsten Lieder Zum Einschlafen", "playMode": 3, "lastPlayPos": 1842837, "trackLastPlayed": 0 }

maxLen of "fileOrUrl" is MAX_FILEPATH_LENTGH = 256, size calculation with https://arduinojson.org/v6/assistant gives about 350 Bytes needed. Recommended is power of 2, so 512 Bytes is best size.

src/Web.cpp Outdated
}
static std::vector<String> nvsKeys {};
Copy link
Contributor

Choose a reason for hiding this comment

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

This vector will never shrink, just something to be aware of. Though it's good to avoid frequent allocations...

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

nvsKeys is declared as local variable and i assume it gets out of scope after function handleGetRFIDRequest() finished.
Am i wrong here?

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 declared as static, meaning it lives as long as the entire program.
If you didn't add nvsKeys.clear() directly afterwards, I it should continue to grow with each call.

The upside is that push_back does not need to reserve more memory every time and instead can re-use the same heap space. The downside is that the heap space that is the capacity of the vector is never reduced or freed.

Copy link
Contributor

@laszloh laszloh Dec 13, 2023

Choose a reason for hiding this comment

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

The downside is that the heap space that is the capacity of the vector is never reduced or freed.

And because of this, I'd advice against static here. The main issue is, that we do not have any idea, how many entries there are and we are basically reserving heap space (at the minimum sizeof(String) * [maxEntries], propably way more).

Also what you have to keep in mind is, that as soon as std::vector runs out of space in it's internal array, it reserves a new memory block on the heap with the factor of 1.5 / 2 (so if an array of 12 elements is full and another object is pushed, the vector will reserve max 24 entries before moving the memory). This is to ensure an O(n) for reallocations over lifetime of the object.

src/Web.cpp Outdated
if (nvsIndex == 0) {
// start, write first tag
json = tagIdToJsonStr(nvsKeys[nvsIndex].c_str());
len += sprintf(((char *) buffer), "[%s", json.c_str());
Copy link
Contributor

Choose a reason for hiding this comment

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

What if maxlen < json len? Can we handle that case? We should make sure to never write more bytes to buffer than allowed.

Copy link
Contributor

Choose a reason for hiding this comment

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

Note: put the first tag also in the loop, then you won't forget the size check too

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good catch!
I never saw a buffer smaller than 1500 Bytes so JSON length never exceeds.
But i will do an additional check here for code safety.

Copy link
Contributor

Choose a reason for hiding this comment

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

Please always use snprintf instead of sprintf. sprintf is considered dangerous, since it does not check against buffer overflows. snprintf should always be preferred, since it takes a buffer size (here maxLen) and checks that we never write more than the buffer.

nvsKeys.clear();
// Dumps all RFID-keys from NVS into key array
listNVSKeys("rfidTags", &nvsKeys, DumpNvsToArrayCallback);
if (nvsKeys.size() == 0) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Would not need this special handling for size == 0 if in the chunked response there was no special handling for the first tag.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Writing the first tag there is no comma in JSON, it is starting with the second entry.
That's is the reason to handle the special case size==0 here and not even start a chunked response.

Copy link
Contributor

Choose a reason for hiding this comment

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

Not starting the chunked response in the first place is probably a good idea.

Copy link
Contributor

Choose a reason for hiding this comment

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

This looks ok for me, we do not start the chunked response here (just send back an empty array and return)

src/Web.cpp Outdated
}
if (nvsIndex == nvsKeys.size()) {
// finish
len += sprintf(((char *) buffer + len), "]");
Copy link
Contributor

Choose a reason for hiding this comment

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

Edge case: that single byte for ']' or ',' could be one byte more than maxLen.

Why not write '[' and ']' by themselves without sprintf?

Copy link
Contributor

Choose a reason for hiding this comment

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

+1 for not using sprintf but directly accessing the array

src/Web.cpp Outdated
[](uint8_t *buffer, size_t maxLen, size_t index) {
maxLen = maxLen >> 1; // some sort of bug with actual size available, reduce the len
size_t len = 0;
static String json;
Copy link
Contributor

Choose a reason for hiding this comment

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

String json does not needs to be static.

Suggested change
static String json;
String json;

Here you can also use index to detect the start of the chunked response to reset the variable nvsIndex:

if (index==0) {
	// first call for us, reset index
	nvsIndex = 0;
}

src/Web.cpp Outdated
if (nvsIndex == 0) {
// start, write first tag
json = tagIdToJsonStr(nvsKeys[nvsIndex].c_str());
len += sprintf(((char *) buffer), "[%s", json.c_str());
Copy link
Contributor

Choose a reason for hiding this comment

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

Please always use snprintf instead of sprintf. sprintf is considered dangerous, since it does not check against buffer overflows. snprintf should always be preferred, since it takes a buffer size (here maxLen) and checks that we never write more than the buffer.

src/Web.cpp Outdated
return serializedJsonString;
}

uint16_t nvsIndex;
Copy link
Contributor

Choose a reason for hiding this comment

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

Move nvsIndex either as a static variable into the lambda in line 1736 or at least make the variable static (so that it does not pollute the global namespace).

src/Web.cpp Outdated
// construct chunked repsonse
nvsIndex = 0;
AsyncWebServerResponse *response = request->beginChunkedResponse("application/json",
[](uint8_t *buffer, size_t maxLen, size_t index) {
Copy link
Contributor

Choose a reason for hiding this comment

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

If nvsKeys is not static any more, we have to capture the variable here so it's available in the lambda. To prevent a copy of the vector due to capture by value, we call std::move to transfer the variable into the lambda (capture by reference would result in undefined behaviour since the local variable will not exists any more when we are called).

Suggested change
[](uint8_t *buffer, size_t maxLen, size_t index) {
[nvsKeys = std::move(nvsKeys)](uint8_t *buffer, size_t maxLen, size_t index) {

nvsKeys.clear();
// Dumps all RFID-keys from NVS into key array
listNVSKeys("rfidTags", &nvsKeys, DumpNvsToArrayCallback);
if (nvsKeys.size() == 0) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This looks ok for me, we do not start the chunked response here (just send back an empty array and return)

src/Web.cpp Outdated
}
if (nvsIndex == nvsKeys.size()) {
// finish
len += sprintf(((char *) buffer + len), "]");
Copy link
Contributor

Choose a reason for hiding this comment

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

+1 for not using sprintf but directly accessing the array

 /rfid returns an array of tag-id keys
 /rfid/details returns an array of tag-ids and details. Optional GET param "id" to list only a single assignment.
src/Web.cpp Outdated
@@ -457,6 +457,7 @@ void webserverStart(void) {

// RFID
wServer.on("/rfid", HTTP_GET, handleGetRFIDRequest);
wServer.addRewrite(new OneParamRewrite("/rfid/details", "/rfid?details=true"));
Copy link
Contributor

Choose a reason for hiding this comment

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

OneParamRewrite is usually for path parameters in the form of /path/{some_param} -> /path?some_param={some_param} because AsyncWebServer cannot handle path params natively.

Does this even work and do something? Even if it does, using the "normal" AsyncWebRewrite makes more sense: https://github.com/me-no-dev/ESPAsyncWebServer#param-rewrite-with-matching

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes it works fine, do you have a concrete improved code here?

Copy link
Contributor

Choose a reason for hiding this comment

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

Didn't test it, but just replacing 'OneParamRewrite' with 'AsyncWebRewrite' should do the same thing.

src/Web.cpp Outdated
json = tagIdToJsonStr(nvsKeys[nvsIndex].c_str());
len += sprintf(((char *) buffer), "[%s", json.c_str());
json = tagIdToJsonStr(nvsKeys[nvsIndex].c_str(), withDetails);
if (json.length() > maxLen) {
Copy link
Contributor

Choose a reason for hiding this comment

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

You check that, but 4 lines later you're writing json.length() + 1 bytes, so you should also check for json.length() + 1 available space

src/Web.cpp Outdated
nvsIndex++;
}
while (nvsIndex < nvsKeys.size()) {
// write tags as long we have enough room
json = tagIdToJsonStr(nvsKeys[nvsIndex].c_str());
json = tagIdToJsonStr(nvsKeys[nvsIndex].c_str(), withDetails);
if ((len + json.length()) > maxLen) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Same issue, check is for 1 byte too few because of the comma

src/Web.cpp Outdated
nvsIndex++;
}
if (nvsIndex == nvsKeys.size()) {
// finish
len += sprintf(((char *) buffer + len), "]");
len += snprintf(((char *) buffer + len), maxLen, "]");
Copy link
Contributor

Choose a reason for hiding this comment

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

That byte is written without a length check at all.

@SZenglein
Copy link
Contributor

SZenglein commented Dec 13, 2023

Regarding the buffer copying, it's still calling for memory issues IMHO.
Most importantly, every code like this:

snprintf(((char *) buffer + len), maxLen, "...", ....);

Needs to be converted to

snprintf(((char *) buffer + len), maxLen-len, "...", ...);

that would already prevent any invalid memory access when writing the buffer. If the string doesn't fit, the json would still be invalid though.

@laszloh
Copy link
Contributor

laszloh commented Dec 14, 2023

Needs to be converted to snprintf(((char *) buffer + len), maxLen-len, "...", ...);

Yes you are correct. It's probably easier if we introduce a local variable for the remaining capacity const size_t remaining = maxLen - len - 1; (-1 for the last ']').

If the string doesn't fit, the json would still be invalid though.

From my point of view still better than getting a buffer overflow, overwriting unknown memory blocks after the buffer and having at best an unhandled exception at the next malloc/free call. We can also check for an error from snprintf by (len < 0 || len >= remaining) and react accordingly (I'd need to look it up, if there is a possibility to abort a chunked response in ESPAsyncWebServer & return f.e. HTTP-Code 500).

Copy link
Contributor

@laszloh laszloh left a comment

Choose a reason for hiding this comment

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

Looks good, I've added some changes to the code (mostly cosmetic). The code works as expected with entry count of over 300 dummy cards.

src/Web.cpp Outdated
return;
}
// get tag details or just an array of id's
bool idsOnly = request->hasParam("ids");
Copy link
Contributor

Choose a reason for hiding this comment

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

See comment in line #460. Here we just grab the parameter

Suggested change
bool idsOnly = request->hasParam("ids");
const bool idsOnly = request->hasParam("ids-only");

src/Web.cpp Outdated
@@ -457,6 +457,7 @@ void webserverStart(void) {

// RFID
wServer.on("/rfid", HTTP_GET, handleGetRFIDRequest);
wServer.addRewrite(new OneParamRewrite("/rfid/ids", "/rfid?ids=true"));
Copy link
Contributor

Choose a reason for hiding this comment

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

I propose, that the parameter ids should be called something like ids-only. We have a parapemeter id already, so a mixup could easily happen.

Suggested change
wServer.addRewrite(new OneParamRewrite("/rfid/ids", "/rfid?ids=true"));
wServer.addRewrite(new OneParamRewrite("/rfid/ids-only", "/rfid?ids-only=true"));

while (nvsIndex < nvsKeys.size()) {
// write tags as long we have enough room
json = tagIdToJsonStr(nvsKeys[nvsIndex].c_str(), idsOnly);
if ((len + json.length()) >= maxLen) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This should be only >, maxLen is the length of the buffer, not an index.

Suggested change
if ((len + json.length()) >= maxLen) {
if ((len + json.length()) > maxLen) {

src/Web.cpp Outdated
}
return len;
});
nvsKeys.clear();
Copy link
Contributor

Choose a reason for hiding this comment

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

I just saw, that we are accessing nvsKeys here. Since we called std::move on the variable, nvsKeys does not hold the array any more. As soons as the lambds is finished, the nvsKeys will go out of scope and it'll be destoryed.

Suggested change
nvsKeys.clear();

[nvsKeys = std::move(nvsKeys), idsOnly](uint8_t *buffer, size_t maxLen, size_t index) {
maxLen = maxLen >> 1; // some sort of bug with actual size available, reduce the len
size_t len = 0;
String json;
Copy link
Contributor

Choose a reason for hiding this comment

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

we can move nvsIndex into the lambda here. Also nvsIndex should have the same type as std::vector::size().

Suggested change
String json;
static size_t nvsIndex = 0;
String json;

src/Web.cpp Outdated
bool idsOnly = request->hasParam("ids");

std::vector<String> nvsKeys {};
static uint16_t nvsIndex;
Copy link
Contributor

Choose a reason for hiding this comment

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

See comment on line 1747.

Suggested change
static uint16_t nvsIndex;


std::vector<String> nvsKeys {};
static uint16_t nvsIndex;
nvsKeys.clear();
Copy link
Contributor

Choose a reason for hiding this comment

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

Not needed since nvsKeys was just created (since it's not a static variable any more), so it's already empty.

Suggested change
nvsKeys.clear();

}
// construct chunked repsonse
nvsIndex = 0;
Copy link
Contributor

Choose a reason for hiding this comment

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

see 1747

Suggested change
nvsIndex = 0;

Comment on lines +1771 to +1772
nvsIndex++;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

We are finished here, so we can clean up here. (see comment on line 1775 regarding nvsKeys)

Suggested change
nvsIndex++;
}
nvsIndex++;
} else if (nvsIndex > nvsKeys.size()) {
nvsIndex = 0;
return 0;
}

src/Web.cpp Outdated
// construct chunked repsonse
nvsIndex = 0;
AsyncWebServerResponse *response = request->beginChunkedResponse("application/json",
[nvsKeys = std::move(nvsKeys), idsOnly](uint8_t *buffer, size_t maxLen, size_t index) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Fix the return type of the lambda to size_t here (so that we can write return 0).

Suggested change
[nvsKeys = std::move(nvsKeys), idsOnly](uint8_t *buffer, size_t maxLen, size_t index) {
[nvsKeys = std::move(nvsKeys), idsOnly](uint8_t *buffer, size_t maxLen, size_t index) -> size_t {

@tueddy tueddy merged commit a1a35c7 into biologist79:dev Dec 29, 2023
10 checks passed
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.

None yet

3 participants