@@ -1065,4 +1065,290 @@ defmodule Exqlite.Sqlite3Test do
10651065 end
10661066 end
10671067 end
1068+
1069+ # -- Busy timeout baseline behavior ------------------------------------------
1070+
1071+ describe "busy_timeout behavior" do
1072+ defp with_file_db ( fun ) do
1073+ path = Temp . path! ( )
1074+
1075+ try do
1076+ fun . ( path )
1077+ after
1078+ File . rm ( path )
1079+ File . rm ( path <> "-wal" )
1080+ File . rm ( path <> "-shm" )
1081+ end
1082+ end
1083+
1084+ defp setup_write_conflict ( path , opts ) do
1085+ busy_timeout = Keyword . get ( opts , :busy_timeout , 2000 )
1086+
1087+ { :ok , db1 } = Sqlite3 . open ( path )
1088+ { :ok , db2 } = Sqlite3 . open ( path )
1089+
1090+ :ok = Sqlite3 . execute ( db1 , "PRAGMA journal_mode=WAL" )
1091+ :ok = Sqlite3 . execute ( db1 , "CREATE TABLE t (i INTEGER)" )
1092+ :ok = Sqlite3 . execute ( db1 , "INSERT INTO t VALUES(1)" )
1093+
1094+ :ok = Sqlite3 . set_busy_timeout ( db2 , busy_timeout )
1095+
1096+ # db1 grabs exclusive write lock
1097+ :ok = Sqlite3 . execute ( db1 , "BEGIN IMMEDIATE" )
1098+ :ok = Sqlite3 . execute ( db1 , "INSERT INTO t VALUES(2)" )
1099+
1100+ { db1 , db2 }
1101+ end
1102+
1103+ test "second writer waits ~busy_timeout before returning error" do
1104+ with_file_db ( fn path ->
1105+ { db1 , db2 } = setup_write_conflict ( path , busy_timeout: 500 )
1106+
1107+ { elapsed_us , result } =
1108+ :timer . tc ( fn -> Sqlite3 . execute ( db2 , "INSERT INTO t VALUES(3)" ) end )
1109+
1110+ elapsed_ms = div ( elapsed_us , 1000 )
1111+
1112+ assert { :error , _msg } = result
1113+
1114+ assert elapsed_ms >= 300 ,
1115+ "returned too quickly (#{ elapsed_ms } ms), expected ~500ms delay"
1116+
1117+ assert elapsed_ms < 3000 , "took too long (#{ elapsed_ms } ms)"
1118+
1119+ :ok = Sqlite3 . execute ( db1 , "ROLLBACK" )
1120+ Sqlite3 . close ( db1 )
1121+ Sqlite3 . close ( db2 )
1122+ end )
1123+ end
1124+
1125+ test "busy_timeout=0 returns error immediately" do
1126+ with_file_db ( fn path ->
1127+ { db1 , db2 } = setup_write_conflict ( path , busy_timeout: 0 )
1128+
1129+ { elapsed_us , result } =
1130+ :timer . tc ( fn -> Sqlite3 . execute ( db2 , "INSERT INTO t VALUES(3)" ) end )
1131+
1132+ elapsed_ms = div ( elapsed_us , 1000 )
1133+
1134+ assert { :error , _msg } = result
1135+
1136+ assert elapsed_ms < 100 ,
1137+ "busy_timeout=0 should return immediately, took #{ elapsed_ms } ms"
1138+
1139+ :ok = Sqlite3 . execute ( db1 , "ROLLBACK" )
1140+ Sqlite3 . close ( db1 )
1141+ Sqlite3 . close ( db2 )
1142+ end )
1143+ end
1144+
1145+ test "multi_step returns :busy on write conflict with timeout=0" do
1146+ with_file_db ( fn path ->
1147+ { db1 , db2 } = setup_write_conflict ( path , busy_timeout: 0 )
1148+
1149+ { :ok , stmt } = Sqlite3 . prepare ( db2 , "INSERT INTO t VALUES(3)" )
1150+ result = Sqlite3 . multi_step ( db2 , stmt , 1 )
1151+
1152+ assert result == :busy
1153+
1154+ :ok = Sqlite3 . execute ( db1 , "ROLLBACK" )
1155+ Sqlite3 . close ( db1 )
1156+ Sqlite3 . close ( db2 )
1157+ end )
1158+ end
1159+
1160+ test "step returns :busy on write conflict with timeout=0" do
1161+ with_file_db ( fn path ->
1162+ { db1 , db2 } = setup_write_conflict ( path , busy_timeout: 0 )
1163+
1164+ { :ok , stmt } = Sqlite3 . prepare ( db2 , "INSERT INTO t VALUES(3)" )
1165+ result = Sqlite3 . step ( db2 , stmt )
1166+
1167+ assert result == :busy
1168+
1169+ :ok = Sqlite3 . execute ( db1 , "ROLLBACK" )
1170+ Sqlite3 . close ( db1 )
1171+ Sqlite3 . close ( db2 )
1172+ end )
1173+ end
1174+ end
1175+
1176+ # -- New NIF: set_busy_timeout/2 ---------------------------------------------
1177+
1178+ describe ".set_busy_timeout/2" do
1179+ test "sets busy timeout and returns :ok" do
1180+ { :ok , conn } = Sqlite3 . open ( ":memory:" )
1181+
1182+ assert :ok = Sqlite3 . set_busy_timeout ( conn , 5000 )
1183+
1184+ Sqlite3 . close ( conn )
1185+ end
1186+
1187+ test "timeout of 0 causes immediate SQLITE_BUSY" do
1188+ with_file_db ( fn path ->
1189+ { :ok , db1 } = Sqlite3 . open ( path )
1190+ { :ok , db2 } = Sqlite3 . open ( path )
1191+
1192+ :ok = Sqlite3 . execute ( db1 , "PRAGMA journal_mode=WAL" )
1193+ :ok = Sqlite3 . execute ( db1 , "CREATE TABLE t (i INTEGER)" )
1194+ :ok = Sqlite3 . execute ( db1 , "INSERT INTO t VALUES(1)" )
1195+
1196+ :ok = Sqlite3 . set_busy_timeout ( db2 , 0 )
1197+
1198+ :ok = Sqlite3 . execute ( db1 , "BEGIN IMMEDIATE" )
1199+ :ok = Sqlite3 . execute ( db1 , "INSERT INTO t VALUES(2)" )
1200+
1201+ { elapsed_us , result } =
1202+ :timer . tc ( fn -> Sqlite3 . execute ( db2 , "INSERT INTO t VALUES(3)" ) end )
1203+
1204+ elapsed_ms = div ( elapsed_us , 1000 )
1205+
1206+ assert { :error , _ } = result
1207+ assert elapsed_ms < 100
1208+
1209+ :ok = Sqlite3 . execute ( db1 , "ROLLBACK" )
1210+ Sqlite3 . close ( db1 )
1211+ Sqlite3 . close ( db2 )
1212+ end )
1213+ end
1214+
1215+ test "custom timeout delays before SQLITE_BUSY" do
1216+ with_file_db ( fn path ->
1217+ { :ok , db1 } = Sqlite3 . open ( path )
1218+ { :ok , db2 } = Sqlite3 . open ( path )
1219+
1220+ :ok = Sqlite3 . execute ( db1 , "PRAGMA journal_mode=WAL" )
1221+ :ok = Sqlite3 . execute ( db1 , "CREATE TABLE t (i INTEGER)" )
1222+ :ok = Sqlite3 . execute ( db1 , "INSERT INTO t VALUES(1)" )
1223+
1224+ :ok = Sqlite3 . set_busy_timeout ( db2 , 500 )
1225+
1226+ :ok = Sqlite3 . execute ( db1 , "BEGIN IMMEDIATE" )
1227+ :ok = Sqlite3 . execute ( db1 , "INSERT INTO t VALUES(2)" )
1228+
1229+ { elapsed_us , result } =
1230+ :timer . tc ( fn -> Sqlite3 . execute ( db2 , "INSERT INTO t VALUES(3)" ) end )
1231+
1232+ elapsed_ms = div ( elapsed_us , 1000 )
1233+
1234+ assert { :error , _ } = result
1235+ assert elapsed_ms >= 300 , "returned too quickly (#{ elapsed_ms } ms)"
1236+ assert elapsed_ms < 3000 , "took too long (#{ elapsed_ms } ms)"
1237+
1238+ :ok = Sqlite3 . execute ( db1 , "ROLLBACK" )
1239+ Sqlite3 . close ( db1 )
1240+ Sqlite3 . close ( db2 )
1241+ end )
1242+ end
1243+ end
1244+
1245+ # -- New NIF: cancel/1 -------------------------------------------------------
1246+
1247+ describe ".cancel/1" do
1248+ test "returns :ok on an idle connection" do
1249+ { :ok , conn } = Sqlite3 . open ( ":memory:" )
1250+
1251+ assert :ok = Sqlite3 . cancel ( conn )
1252+
1253+ Sqlite3 . close ( conn )
1254+ end
1255+
1256+ test "breaks through busy handler sleep" do
1257+ with_file_db ( fn path ->
1258+ { :ok , db1 } = Sqlite3 . open ( path )
1259+ { :ok , db2 } = Sqlite3 . open ( path )
1260+
1261+ :ok = Sqlite3 . execute ( db1 , "PRAGMA journal_mode=WAL" )
1262+ :ok = Sqlite3 . execute ( db1 , "CREATE TABLE t (i INTEGER)" )
1263+ :ok = Sqlite3 . execute ( db1 , "INSERT INTO t VALUES(1)" )
1264+
1265+ # db2 gets a long busy timeout — without cancel, it would wait 60s
1266+ :ok = Sqlite3 . set_busy_timeout ( db2 , 60_000 )
1267+
1268+ # db1 holds exclusive lock
1269+ :ok = Sqlite3 . execute ( db1 , "BEGIN IMMEDIATE" )
1270+ :ok = Sqlite3 . execute ( db1 , "INSERT INTO t VALUES(2)" )
1271+
1272+ parent = self ( )
1273+
1274+ # db2 tries to write — enters busy handler sleep
1275+ spawn ( fn ->
1276+ result = Sqlite3 . execute ( db2 , "INSERT INTO t VALUES(3)" )
1277+ send ( parent , { :write_result , result } )
1278+ end )
1279+
1280+ # Give it time to enter the busy handler
1281+ Process . sleep ( 200 )
1282+
1283+ # Cancel should wake the busy handler immediately
1284+ :ok = Sqlite3 . cancel ( db2 )
1285+
1286+ result =
1287+ receive do
1288+ { :write_result , r } -> r
1289+ after
1290+ 2_000 -> :timeout
1291+ end
1292+
1293+ assert result != :timeout ,
1294+ "cancel did not break through busy handler within 2s"
1295+
1296+ assert { :error , _ } = result
1297+
1298+ :ok = Sqlite3 . execute ( db1 , "ROLLBACK" )
1299+ Sqlite3 . close ( db1 )
1300+ Sqlite3 . close ( db2 )
1301+ end )
1302+ end
1303+
1304+ test "cancelled connection can be reused after reset" do
1305+ with_file_db ( fn path ->
1306+ { :ok , db1 } = Sqlite3 . open ( path )
1307+ { :ok , db2 } = Sqlite3 . open ( path )
1308+
1309+ :ok = Sqlite3 . execute ( db1 , "PRAGMA journal_mode=WAL" )
1310+ :ok = Sqlite3 . execute ( db1 , "CREATE TABLE t (i INTEGER)" )
1311+ :ok = Sqlite3 . execute ( db1 , "INSERT INTO t VALUES(1)" )
1312+
1313+ :ok = Sqlite3 . set_busy_timeout ( db2 , 60_000 )
1314+
1315+ :ok = Sqlite3 . execute ( db1 , "BEGIN IMMEDIATE" )
1316+ :ok = Sqlite3 . execute ( db1 , "INSERT INTO t VALUES(2)" )
1317+
1318+ parent = self ( )
1319+
1320+ spawn ( fn ->
1321+ result = Sqlite3 . execute ( db2 , "INSERT INTO t VALUES(3)" )
1322+ send ( parent , { :write_result , result } )
1323+ end )
1324+
1325+ Process . sleep ( 200 )
1326+ :ok = Sqlite3 . cancel ( db2 )
1327+
1328+ receive do
1329+ { :write_result , _ } -> :ok
1330+ after
1331+ 2_000 -> flunk ( "cancel did not break through busy handler" )
1332+ end
1333+
1334+ # Release the lock
1335+ :ok = Sqlite3 . execute ( db1 , "ROLLBACK" )
1336+
1337+ # db2 should be usable again for reads and writes
1338+ assert { :ok , _stmt } = Sqlite3 . prepare ( db2 , "SELECT * FROM t" )
1339+ assert :ok = Sqlite3 . execute ( db2 , "INSERT INTO t VALUES(99)" )
1340+
1341+ Sqlite3 . close ( db1 )
1342+ Sqlite3 . close ( db2 )
1343+ end )
1344+ end
1345+
1346+ test "cancel on closed connection is a no-op" do
1347+ { :ok , conn } = Sqlite3 . open ( ":memory:" )
1348+ :ok = Sqlite3 . close ( conn )
1349+
1350+ # cancel on a closed connection is safe (same as interrupt)
1351+ assert :ok = Sqlite3 . cancel ( conn )
1352+ end
1353+ end
10681354end
0 commit comments