Files
JetCore/__mysql.cpp
T
Brad Arant b0cadc3f08 Multi-day accumulated fixes: tag dispatch, session/pooling correctness, SPA rollout, jetc compiler
JetCore engine (Tag.cpp/.h, Global.cpp/.h, __*.cpp tag classes):
- Numerous __mysql.cpp connection-pooling correctness fixes (stale
  result sets, double-indirection sessionid bugs, field caching,
  pool keying) and a session-registration-before-connection-confirmed
  ordering fix (dangling pointer on a failed connect)
- Tag.cpp: large body of fixes to the tag-dispatch/container-scanning
  logic (bare-name fallback, database aliasing, single-quote handling,
  @-prefix session variable resolution, TAGDEF caching, nested split-tag
  scanning for if/while/until/ifrow so an inner one doesn't corrupt an
  outer one's own boundary scan) plus the process-first rendering
  order fix (a <process> now runs before <checksession>/page
  resolution, fixing a real "blank page after successful login" bug)
- Global.h/.cpp: added imapSessions (parallel to the existing __mysql*
  session map) for the new <imap> tag family
- __whiledir.cpp/__include.cpp: caching and directory-name-aliasing
  fixes
- __call.cpp/.h: fixed a dangling-pointer bug
- __write.cpp: fixed an O_WRONLY-only open that couldn't create a new
  file
- __ifrow.cpp/__sql.cpp: fixed the same sessionid double-indirection
  bug __mysql.cpp had (resolveKeyword's result was being used as a map
  key lookup instead of the resolved value itself)

jetc compiler (jetc/JetParser.cpp/.h, JetCodegen.cpp/.h, JetAST.h,
jetc.cpp, build-shared.sh, build-compiled.sh):
- Parser fixes: inline <include>, nested-quote handling, void-tag
  recognition
- Codegen fixes and expanded AST support
- build-shared.sh: atomic rename into place for a freshly-built .jtc
  (a live server dlopen()ing the same path mid-rebuild was corrupting
  its own already-mapped view of the old file) and a staleness check
  so a cached JetCore .o doesn't silently keep serving yesterday's
  fixes forever
- Added -lssl to both build scripts' link lines (needed once the new
  <imap> tag's client-side TLS code existed - previously nothing in
  this codebase used OpenSSL's SSL/TLS client API, only libcrypto)

Site content (jetc/barant-site/): SPA (fetch-and-swap navigation)
rollout across essentially every .page/.jet/.section/.tag/.proc/.method
file - dynamic-content fixes, fragment-mode stripping, native
$[@name] session variables replacing older mechanisms, and this
session's own checkout.page redesign, login start-param redirect fix,
and per-page side-menu (menu.bar/$[PAGE].menu) refresh support.
Completed the earlier main.css/control.css move from assets/ to
assets/images/ (git still showed the old paths as tracked-but-deleted
until now).

Deliberately left out of this commit: dozens of .pre-<description>-
<date> manual backup files (this session's and earlier ones' own
before/after snapshots, not meant for permanent history), compiled
.jtc build outputs, ad hoc test binaries and scripts under jetc/
(login_asan, wcwc_test, optest2_bin, etc.), and jetc/__pycache__/ -
none of these are meant to be tracked; a .gitignore covering them
would be worth adding separately.
2026-09-03 05:20:48 +00:00

261 lines
13 KiB
C++

#include "__mysql.h"
#include "Exception.h"
#include <iostream>
#include <string>
#include <unordered_map>
namespace jet {
namespace {
struct PooledConnection {
MYSQL *handle;
std::string host, user, database;
};
// Persistent, per-worker-thread connection cache - reused across
// requests instead of a fresh mysql_real_connect() every time.
// Safe with no locking: the apiserver's EPoll worker pool is a
// fixed set of OS threads for the process's whole life (see
// MysqlThreadScope's own doc comment - mysql_thread_init() runs
// once per worker thread at thread start), so a thread_local
// instance here means a given MYSQL* handle is only ever touched
// by the one thread that created it, never concurrently accessed.
// Keyed by sessionid, which is how this codebase's own <database>/
// <sql>/<ifrow> tags already address a specific connection
// (Global::sessions is keyed the same way) - the cached host/user/
// database are kept alongside purely as a defensive check that a
// reused entry really matches what THIS invocation asked for,
// not a real expectation that sessionid is ever reused across
// genuinely different backing databases in practice.
thread_local std::unordered_map<std::string, PooledConnection> connectionPool;
}
__mysql::__mysql(coreutils::ZString &in, coreutils::MString &parentOut, Global &global, Tag *parent, Tag *local) : Tag(in, parentOut, global, parent, this) {
if(!keywordDefined("host"))
throw coreutils::Exception("host must be specified for mysql tag.");
// jet-1.4's <database> spells the same attribute "name" instead of
// "database" (e.g. SESSIONCTL.tag's own <sessionctl> definition) -
// accept either rather than requiring that real content to change.
coreutils::ZString databaseKeyword = keywordDefined("database") ? "database" : "name";
if(!keywordDefined(databaseKeyword))
throw coreutils::Exception("database must be specified for mysql tag.");
if(!keywordDefined("user"))
throw coreutils::Exception("user must be specified for mysql tag.");
if(!keywordDefined("password"))
throw coreutils::Exception("password must be specified for mysql tag.");
// Same double-indirection bug as the host/user/password/database
// fix above - resolveKeyword("sessionid") already returns the
// resolved VALUE (e.g. "_SESSIONCTL"), so wrapping it in keywords[...]
// used that value as a map KEY instead, auto-vivifying an empty
// string (no attribute is ever literally named "_SESSIONCTL").
// sessionId was therefore always empty regardless of what
// sessionid= actually specified - confirmed live via gdb: two
// real <mysql>/<database> tags with genuinely different sessionid
// values ("" and "_SESSIONCTL") both registered under "", the
// second colliding with "sessionid already exists.".
sessionId = resolveKeyword("sessionid");
// mysql_real_connect() returns NULL on failure but does NOT itself
// free the handle mysql_init() allocated - the caller is still
// expected to mysql_close() that original handle to avoid leaking
// it (MySQL C API docs). The previous code overwrote `mysql` with
// the connect() return value directly, so on failure the only
// pointer to the real handle was lost before ever being freed - a
// real leak on every failed connection attempt (this member's
// eventual value, NULL, is what a normal ~__mysql() would try to
// mysql_close() anyway, which is a safe no-op, but that never even
// runs here since the constructor throws below - see the
// addSession() comment for why that matters more).
// resolveKeyword() already returns the fully-resolved VALUE - the
// previous keywords[resolveKeyword("host")] wrapped that resolved
// VALUE ("localhost") back into a keywords[] lookup, i.e. used it
// as a MAP KEY instead of the value itself. Since no attribute is
// ever literally named "localhost"/the real username/etc.,
// std::map::operator[] silently auto-vivified an empty MString for
// every one of the four parameters - mysql_real_connect() was
// therefore always called with host/user/password/database all
// empty, regardless of what the tag actually specified. Confirmed
// live: MySQL's own error ("Access denied for user 'barant'@'localhost'
// (using password: NO)") showed the password was genuinely empty,
// not merely wrong.
coreutils::MString hostVal = resolveKeyword("host");
coreutils::MString userVal = resolveKeyword("user");
coreutils::MString passwordVal = resolveKeyword("password");
coreutils::MString databaseVal = resolveKeyword(databaseKeyword);
// Reuse a live connection this same worker thread already holds
// for this sessionid, rather than paying mysql_real_connect()'s
// full handshake cost on every single request. mysql_ping() is
// what makes this safe - it's a genuine round trip to the server,
// so a connection MySQL itself already dropped (its own
// wait_timeout, or an admin-initiated restart like this project
// already lived through once) is detected and discarded here
// rather than handed to processContainer() to fail on later.
std::string poolKey = sessionId.str();
auto pooled = connectionPool.find(poolKey);
if(pooled != connectionPool.end() &&
pooled->second.host == hostVal.str() &&
pooled->second.user == userVal.str() &&
pooled->second.database == databaseVal.str()) {
if(mysql_ping(pooled->second.handle) == 0) {
mysql = pooled->second.handle;
} else {
mysql_close(pooled->second.handle);
connectionPool.erase(pooled);
}
}
if(!mysql) {
MYSQL *handle = mysql_init(NULL);
mysql = mysql_real_connect(handle,
hostVal.c_str(),
userVal.c_str(),
passwordVal.c_str(),
databaseVal.c_str(), 0, NULL, 0);
if(!mysql) {
std::string err = mysql_error(handle);
mysql_close(handle);
throw coreutils::Exception("database connection failed: " + err);
}
connectionPool[poolKey] = PooledConnection{mysql, hostVal.str(), userVal.str(), databaseVal.str()};
}
// Registered only once the connection is confirmed live - a real,
// reproducible crash (heap corruption - "free(): invalid pointer"
// seen live, then two independent server-hang repros) traced back
// to this: addSession() used to run BEFORE the connect attempt, so
// a FAILED connection (which throws, right above) left a dangling
// `this` pointer registered under sessionId in Global's session
// map forever - ~__mysql() (which would normally call
// removeSession()) never runs for a constructor that itself threw,
// per ordinary C++ rules (only already-completed base-class
// subobjects get destroyed during exception unwinding, not the
// derived object whose constructor is still executing). The very
// next <mysql> tag construction using the same sessionId (e.g. two
// product pages with an empty sessionid="" back to back, both
// failing the same way) then collided with that stale entry.
// Confirmed via a real, deterministic two-request repro before this
// fix, and confirmed gone after it.
global.addSession(sessionId, this);
processContainer(container);
}
__mysql::~__mysql() {
global.removeSession(sessionId);
mysql_free_result(result);
// mysql itself is deliberately NOT closed here anymore - see the
// connectionPool doc comment above the constructor. It's owned by
// this worker thread's persistent pool now and gets reused by
// whatever request this thread handles next, instead of being
// torn down and fully reconnected from scratch every time.
}
void __mysql::query(coreutils::MString query) {
int rc = mysql_real_query(mysql, query.getData(), query.getLength());
result = mysql_store_result(mysql);
if(result) {
row = mysql_fetch_row(result);
fieldLength = mysql_fetch_lengths(result);
qFields = mysql_num_fields(result);
// Built once per query, not once per column LOOKUP - see the
// matching comment on getColumnValue() and on fieldIndex's own
// declaration in __mysql.h. mysql_fetch_fields() returns the
// whole field array in one call (mysql_fetch_field_direct()
// looping ix=0..qFields-1, as getColumnValue() used to do on
// every single named access, does the exact same work over and
// over for a result set whose schema never changes after this
// point).
fieldIndex.clear();
MYSQL_FIELD *fields = mysql_fetch_fields(result);
for(unsigned int ix = 0; ix < qFields; ++ix)
fieldIndex[std::string(fields[ix].name)] = (int)ix;
} else {
// A failed/errored query (real content builds one from an unset
// $[...] parameter, e.g. "where tasks.id=" with no value - genuine
// MySQL syntax error) previously left row/qFields holding whatever
// a PRIOR successful query on this same session object had set,
// since only the success branch above ever touched them. A later
// getColumnValue() call then looped 0..qFields (stale, nonzero)
// calling mysql_fetch_field_direct() against this NULL `result` -
// segfaulting (confirmed via gdb: SIGSEGV in
// mysql_fetch_field_direct, called from getColumnValue) instead of
// reaching the "column does not exist in session result." the
// caller already throws/catches for a genuinely missing column.
// Resetting both here makes a failed query present as "no row, no
// columns" - clean and consistent regardless of what ran before it
// on this session.
row = NULL;
qFields = 0;
fieldIndex.clear();
}
}
void __mysql::nextRow() {
row = mysql_fetch_row(result);
fieldLength = mysql_fetch_lengths(result);
}
bool __mysql::hasRow() {
return row != NULL;
}
coreutils::ZString __mysql::getColumnValue(coreutils::ZString column) {
MYSQL_FIELD *field;
if(column == "?#") {
nbrOfColumns = (int)qFields;
return nbrOfColumns;
} else if(column.ifNext("#") || column.ifNext("?")) {
// "#N"/"?N" (column name at 1-based position N) are the same
// jet-1.4 syntax under two different prefixes - real, unmodified
// 1.4 content uses both (barant.tag's own <showtable> definition
// writes $[?$[index]], never $[#$[index]]) - confirmed via a real
// repro: admin.page's own Users/Products tables rendered every
// column header and cell completely blank, since "?N" fell all
// the way through to the ordinary column-VALUE lookup below
// (nothing named "?3" exists in the row) and read as empty rather
// than as "the name of column 3", per the jet-1.4-fallback #2
// empty-string-on-missing-column behavior elsewhere in this
// codebase (see Tag.cpp).
if(column.eod()) {
nbrOfRows = (int)mysql_num_rows(result);
return nbrOfRows;
} else {
int index = column.asInteger();
field = mysql_fetch_field_direct(result, index - 1);
return coreutils::ZString(field->name);
}
}
// A query that ran successfully but matched zero rows (a real,
// valid query - e.g. cart.page's own aggregate/JOIN query against
// an empty cart session - not a failed one, see query()'s own
// row=NULL/qFields=0 reset above for that separate case) leaves
// qFields correctly reflecting the real schema but `row` NULL.
// The loop below finds a genuine matching column name and then
// dereferences row[ix] - segfaulting (confirmed via gdb: SIGSEGV
// right here) instead of reaching a catchable error. Reusing the
// same "column does not exist" exception the loop's own fallthrough
// already throws - no current row means no column value is
// obtainable either way, and callers already expect and catch
// this exact exception.
if(!row)
throw coreutils::Exception("column does not exist in session result.");
// O(1) against the map query() already built for this result set,
// instead of re-deriving field metadata (mysql_fetch_field_direct,
// a real MySQL C API call) and string-comparing column names one
// by one on every single named access - see fieldIndex's own doc
// comment in __mysql.h for the full story.
auto it = fieldIndex.find(column.str());
if(it != fieldIndex.end())
return coreutils::ZString(row[it->second], fieldLength[it->second]);
throw coreutils::Exception("column does not exist in session result.");
}
}