Bug 9

Unbounded IPC debug logging made the test harness run out of memory

Status
Fixed
Symptom
Out of memory; worst effect seen: test failure only
Where seen
Bitcoin Core functional tests
Trigger
Writing log output
First reported
2019-07-09 (first report)
Fixed
2019-07-15
Fix
mp#3
Shipped to Bitcoin Core
bitcoin#18677
In released binaries
No
Defect ever on master
Yes

Summary

IPC debug logging wrote the full escaped text of every request and response to debug.log with no size limit, so on the multiprocess branch the log grew large enough that the functional test harness died with a Python MemoryError reading it. The node itself did not fail. This was in libmultiprocess's first weeks, before Bitcoin Core used the library, so no release was affected. Fixed in mp#3 by truncating logged messages to about 1000 characters.

What happened

Root cause

LogEscape() in src/mp/util.cpp escaped and returned the full kj::StringTree text of every IPC request and response for the IPC client send/recv and IPC server recv/send debug lines, with no size cap. feature_block.py pushes many full-size blocks through the node, and with IPC logging on, each block's bytes were rendered into debug.log as escaped text on both sides of the connection.

The unbounded rendering was in the library's initial commit. Running Bitcoin Core's full functional test suite against the library, in bitcoin#10102's CI from June 2019, was the first time large binary payloads were logged at that scale.

The code

Quoted from 724d2f7fe35c^. clientInvoke (include/mp/proxy-types.h) logs every request and response as text, and the server side logs its params and results the same way:

proxy_client.m_connection->m_loop.logPlain()
    << "{" << invoke_context.thread_context.thread_name << "} IPC client send "
    << TypeName<typename Request::Params>() << " " << LogEscape(request.toString());

LogEscape (src/mp/util.cpp) escapes the whole message, however large:

std::string LogEscape(const kj::StringTree& string)
{
    std::string result;
    string.visit([&](const kj::ArrayPtr<const char>& piece) {
        for (char c : piece) {
            // ...
            } else if (c < 0x20 || c > 0x7e) {
                char escape[4];
                snprintf(escape, 4, "\\%02x", c);
                result.append(escape);
            } else {
                result.push_back(c);
            }
        }
    });
    return result;
}

The text form of a call that passes a block contains the whole serialized block, so every such call wrote at least the block's size to debug.log, on both sides of the connection. After feature_block.py sent many large blocks, the test framework's assert_debug_log read the log into memory and failed with MemoryError.

The fix stops escaping once the result passes 1000 characters and marks the cut with ... (724d2f7fe35c):

@@ -31,8 +31,10 @@ std::string ThreadName(const char* exe_name)
 std::string LogEscape(const kj::StringTree& string)
 {
+    const int MAX_SIZE = 1000;
     std::string result;
     string.visit([&](const kj::ArrayPtr<const char>& piece) {
+        if (result.size() > MAX_SIZE) return;
         for (char c : piece) {
@@ -43,6 +45,10 @@ std::string LogEscape(const kj::StringTree& string)
                 result.push_back(c);
             }
+            if (result.size() > MAX_SIZE) {
+                result += "...";
+                break;
+            }
         }

Each log line now holds at most about 1000 characters of message text, whatever the size of the message.

Fix

724d2f7fe35c adds a MAX_SIZE = 1000 limit to LogEscape(): it stops visiting further string pieces once the result exceeds the limit and appends ... (+6/-0).

Timeline

Lessons

What would have prevented this bug, found it sooner, or made it easier to debug. The patterns page groups these lessons across all bugs.

Pattern An in-process interface trusting what now comes from another process: Unbounded log output from peer data

  • Prevented by a design rule
  • Earliest detector other (existed then)
  • Diagnosis easy
  • Fix design easy
  • Debugging aids CI artifacts
  • Rust no

Would Rust have prevented it? No. Writing the whole message to a log is a resource-use choice with no type, lifetime or Send/Sync violation for a compiler to reject. More on Rust

Could it have been prevented?

Yes, by the rule the fix adds: a debug line never contains more than a fixed amount of payload. LogEscape() in the initial commit (06e1045baba0) rendered the full text of every request and response at four call sites, which is natural for a two-week-old library whose test messages were a few bytes; a block is a megabyte, and it is logged on both sides of the connection. A logging helper that takes a size limit by contract, or a habit of asking "what is the largest value this line can carry?" for any log that prints user data, is enough. 724d2f7fe35c is six lines.

Could it have been found sooner?

It was found about as soon as it could be. The check that caught it, Bitcoin Core's functional suite run against the multiprocess branch in bitcoin#10102's CI, was the first time large payloads went through the logging at scale, and feature_block.py hit it in the first weeks (2019-07). A unit test that logs a large message and checks the line length would be cheaper, but nobody writes that test without first having the thought that prevents the bug.

What made it hard to debug?

Nothing. The Travis traceback ended in a Python MemoryError inside assert_debug_log reading debug.log, which names the file that grew; the fix commit quotes it and mp#3 links the job. The one indirection is that the failing process was the test harness, not the node, which is why the record carries low confidence as a stability bug; the lasting effect in production would have been unbounded log growth whenever IPC logging was on.

Lesson

Any log line that prints data supplied by the workload needs a size cap, decided when the line is written, because the first large input will arrive from somewhere the author did not test.

Notes

Classification

The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.

Field Value
Symptom oom
Worst effect observed test-failure-only
Seen in functional-test
Scope production
Trigger Logging (logging)
Phase steady-state
Side both
Component logging
Root cause kind resource-leak
Fix kind local-fix
Detection ci-deterministic
Needs a peer that is not libmultiprocess C++ no
Needed a sanitizer to see no
Defect ever on master yes
Released no
Confidence low