為什麼我想寫一個 Clang 插件?#
我每天的工作是為一個名為 Greenplum 的資料庫開發擴展。這是一個源自 PostgreSQL 的分散式資料庫。每次我使用它時,當遇到使用 PG_TRY()
/PG_CATCH()
區塊的 PostgreSQL 錯誤處理代碼時,我都感到警惕,因為我們見過許多因為誤用而導致的錯誤。我決定編寫一些自動化工具來捕捉這些錯誤。
PostgreSQL 錯誤處理代碼是什麼樣的?#
除了 PG_TRY()
和 PG_CATCH()
,在 PostgreSQL 錯誤處理過程中還涉及 2 個額外的宏:ereport()
和 PG_END_TRY()
。
PG_TRY()
、PG_CATCH()
和PG_END_TRY()
用於構建錯誤處理控制流。ereport()
用於報告錯誤。
PostgreSQL 中錯誤處理過程的代碼模式如下所示,
PG_TRY();
{
// FallibleMethod() 包含潛在的錯誤報告調用,例如,
// ereport();
FallibleMethod();
}
PG_CATCH();
{
// 執行錯誤處理。
}
PG_END_TRY();
這些宏的代碼模式看起來與其他語言中的 try-catch
表達式非常相似,但它們的用法更為複雜。這些宏的簡化定義如下,
// 我已經註釋掉了宏定義關鍵字,以便我們可以受益於
// 語法高亮 :-)
// 如果你想找到這些宏的完整定義,可以在下面的鏈接中找到
// https://github.com/postgres/postgres/blob/f7431bca8b0138bdbce7025871560d39119565a0/src/include/utils/elog.h#L384
// #define PG_TRY()
do {
sigjmp_buf *save_exception_stack = PG_exception_stack;
sigjmp_buf local_sigjmp_buf;
if (sigsetjmp(local_sigjmp_buf, 0) == 0)
{
PG_exception_stack = &local_sigjmp_buf
// #define PG_CATCH()
}
else
{
PG_exception_stack = save_exception_stack;
// #define PG_END_TRY()
}
PG_exception_stack = save_exception_stack;
} while (0)
// #define ereport()
if (PG_exception_stack != NULL)
siglongjmp(*PG_exception_stack, 1);
else
{
// 在現實中,我們不希望這個分支被執行。
...
}
PG_TRY()
和 PG_CATCH()
是如何工作的?#
全局變量 sigjmp_buf *PG_exception_stack
保存了前一次 sigsetjmp()
調用的環境(堆棧上下文)。在進入可失敗的代碼區域(由 PG_TRY()
和 PG_CATCH()
包裝)之前,我們使用 sigjmp_buf *save_exception_stack
保存先前的環境,並將當前環境分配給 PG_exception_stack
,這樣如果發生任何錯誤(調用 ereport()
),我們可以使用 siglongjmp(*PG_exception_stack)
跳轉到正確的位置(被正確的 PG_CATCH()
區塊捕獲)。
在 PG_CATCH()
區塊中,我們在採取任何錯誤處理行動之前恢復 PG_exception_stack
的先前環境,因此如果我們想將錯誤傳遞給上層調用者(調用 PG_RE_THROW()
,這是 siglongjmp()
的另一種包裝),siglongjmp(*PG_exception_stack)
可以再次跳轉到正確的位置。
問題#
常見的錯誤之一是在 PG_TRY()
區塊內使用跳轉語句(例如 return
、break
、continue
和 goto
),即使對於經驗豐富的 PostgreSQL 貢獻者也是如此1。例如,如果我們在 PG_TRY()
區塊內使用 return
語句,則在離開 PG_TRY()
區塊之前,PG_exception_stack
不會恢復到正確的堆棧上下文,這可能會對 PostgreSQL/Greenplum 伺服器造成嚴重問題,例如伺服器崩潰2。
我們試圖檢測的代碼模式是在 PG_TRY()
區塊內使用不安全的 return
、break
、continue
和 goto
語句。不安全的代碼模式可以總結為以下規則,
-
在
PG_TRY()
區塊的任何地方使用return
語句。PG_TRY(); { ... // 不安全,因為 PG_exception_stack 在離開 PG_TRY-PG_CATCH // 區塊後仍然存儲當前環境。 return; } PG_CATCH(); { ... } PG_END_TRY();
-
在
PG_TRY()
區塊的任何地方使用break
語句,除了在switch
、do-while
、while
和for
語句內使用的情況。PG_TRY(); { ... // 不安全,因為 break 語句跳出 PG_TRY() 區塊,這會破壞錯誤處理 // 堆棧上下文或環境。 break; do { // 安全,因為我們跳出的是 do-while 循環而不是 // PG_TRY() 區塊。 break; } while (0); while (1) { // 安全。同上。 break; } for (;;) { // 安全。同上。 } switch (c) { case 1: // 安全。同上。 break; } } PG_CATCH(); { ... } PG_END_TRY();
-
在
PG_TRY()
區塊的任何地方使用continue
語句,除了在do-while
、while
和for
循環內使用的情況。PG_TRY(); { // 不安全,因為 continue 語句將終止從 PG_TRY() 宏擴展的 do-while 循環 // 並跳出 PG_TRY() 區塊,導致錯誤處理堆棧上下文破壞。 continue; do { // 安全。 continue; } while (0); while (0) { // 安全。 continue; } for (;;) { // 安全。 continue; } } PG_CATCH(); { ... } PG_END_TRY();
-
在
PG_TRY()
區塊外有標籤的goto
語句。label1: PG_TRY(); { label2: // 不安全。因為我們跳出 PG_TRY() 區塊,這會破壞堆棧上下文。 goto label1; // 安全。 goto label2; } PG_CATCH(); { ... } PG_END_TRY();
AST 匹配器還是靜態分析器?#
PG_TRY()
是 C 中的一個宏,並且它總是擴展為相同的東西。此外,我們想檢測的語句非常簡單,不涉及跟踪符號狀態的變化。Clang 的 AST 匹配器對於我們的問題已經足夠好。
首先,我們註冊一個回調函數 checkEndOfTranslationUnit()
來找出包含 return
/break
/continue
/goto
語句的 PG_TRY()
區塊。該回調函數將在編譯期間對每個翻譯單元進行調用。當一個 PG_TRY()
區塊被匹配時,我們將仔細檢查它是否真的不安全,以減少誤報。帶有註釋的代碼片段如下所示。
class ReturnInPgTryBlockChecker : public Checker<check::EndOfTranslationUnit> {
public:
void checkEndOfTranslationUnit(const TranslationUnitDecl *TU,
AnalysisManager &AM, BugReporter &B) const {
MatchFinder F;
PgTryBlockMatcherCallback CB;
StatementMatcher PgTry =
// PG_TRY() 將擴展為以下表達式。
// if (__sigsetjmp() == 0) {
// PG_exception_stack = &local_sigjmp_buf;
// ...
// }
ifStmt(
// 'if' 語句必須包含一個二元運算符,且該二元運算符
// 必須是 '=='。
hasCondition(
binaryOperator(allOf(hasOperatorName("=="),
// '==' 的一個操作數必須是函數調用,且該
// 函數必須名為 '__sigsetjmp'。
// 另一個操作數必須是整數字面量 '0'。
hasOperands(callExpr(callee(functionDecl(
hasName("__sigsetjmp")))),
integerLiteral(equals(0)))))),
// 'if' 語句必須有一個 'then' 區塊,且 'then' 區塊必須
// 包含 'return'、'break'、'continue' 和 'goto' 語句之一。
hasThen(eachOf(
// 為了方便,我們將 PG_TRY() 區塊與 return 語句綁定
// 名稱 'ReturnInPgTryBlock',以便我們可以立即發出警告消息
// 之後。
forEachDescendant(returnStmt().bind("ReturnInPgTryBlock")),
anyOf(hasDescendant(breakStmt()), hasDescendant(continueStmt()),
hasDescendant(gotoStmt())))))
// 我們將感興趣的 PG_TRY() 區塊的 AST 綁定到名稱 'PgTryBlock' 以便稍後仔細檢查。
.bind("PgTryBlock");
// &CB 是稍後將被調用的回調,用於仔細檢查匹配的
// PG_TRY() 區塊的 AST。
F.addMatcher(PgTry, &CB);
// 匹配 AST!
F.matchAST(TU->getASTContext());
}
};
然後,我們仔細檢查匹配的 PG_TRY()
區塊的 AST。當綁定到名稱 "ReturnInPgTryBlock"
或 "PgTryBlock"
的 AST 被匹配時,以下回調將被調用。
class PgTryBlockMatcherCallback : public MatchFinder::MatchCallback {
public:
PgTryBlockMatcherCallback() = default;
void run(const MatchFinder::MatchResult &Result) override {
ASTContext *Ctx = Result.Context;
if (const ReturnStmt *Return =
Result.Nodes.getNodeAs<ReturnStmt>("ReturnInPgTryBlock")) {
// 我們在 PG_TRY 區塊內找到了 return 語句。讓我們發出警告
// 關於它。
DiagnosticsEngine &DE = Ctx->getDiagnostics();
unsigned DiagID = DE.getCustomDiagID(
DiagnosticsEngine::Error,
"在 PG_TRY 區塊內使用不安全的 return 語句");
auto DB = DE.Report(Return->getReturnLoc(), DiagID);
DB.AddSourceRange(
CharSourceRange::getCharRange(Return->getSourceRange()));
} else if (const IfStmt *If =
Result.Nodes.getNodeAs<IfStmt>("PgTryBlock")) {
// 檢查 PG_TRY() 區塊內的 'break'/'continue'/'goto' 語句是否不安全。
const Stmt *Then = If->getThen();
CheckUnsafeBreakStmt(Then, Ctx);
CheckUnsafeContinueStmt(Then, Ctx);
CheckUnsafeGotoStmt(Then, Ctx);
}
}
};
檢查在 PG_TRY()
區塊內使用 break
/continue
/goto
語句的安全性的代碼非常相似。這裡,我們以 CheckUnsafeBreakStmt()
為例。其基本思想是在匹配的 AST 上執行 BFS。
static void CheckUnsafeBreakStmt(const Stmt *Then, ASTContext *Ctx) {
std::queue<const Stmt *> StmtQueue;
StmtQueue.push(Then);
while (!StmtQueue.empty()) {
const Stmt *CurrStmt = StmtQueue.front();
StmtQueue.pop();
if (!CurrStmt)
continue;
if (const BreakStmt *Break =
llvm::dyn_cast_if_present<BreakStmt>(CurrStmt)) {
// 我們在 PG_TRY 區塊內找到了 break 語句。讓我們發出警告
// 關於它。
DiagnosticsEngine &DE = Ctx->getDiagnostics();
unsigned DiagID = DE.getCustomDiagID(
DiagnosticsEngine::Error,
"在 PG_TRY 區塊內使用的 break 語句是不安全的");
auto DB = DE.Report(Break->getBreakLoc(), DiagID);
DB.AddSourceRange(CharSourceRange::getCharRange(Break->getSourceRange()));
}
// 在 while/do-while/for/switch 語句中的 break 語句是安全的。我們不
// 需要對子節點執行 BFS。
if (llvm::isa<WhileStmt>(CurrStmt) || llvm::isa<DoStmt>(CurrStmt) ||
llvm::isa<ForStmt>(CurrStmt) || llvm::isa<SwitchStmt>(CurrStmt)) {
continue;
}
for (const Stmt *C : CurrStmt->children()) {
StmtQueue.push(C);
}
}
}
現在,我們的檢查器可以報告 PostgreSQL 基於項目中的不安全代碼模式。檢查器的源代碼可以在我的 GitHub 倉庫中找到3。
它在現實世界中找到任何潛在的錯誤嗎?#
是的,它找到了!我在 PostgreSQL1 中發現了一些不安全的代碼,當然也在 Greenplum 中(我沒有向 Greenplum 提交問題,因為我想先在 PostgreSQL 中修復它,然後再將補丁選擇性地回滾到 Greenplum)。我從 pgsql-hackers 郵件列表中得到的一些有趣的回覆如下,
- Tom Lane 提到在
PG_TRY()
區塊內使用break
/continue
/goto
也可能會搞砸事情。 - Andres Freund 提供了一個非常酷的編譯器黑客技術,使用 clang 的線程安全分析。該補丁似乎比我的 AST 匹配器想法更好。如果他的補丁被提交,我們可以在編譯 PostgreSQL 時拒絕這種不安全的代碼模式。
From d1c99e9d12ba01adb21c5f17c792be44cfeef20f Mon Sep 17 00:00:00 2001
From: Andres Freund <[email protected]>
Date: Thu, 12 Jan 2023 21:18:55 -0800
Subject: [PATCH v1] wip: use clang anotations to warn if code in
PG_TRY/CATCH/FINALLY returns
Only hooked up to meson right now.
---
meson.build | 1 +
src/include/utils/elog.h | 43 +++++++++++++++++++++++++++++++++++++---
2 files changed, 41 insertions(+), 3 deletions(-)
diff --git a/meson.build b/meson.build
index 45fb9dd616e..66a40e728f4 100644
--- a/meson.build
+++ b/meson.build
@@ -1741,6 +1741,7 @@ common_warning_flags = [
'-Wimplicit-fallthrough=3',
'-Wcast-function-type',
'-Wshadow=compatible-local',
+ '-Wthread-safety',
# This was included in -Wall/-Wformat in older GCC versions
'-Wformat-security',
]
diff --git a/src/include/utils/elog.h b/src/include/utils/elog.h
index 4a9562fdaae..b211e08322a 100644
--- a/src/include/utils/elog.h
+++ b/src/include/utils/elog.h
@@ -381,32 +381,69 @@ extern PGDLLIMPORT ErrorContextCallback *error_context_stack;
* same within each component macro of the given PG_TRY() statement.
*----------
*/
+
+
+/*
+ * Annotations for detecting returns inside a PG_TRY(), using clang's thread
+ * safety annotations.
+ *
+ * The "lock" implementations need no_thread_safety_analysis as clang can't
+ * understand how a lock is implemented. We wouldn't want an implementation
+ * anyway, since there's no real lock here.
+ */
+#ifdef __clang__
+
+typedef int __attribute__((capability("no_returns_in_pg_try"))) no_returns_handle_t;
+
+static inline void no_returns_start(no_returns_handle_t l)
+ __attribute__((acquire_capability(l)))
+ __attribute__((no_thread_safety_analysis))
+{
+}
+
+static inline void no_returns_stop(no_returns_handle_t l)
+ __attribute__((release_capability(l)))
+ __attribute__((no_thread_safety_analysis))
+{}
+#else
+typedef int pg_attribute_unused() no_returns_handle_t;
+#define no_returns_start(t) (void)0
+#define no_returns_stop(t) (void)0
+#endif
+
#define PG_TRY(...) \
do { \
sigjmp_buf *_save_exception_stack##__VA_ARGS__ = PG_exception_stack; \
ErrorContextCallback *_save_context_stack##__VA_ARGS__ = error_context_stack; \
sigjmp_buf _local_sigjmp_buf##__VA_ARGS__; \
bool _do_rethrow##__VA_ARGS__ = false; \
+ no_returns_handle_t no_returns_handle##__VA_ARGS__ = 0; \
if (sigsetjmp(_local_sigjmp_buf##__VA_ARGS__, 0) == 0) \
{ \
- PG_exception_stack = &_local_sigjmp_buf##__VA_ARGS__
+ PG_exception_stack = &_local_sigjmp_buf##__VA_ARGS__; \
+ no_returns_start(no_returns_handle##__VA_ARGS__)
#define PG_CATCH(...) \
+ no_returns_stop(no_returns_handle##__VA_ARGS__); \
} \
else \
{ \
PG_exception_stack = _save_exception_stack##__VA_ARGS__; \
- error_context_stack = _save_context_stack##__VA_ARGS__
+ error_context_stack = _save_context_stack##__VA_ARGS__; \
+ no_returns_start(no_returns_handle##__VA_ARGS__)
#define PG_FINALLY(...) \
+ no_returns_stop(no_returns_handle##__VA_ARGS__); \
} \
else \
_do_rethrow##__VA_ARGS__ = true; \
{ \
PG_exception_stack = _save_exception_stack##__VA_ARGS__; \
- error_context_stack = _save_context_stack##__VA_ARGS__
+ error_context_stack = _save_context_stack##__VA_ARGS__; \
+ no_returns_start(no_returns_handle##__VA_ARGS__)
#define PG_END_TRY(...) \
+ no_returns_stop(no_returns_handle##__VA_ARGS__); \
} \
if (_do_rethrow##__VA_ARGS__) \
PG_RE_THROW(); \
--
2.38.0
下一步是什麼?#
這是我第一次嘗試編寫基於 Clang 的檢查器。除了在 PG_TRY()
區塊內使用不安全的 return
/break
/continue
/goto
語句外,仍然存在一些不安全的代碼模式,例如,在 PG_TRY()
區塊內修改自動存儲類的局部變量並在 PG_CATCH()
區塊中使用它。未來能夠有更多檢查器來檢查這些不安全的代碼模式將是非常好的。