Skip to content

Commit 74ab2f8

Browse files
committed
Extract Turso source patches to .github/turso-patches/
The 'Patch Turso to not abort on recoverable conditions' step had grown to 14 inlined Python heredocs (~670 lines), making review and maintenance tedious. Move each fix to its own self-contained Python script under .github/turso-patches/, ordered by 2-digit prefix: 01-stub-macro 02-column-functions-null-row 03-text-blob-free-types 04-create-function-v2-skip-old-destroy 05-finalize-try-lock-on-gc-reentry 06-max-custom-funcs-32-to-64 07-function-name-case-insensitive 08-collation-mysql-aliases 09-allow-delete-from-sqlite-sequence 10-collate-direct-column-refs-only 11-create-trigger-preserve-original-sql 12-drop-table-sqlite-sequence-db-mismatch 13-hash-join-correlated-subquery 14-drop-table-indices-db-mismatch apply.sh runs them in lex order from the Turso source root. Each script keeps the same OLD/NEW string-replace contract and asserts the original block exists, so upstream churn fails loudly instead of silently mis-applying. Verified locally: a fresh Turso checkout + apply.sh applies all 14 patches; cargo check on the patched source succeeds. Workflow shrinks from 2356 to 1685 lines. Patches under (UPSTREAM) in the docstring (12, 13, 14) are real Turso bugs worth filing.
1 parent 11f0d4e commit 74ab2f8

17 files changed

Lines changed: 878 additions & 669 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Neutralize Turso's stub! macro (sqlite3/src/lib.rs).
4+
5+
Many SQLite C API functions are stubbed out via `stub!()`, which expands
6+
to `todo!("X is not implemented")`. pdo_sqlite hits one during PDO
7+
construction (sqlite3_set_authorizer) and the panic aborts the PHP
8+
process. Rewrite the body to return a zeroed value of the function's
9+
return type (0 / SQLITE_OK for ints, NULL for pointers) instead.
10+
"""
11+
12+
import sys
13+
14+
PATH = 'sqlite3/src/lib.rs'
15+
OLD = 'todo!("{} is not implemented", stringify!($fn));'
16+
NEW = 'return unsafe { std::mem::zeroed() };'
17+
18+
with open(PATH) as f:
19+
src = f.read()
20+
if OLD not in src:
21+
sys.exit(f'{PATH}: stub! todo!() body not found')
22+
n = src.count(OLD)
23+
src = src.replace(OLD, NEW)
24+
with open(PATH, 'w') as f:
25+
f.write(src)
26+
print(f'patched stub! macro ({n} occurrence{"s" if n != 1 else ""})')
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
#!/usr/bin/env python3
2+
"""
3+
sqlite3_column_* — early-return when no row is present (sqlite3/src/lib.rs).
4+
5+
The sqlite3_column_* functions `.expect()` that a row is present, but
6+
pdo_sqlite legitimately calls them on statements that have not yet
7+
stepped to SQLITE_ROW (e.g. for column metadata). Replace the expect
8+
with an early return of the type's "null" value, matching SQLite's
9+
actual behaviour.
10+
"""
11+
12+
import re
13+
import sys
14+
15+
PATH = 'sqlite3/src/lib.rs'
16+
17+
DEFAULTS = {
18+
'sqlite3_column_type': 'SQLITE_NULL',
19+
'sqlite3_column_int': '0',
20+
'sqlite3_column_int64': '0',
21+
'sqlite3_column_double': '0.0',
22+
'sqlite3_column_blob': 'std::ptr::null()',
23+
'sqlite3_column_bytes': '0',
24+
'sqlite3_column_text': 'std::ptr::null()',
25+
}
26+
27+
PATTERN = re.compile(
28+
r'(pub unsafe extern "C" fn (sqlite3_column_\w+)\([^)]*\)[^{]*\{)'
29+
r'((?:[^{}]|\{[^{}]*\})*?)'
30+
r'(let row = stmt\s*\.stmt\s*\.row\(\)\s*'
31+
r'\.expect\("Function should only be called after `SQLITE_ROW`"\);)',
32+
re.DOTALL,
33+
)
34+
35+
36+
def repl(m):
37+
header, name, body, _ = m.group(1), m.group(2), m.group(3), m.group(4)
38+
default = DEFAULTS.get(name, '0')
39+
guarded = (
40+
f'let row = match stmt.stmt.row() {{ '
41+
f'Some(r) => r, None => return {default} }};'
42+
)
43+
return header + body + guarded
44+
45+
46+
with open(PATH) as f:
47+
src = f.read()
48+
src, n = PATTERN.subn(repl, src)
49+
if n == 0:
50+
sys.exit(f'{PATH}: no sqlite3_column_* expect-row blocks matched')
51+
with open(PATH, 'w') as f:
52+
f.write(src)
53+
print(f'patched {n} sqlite3_column_* functions')
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#!/usr/bin/env python3
2+
"""
3+
TextValue::free / Blob::free — rebuild the original slice box (extensions/core/src/types.rs).
4+
5+
TextValue/Blob `free` reconstructs `Box<u8>` from a pointer that was
6+
originally `Box<str>` / `Box<[u8]>` (fat pointers, length lost in the
7+
cast). This corrupts the heap when custom UDFs return text/blob values.
8+
Fix both frees to use the stored length and rebuild the correct slice
9+
box.
10+
"""
11+
12+
import re
13+
import sys
14+
15+
PATH = 'extensions/core/src/types.rs'
16+
17+
OLD_TEXT = (
18+
' #[cfg(feature = "core_only")]\n'
19+
' fn free(self) {\n'
20+
' if !self.text.is_null() {\n'
21+
' let _ = unsafe { Box::from_raw(self.text as *mut u8) };\n'
22+
' }\n'
23+
' }\n'
24+
)
25+
NEW_TEXT = (
26+
' #[cfg(feature = "core_only")]\n'
27+
' fn free(self) {\n'
28+
' if !self.text.is_null() && self.len > 0 {\n'
29+
' unsafe {\n'
30+
' let slice = std::slice::from_raw_parts_mut(\n'
31+
' self.text as *mut u8, self.len as usize);\n'
32+
' let _ = Box::from_raw(slice as *mut [u8]);\n'
33+
' }\n'
34+
' }\n'
35+
' }\n'
36+
)
37+
38+
with open(PATH) as f:
39+
s = f.read()
40+
if OLD_TEXT not in s:
41+
sys.exit(f'{PATH}: TextValue::free not found')
42+
s = s.replace(OLD_TEXT, NEW_TEXT, 1)
43+
44+
# Blob::free uses the same pattern.
45+
blob_pat = re.compile(
46+
r'(impl Blob \{\n(?:[^}]|\{[^}]*\})*?)'
47+
r'( #\[cfg\(feature = "core_only"\)\]\n'
48+
r' fn free\(self\) \{\n'
49+
r' if !self\.data\.is_null\(\) \{\n'
50+
r' let _ = unsafe \{ Box::from_raw\(self\.data as \*mut u8\) \};\n'
51+
r' \}\n'
52+
r' \}\n)'
53+
)
54+
m = blob_pat.search(s)
55+
if m:
56+
new_blob = (
57+
' #[cfg(feature = "core_only")]\n'
58+
' fn free(self) {\n'
59+
' if !self.data.is_null() && self.size > 0 {\n'
60+
' unsafe {\n'
61+
' let slice = std::slice::from_raw_parts_mut(\n'
62+
' self.data as *mut u8, self.size as usize);\n'
63+
' let _ = Box::from_raw(slice as *mut [u8]);\n'
64+
' }\n'
65+
' }\n'
66+
' }\n'
67+
)
68+
s = s[:m.start(2)] + new_blob + s[m.end(2):]
69+
print('patched Blob::free')
70+
71+
with open(PATH, 'w') as f:
72+
f.write(s)
73+
print('patched TextValue::free')
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#!/usr/bin/env python3
2+
"""
3+
create_function_v2 — don't fire the old destroy callback on slot reuse (sqlite3/src/lib.rs).
4+
5+
Turso's create_function_v2 invokes the previous FuncSlot's destroy
6+
callback when re-registering a UDF with the same name. In practice
7+
(PHPUnit) this means:
8+
- setUp #1 opens PDO A, registers 44 UDFs, each with a destroy
9+
callback + p_app pointing to A.
10+
- tearDown closes PDO A — Turso's sqlite3_close doesn't clear those
11+
FuncSlots.
12+
- setUp #2 opens PDO B, re-registers the same 44 names. Turso invokes
13+
the OLD destroy callback with the now-dangling A p_app, which trips
14+
pdo_sqlite and hangs the process.
15+
16+
Comment the destroy invocation out; the callbacks still fire at real
17+
PDO-destruction time from the PHP side.
18+
"""
19+
20+
import sys
21+
22+
PATH = 'sqlite3/src/lib.rs'
23+
OLD = (
24+
' // Reuse existing slot — invoke old destroy callback on old user data.\n'
25+
' if let Some(old) = slots[id].take() {\n'
26+
' if old.destroy != 0 {\n'
27+
' let old_destroy: unsafe extern "C" fn(*mut ffi::c_void) =\n'
28+
' std::mem::transmute(old.destroy);\n'
29+
' old_destroy(old.p_app as *mut ffi::c_void);\n'
30+
' }\n'
31+
' }\n'
32+
)
33+
NEW = (
34+
" // Don't invoke the old destroy callback here — in PDO\n"
35+
" // usage the previous slot's p_app often belongs to a db\n"
36+
' // that has already been closed, so the callback UAFs.\n'
37+
' let _ = slots[id].take();\n'
38+
)
39+
40+
with open(PATH) as f:
41+
s = f.read()
42+
if OLD not in s:
43+
sys.exit(f'{PATH}: slot destroy block not found')
44+
with open(PATH, 'w') as f:
45+
f.write(s.replace(OLD, NEW, 1))
46+
print('patched slot-reuse destroy invocation')
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
#!/usr/bin/env python3
2+
"""
3+
sqlite3_finalize / stmt_run_to_completion — try_lock to dodge GC re-entry deadlock.
4+
5+
sqlite3_finalize uses a non-reentrant std::sync::Mutex on the db. PHP's
6+
cycle GC can fire a PDO statement destructor while another statement's
7+
sqlite3_step is in progress (i.e., from inside a UDF callback whose
8+
bridge re-enters PHP). The outer step holds the mutex, the inner
9+
finalize blocks on it, and we deadlock.
10+
11+
Fix: use try_lock; on contention, skip the stmt_list unlink and the
12+
drain. The list is only traversed by sqlite3_next_stmt (which
13+
pdo_sqlite doesn't call) and dropped on sqlite3_close, so a stale entry
14+
is harmless; the stmt's own Box is still freed below.
15+
"""
16+
17+
import sys
18+
19+
PATH = 'sqlite3/src/lib.rs'
20+
21+
OLD_FINALIZE = (
22+
' if !stmt_ref.db.is_null() {\n'
23+
' let db = &mut *stmt_ref.db;\n'
24+
' let mut db_inner = db.inner.lock().unwrap();\n'
25+
'\n'
26+
' if db_inner.stmt_list == stmt {\n'
27+
' db_inner.stmt_list = stmt_ref.next;\n'
28+
' } else {\n'
29+
' let mut current = db_inner.stmt_list;\n'
30+
' while !current.is_null() {\n'
31+
' let current_ref = &mut *current;\n'
32+
' if current_ref.next == stmt {\n'
33+
' current_ref.next = stmt_ref.next;\n'
34+
' break;\n'
35+
' }\n'
36+
' current = current_ref.next;\n'
37+
' }\n'
38+
' }\n'
39+
' }\n'
40+
)
41+
NEW_FINALIZE = (
42+
' if !stmt_ref.db.is_null() {\n'
43+
' let db = &mut *stmt_ref.db;\n'
44+
' // try_lock to avoid deadlock when finalize is invoked\n'
45+
' // re-entrantly (GC destructor during UDF callback).\n'
46+
' if let Ok(mut db_inner) = db.inner.try_lock() {\n'
47+
' if db_inner.stmt_list == stmt {\n'
48+
' db_inner.stmt_list = stmt_ref.next;\n'
49+
' } else {\n'
50+
' let mut current = db_inner.stmt_list;\n'
51+
' while !current.is_null() {\n'
52+
' let current_ref = &mut *current;\n'
53+
' if current_ref.next == stmt {\n'
54+
' current_ref.next = stmt_ref.next;\n'
55+
' break;\n'
56+
' }\n'
57+
' current = current_ref.next;\n'
58+
' }\n'
59+
' }\n'
60+
' }\n'
61+
' }\n'
62+
)
63+
64+
OLD_DRAIN = (
65+
'unsafe fn stmt_run_to_completion(stmt: *mut sqlite3_stmt) -> ffi::c_int {\n'
66+
' let stmt_ref = &mut *stmt;\n'
67+
' while stmt_ref.stmt.execution_state().is_running() {\n'
68+
' let result = sqlite3_step(stmt);\n'
69+
' if result != SQLITE_DONE && result != SQLITE_ROW {\n'
70+
' return result;\n'
71+
' }\n'
72+
' }\n'
73+
' SQLITE_OK\n'
74+
'}\n'
75+
)
76+
NEW_DRAIN = (
77+
'unsafe fn stmt_run_to_completion(stmt: *mut sqlite3_stmt) -> ffi::c_int {\n'
78+
' let stmt_ref = &mut *stmt;\n'
79+
" // Skip drain if we can't acquire the db mutex: we're\n"
80+
" // re-entering from a UDF callback's GC destructor, and\n"
81+
' // sqlite3_step would block forever. The stmt will be\n'
82+
' // freed anyway by the caller.\n'
83+
' if !stmt_ref.db.is_null() {\n'
84+
' let db = &*stmt_ref.db;\n'
85+
' if db.inner.try_lock().is_err() {\n'
86+
' return SQLITE_OK;\n'
87+
' }\n'
88+
' }\n'
89+
' while stmt_ref.stmt.execution_state().is_running() {\n'
90+
' let result = sqlite3_step(stmt);\n'
91+
' if result != SQLITE_DONE && result != SQLITE_ROW {\n'
92+
' return result;\n'
93+
' }\n'
94+
' }\n'
95+
' SQLITE_OK\n'
96+
'}\n'
97+
)
98+
99+
with open(PATH) as f:
100+
s = f.read()
101+
if OLD_FINALIZE not in s:
102+
sys.exit(f'{PATH}: sqlite3_finalize stmt_list block not found')
103+
s = s.replace(OLD_FINALIZE, NEW_FINALIZE, 1)
104+
if OLD_DRAIN not in s:
105+
sys.exit(f'{PATH}: stmt_run_to_completion block not found')
106+
s = s.replace(OLD_DRAIN, NEW_DRAIN, 1)
107+
with open(PATH, 'w') as f:
108+
f.write(s)
109+
print('patched sqlite3_finalize + stmt_run_to_completion for GC re-entry')
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Bump MAX_CUSTOM_FUNCS from 32 to 64 (sqlite3/src/lib.rs).
4+
5+
Turso's custom-function registry is capped at 32 pre-generated bridge
6+
trampolines; the driver registers 44 UDFs, so the last 12 silently fail.
7+
Bump to 64 by adding 32 more func_bridge!/FUNC_BRIDGES entries.
8+
"""
9+
10+
import re
11+
import sys
12+
13+
PATH = 'sqlite3/src/lib.rs'
14+
15+
with open(PATH) as f:
16+
s = f.read()
17+
18+
OLD_MAX = 'const MAX_CUSTOM_FUNCS: usize = 32;'
19+
NEW_MAX = 'const MAX_CUSTOM_FUNCS: usize = 64;'
20+
if OLD_MAX not in s:
21+
sys.exit(f'{PATH}: MAX_CUSTOM_FUNCS not found')
22+
s = s.replace(OLD_MAX, NEW_MAX, 1)
23+
24+
# Inject 32 more func_bridge! declarations after func_bridge_31.
25+
bridge_marker = 'func_bridge!(31, func_bridge_31);\n'
26+
if bridge_marker not in s:
27+
sys.exit(f'{PATH}: func_bridge_31 marker not found')
28+
extra_bridges = ''.join(
29+
f'func_bridge!({i}, func_bridge_{i});\n' for i in range(32, 64)
30+
)
31+
s = s.replace(bridge_marker, bridge_marker + extra_bridges, 1)
32+
33+
# Extend the FUNC_BRIDGES array: find the closing `];` of the static
34+
# and inject the extra entries before it.
35+
pat = re.compile(
36+
r'(static FUNC_BRIDGES: \[ScalarFunction; MAX_CUSTOM_FUNCS\] = \[\n'
37+
r'(?:\s*func_bridge_\d+,\n)+)(\];\n)'
38+
)
39+
m = pat.search(s)
40+
if m is None:
41+
sys.exit(f'{PATH}: FUNC_BRIDGES array not found')
42+
extra_entries = ''.join(f' func_bridge_{i},\n' for i in range(32, 64))
43+
s = s[:m.start(2)] + extra_entries + s[m.start(2):]
44+
45+
with open(PATH, 'w') as f:
46+
f.write(s)
47+
print('patched MAX_CUSTOM_FUNCS 32 -> 64')
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Function-name lookup case-insensitivity (core/connection.rs + core/ext/mod.rs).
4+
5+
SQLite looks up function names case-insensitively, but Turso's extension
6+
registry stores names as-is and connection.rs looks them up with
7+
HashMap::get directly. The driver's translator emits e.g. THROW(...)
8+
uppercase, so 32 tests fail with "no such function: THROW" even though
9+
we registered "throw". Normalise to lowercase at both register and
10+
lookup sites.
11+
"""
12+
13+
import sys
14+
15+
CONN = 'core/connection.rs'
16+
EXT = 'core/ext/mod.rs'
17+
18+
with open(CONN) as f:
19+
s = f.read()
20+
old = 'self.functions.get(name).cloned()'
21+
new = 'self.functions.get(&name.to_lowercase()).cloned()'
22+
if old not in s:
23+
sys.exit(f'{CONN}: resolve_function lookup not found')
24+
with open(CONN, 'w') as f:
25+
f.write(s.replace(old, new, 1))
26+
27+
with open(EXT) as f:
28+
s = f.read()
29+
old = '(*ext_ctx.syms).functions.insert(\n name_str.clone(),'
30+
new = '(*ext_ctx.syms).functions.insert(\n name_str.to_lowercase(),'
31+
if old not in s:
32+
sys.exit(f'{EXT}: register_scalar_function insert not found')
33+
with open(EXT, 'w') as f:
34+
f.write(s.replace(old, new, 1))
35+
36+
print('patched function-name case (register + resolve)')

0 commit comments

Comments
 (0)