@@ -1221,4 +1221,290 @@ defmodule Exqlite.Sqlite3Test do
12211221 end
12221222 end
12231223 end
1224+
1225+ # -- Busy timeout baseline behavior ------------------------------------------
1226+
1227+ describe "busy_timeout behavior" do
1228+ defp with_file_db ( fun ) do
1229+ path = Temp . path! ( )
1230+
1231+ try do
1232+ fun . ( path )
1233+ after
1234+ File . rm ( path )
1235+ File . rm ( path <> "-wal" )
1236+ File . rm ( path <> "-shm" )
1237+ end
1238+ end
1239+
1240+ defp setup_write_conflict ( path , opts ) do
1241+ busy_timeout = Keyword . get ( opts , :busy_timeout , 2000 )
1242+
1243+ { :ok , db1 } = Sqlite3 . open ( path )
1244+ { :ok , db2 } = Sqlite3 . open ( path )
1245+
1246+ :ok = Sqlite3 . execute ( db1 , "PRAGMA journal_mode=WAL" )
1247+ :ok = Sqlite3 . execute ( db1 , "CREATE TABLE t (i INTEGER)" )
1248+ :ok = Sqlite3 . execute ( db1 , "INSERT INTO t VALUES(1)" )
1249+
1250+ :ok = Sqlite3 . set_busy_timeout ( db2 , busy_timeout )
1251+
1252+ # db1 grabs exclusive write lock
1253+ :ok = Sqlite3 . execute ( db1 , "BEGIN IMMEDIATE" )
1254+ :ok = Sqlite3 . execute ( db1 , "INSERT INTO t VALUES(2)" )
1255+
1256+ { db1 , db2 }
1257+ end
1258+
1259+ test "second writer waits ~busy_timeout before returning error" do
1260+ with_file_db ( fn path ->
1261+ { db1 , db2 } = setup_write_conflict ( path , busy_timeout: 500 )
1262+
1263+ { elapsed_us , result } =
1264+ :timer . tc ( fn -> Sqlite3 . execute ( db2 , "INSERT INTO t VALUES(3)" ) end )
1265+
1266+ elapsed_ms = div ( elapsed_us , 1000 )
1267+
1268+ assert { :error , _msg } = result
1269+
1270+ assert elapsed_ms >= 300 ,
1271+ "returned too quickly (#{ elapsed_ms } ms), expected ~500ms delay"
1272+
1273+ assert elapsed_ms < 3000 , "took too long (#{ elapsed_ms } ms)"
1274+
1275+ :ok = Sqlite3 . execute ( db1 , "ROLLBACK" )
1276+ Sqlite3 . close ( db1 )
1277+ Sqlite3 . close ( db2 )
1278+ end )
1279+ end
1280+
1281+ test "busy_timeout=0 returns error immediately" do
1282+ with_file_db ( fn path ->
1283+ { db1 , db2 } = setup_write_conflict ( path , busy_timeout: 0 )
1284+
1285+ { elapsed_us , result } =
1286+ :timer . tc ( fn -> Sqlite3 . execute ( db2 , "INSERT INTO t VALUES(3)" ) end )
1287+
1288+ elapsed_ms = div ( elapsed_us , 1000 )
1289+
1290+ assert { :error , _msg } = result
1291+
1292+ assert elapsed_ms < 100 ,
1293+ "busy_timeout=0 should return immediately, took #{ elapsed_ms } ms"
1294+
1295+ :ok = Sqlite3 . execute ( db1 , "ROLLBACK" )
1296+ Sqlite3 . close ( db1 )
1297+ Sqlite3 . close ( db2 )
1298+ end )
1299+ end
1300+
1301+ test "multi_step returns :busy on write conflict with timeout=0" do
1302+ with_file_db ( fn path ->
1303+ { db1 , db2 } = setup_write_conflict ( path , busy_timeout: 0 )
1304+
1305+ { :ok , stmt } = Sqlite3 . prepare ( db2 , "INSERT INTO t VALUES(3)" )
1306+ result = Sqlite3 . multi_step ( db2 , stmt , 1 )
1307+
1308+ assert result == :busy
1309+
1310+ :ok = Sqlite3 . execute ( db1 , "ROLLBACK" )
1311+ Sqlite3 . close ( db1 )
1312+ Sqlite3 . close ( db2 )
1313+ end )
1314+ end
1315+
1316+ test "step returns :busy on write conflict with timeout=0" do
1317+ with_file_db ( fn path ->
1318+ { db1 , db2 } = setup_write_conflict ( path , busy_timeout: 0 )
1319+
1320+ { :ok , stmt } = Sqlite3 . prepare ( db2 , "INSERT INTO t VALUES(3)" )
1321+ result = Sqlite3 . step ( db2 , stmt )
1322+
1323+ assert result == :busy
1324+
1325+ :ok = Sqlite3 . execute ( db1 , "ROLLBACK" )
1326+ Sqlite3 . close ( db1 )
1327+ Sqlite3 . close ( db2 )
1328+ end )
1329+ end
1330+ end
1331+
1332+ # -- New NIF: set_busy_timeout/2 ---------------------------------------------
1333+
1334+ describe ".set_busy_timeout/2" do
1335+ test "sets busy timeout and returns :ok" do
1336+ { :ok , conn } = Sqlite3 . open ( ":memory:" )
1337+
1338+ assert :ok = Sqlite3 . set_busy_timeout ( conn , 5000 )
1339+
1340+ Sqlite3 . close ( conn )
1341+ end
1342+
1343+ test "timeout of 0 causes immediate SQLITE_BUSY" do
1344+ with_file_db ( fn path ->
1345+ { :ok , db1 } = Sqlite3 . open ( path )
1346+ { :ok , db2 } = Sqlite3 . open ( path )
1347+
1348+ :ok = Sqlite3 . execute ( db1 , "PRAGMA journal_mode=WAL" )
1349+ :ok = Sqlite3 . execute ( db1 , "CREATE TABLE t (i INTEGER)" )
1350+ :ok = Sqlite3 . execute ( db1 , "INSERT INTO t VALUES(1)" )
1351+
1352+ :ok = Sqlite3 . set_busy_timeout ( db2 , 0 )
1353+
1354+ :ok = Sqlite3 . execute ( db1 , "BEGIN IMMEDIATE" )
1355+ :ok = Sqlite3 . execute ( db1 , "INSERT INTO t VALUES(2)" )
1356+
1357+ { elapsed_us , result } =
1358+ :timer . tc ( fn -> Sqlite3 . execute ( db2 , "INSERT INTO t VALUES(3)" ) end )
1359+
1360+ elapsed_ms = div ( elapsed_us , 1000 )
1361+
1362+ assert { :error , _ } = result
1363+ assert elapsed_ms < 100
1364+
1365+ :ok = Sqlite3 . execute ( db1 , "ROLLBACK" )
1366+ Sqlite3 . close ( db1 )
1367+ Sqlite3 . close ( db2 )
1368+ end )
1369+ end
1370+
1371+ test "custom timeout delays before SQLITE_BUSY" do
1372+ with_file_db ( fn path ->
1373+ { :ok , db1 } = Sqlite3 . open ( path )
1374+ { :ok , db2 } = Sqlite3 . open ( path )
1375+
1376+ :ok = Sqlite3 . execute ( db1 , "PRAGMA journal_mode=WAL" )
1377+ :ok = Sqlite3 . execute ( db1 , "CREATE TABLE t (i INTEGER)" )
1378+ :ok = Sqlite3 . execute ( db1 , "INSERT INTO t VALUES(1)" )
1379+
1380+ :ok = Sqlite3 . set_busy_timeout ( db2 , 500 )
1381+
1382+ :ok = Sqlite3 . execute ( db1 , "BEGIN IMMEDIATE" )
1383+ :ok = Sqlite3 . execute ( db1 , "INSERT INTO t VALUES(2)" )
1384+
1385+ { elapsed_us , result } =
1386+ :timer . tc ( fn -> Sqlite3 . execute ( db2 , "INSERT INTO t VALUES(3)" ) end )
1387+
1388+ elapsed_ms = div ( elapsed_us , 1000 )
1389+
1390+ assert { :error , _ } = result
1391+ assert elapsed_ms >= 300 , "returned too quickly (#{ elapsed_ms } ms)"
1392+ assert elapsed_ms < 3000 , "took too long (#{ elapsed_ms } ms)"
1393+
1394+ :ok = Sqlite3 . execute ( db1 , "ROLLBACK" )
1395+ Sqlite3 . close ( db1 )
1396+ Sqlite3 . close ( db2 )
1397+ end )
1398+ end
1399+ end
1400+
1401+ # -- New NIF: cancel/1 -------------------------------------------------------
1402+
1403+ describe ".cancel/1" do
1404+ test "returns :ok on an idle connection" do
1405+ { :ok , conn } = Sqlite3 . open ( ":memory:" )
1406+
1407+ assert :ok = Sqlite3 . cancel ( conn )
1408+
1409+ Sqlite3 . close ( conn )
1410+ end
1411+
1412+ test "breaks through busy handler sleep" do
1413+ with_file_db ( fn path ->
1414+ { :ok , db1 } = Sqlite3 . open ( path )
1415+ { :ok , db2 } = Sqlite3 . open ( path )
1416+
1417+ :ok = Sqlite3 . execute ( db1 , "PRAGMA journal_mode=WAL" )
1418+ :ok = Sqlite3 . execute ( db1 , "CREATE TABLE t (i INTEGER)" )
1419+ :ok = Sqlite3 . execute ( db1 , "INSERT INTO t VALUES(1)" )
1420+
1421+ # db2 gets a long busy timeout — without cancel, it would wait 60s
1422+ :ok = Sqlite3 . set_busy_timeout ( db2 , 60_000 )
1423+
1424+ # db1 holds exclusive lock
1425+ :ok = Sqlite3 . execute ( db1 , "BEGIN IMMEDIATE" )
1426+ :ok = Sqlite3 . execute ( db1 , "INSERT INTO t VALUES(2)" )
1427+
1428+ parent = self ( )
1429+
1430+ # db2 tries to write — enters busy handler sleep
1431+ spawn ( fn ->
1432+ result = Sqlite3 . execute ( db2 , "INSERT INTO t VALUES(3)" )
1433+ send ( parent , { :write_result , result } )
1434+ end )
1435+
1436+ # Give it time to enter the busy handler
1437+ Process . sleep ( 200 )
1438+
1439+ # Cancel should wake the busy handler immediately
1440+ :ok = Sqlite3 . cancel ( db2 )
1441+
1442+ result =
1443+ receive do
1444+ { :write_result , r } -> r
1445+ after
1446+ 2_000 -> :timeout
1447+ end
1448+
1449+ assert result != :timeout ,
1450+ "cancel did not break through busy handler within 2s"
1451+
1452+ assert { :error , _ } = result
1453+
1454+ :ok = Sqlite3 . execute ( db1 , "ROLLBACK" )
1455+ Sqlite3 . close ( db1 )
1456+ Sqlite3 . close ( db2 )
1457+ end )
1458+ end
1459+
1460+ test "cancelled connection can be reused after reset" do
1461+ with_file_db ( fn path ->
1462+ { :ok , db1 } = Sqlite3 . open ( path )
1463+ { :ok , db2 } = Sqlite3 . open ( path )
1464+
1465+ :ok = Sqlite3 . execute ( db1 , "PRAGMA journal_mode=WAL" )
1466+ :ok = Sqlite3 . execute ( db1 , "CREATE TABLE t (i INTEGER)" )
1467+ :ok = Sqlite3 . execute ( db1 , "INSERT INTO t VALUES(1)" )
1468+
1469+ :ok = Sqlite3 . set_busy_timeout ( db2 , 60_000 )
1470+
1471+ :ok = Sqlite3 . execute ( db1 , "BEGIN IMMEDIATE" )
1472+ :ok = Sqlite3 . execute ( db1 , "INSERT INTO t VALUES(2)" )
1473+
1474+ parent = self ( )
1475+
1476+ spawn ( fn ->
1477+ result = Sqlite3 . execute ( db2 , "INSERT INTO t VALUES(3)" )
1478+ send ( parent , { :write_result , result } )
1479+ end )
1480+
1481+ Process . sleep ( 200 )
1482+ :ok = Sqlite3 . cancel ( db2 )
1483+
1484+ receive do
1485+ { :write_result , _ } -> :ok
1486+ after
1487+ 2_000 -> flunk ( "cancel did not break through busy handler" )
1488+ end
1489+
1490+ # Release the lock
1491+ :ok = Sqlite3 . execute ( db1 , "ROLLBACK" )
1492+
1493+ # db2 should be usable again for reads and writes
1494+ assert { :ok , _stmt } = Sqlite3 . prepare ( db2 , "SELECT * FROM t" )
1495+ assert :ok = Sqlite3 . execute ( db2 , "INSERT INTO t VALUES(99)" )
1496+
1497+ Sqlite3 . close ( db1 )
1498+ Sqlite3 . close ( db2 )
1499+ end )
1500+ end
1501+
1502+ test "cancel on closed connection is a no-op" do
1503+ { :ok , conn } = Sqlite3 . open ( ":memory:" )
1504+ :ok = Sqlite3 . close ( conn )
1505+
1506+ # cancel on a closed connection is safe (same as interrupt)
1507+ assert :ok = Sqlite3 . cancel ( conn )
1508+ end
1509+ end
12241510end
0 commit comments