Prechádzať zdrojové kódy

Harden AGI SQLite lifecycle and concurrency

This adds robust SQLite resource management in AGI by introducing per-library VM cleanup hooks and wiring them into all script exit paths, preventing leaked DB handles after errors, exits, timeouts, or force-stops. It also updates SQLite connection setup to use WAL/busy-timeout/immediate-lock pragmas and single-connection pooling for safer concurrent access.

A new `db.transaction(fn)` API is added to the JS wrapper for explicit batched writes with commit/rollback semantics and nested-transaction protection. README and Terminal API docs are updated, and tests are expanded to cover DSN behavior, concurrency expectations, cleanup registration, and transaction semantics.
Toby Chui 2 týždňov pred
rodič
commit
4404efa42d

+ 38 - 2
src/mod/agi/README.md

@@ -856,8 +856,15 @@ requirelib("sqlite");
 > `windows/arm`, or `windows/386` builds (excluded at compile time).
 
 `sqlite.open()` returns a connection object. All SQL operations go through that
-object. Connections are automatically closed when the script exits; you may also
-call `db.close()` explicitly to release the handle early.
+object. Connections are automatically closed when the script exits — including
+when it throws or calls `exit()` — so a handle can never be leaked; you may also
+call `db.close()` explicitly to release it early.
+
+**Concurrency.** Databases are opened in WAL mode with a 5 second busy timeout, so
+readers never block the writer and simultaneous requests queue for the write lock
+instead of failing. If you are writing many rows in a loop, wrap them in
+[`db.transaction()`](#dbtransactionfn--any) — a batch of N separate `db.exec()`
+calls costs N lock cycles and N fsyncs, while one transaction costs one of each.
 
 ### `sqlite.open(vpath)` → db
 Opens (or creates) a SQLite database file at the given virtual path.
@@ -910,6 +917,27 @@ var cols = db.schema("notes");
 sendJSONResp(cols);
 ```
 
+### `db.transaction(fn)` → any
+Runs `fn` inside a single write transaction, committing when it returns and
+rolling back if it throws. `fn` receives the connection object, and whatever it
+returns becomes the return value of `db.transaction()`.
+
+Use this whenever you write more than a couple of rows at once: it collapses N
+lock/fsync cycles into one, and keeps the database available to other requests
+for far longer.
+
+```javascript
+db.transaction(function(tx) {
+    for (var i = 0; i < items.length; i++) {
+        tx.exec("INSERT INTO notes (body) VALUES (?)", [items[i]]);
+    }
+});
+```
+
+The transaction opens with `BEGIN IMMEDIATE`, so the busy timeout applies to
+acquiring the write lock rather than deadlocking partway through. Transactions
+cannot be nested — calling `transaction()` inside `fn` throws.
+
 ### `db.close()` → bool
 Closes the database connection and releases the handle.
 
@@ -929,6 +957,14 @@ db.exec("CREATE TABLE IF NOT EXISTS tasks (id INTEGER PRIMARY KEY, title TEXT, d
 var res = db.exec("INSERT INTO tasks (title) VALUES (?)", ["Buy milk"]);
 console.log("new id:", res.lastInsertId);
 
+// Bulk insert — one transaction instead of one per row
+db.transaction(function(tx) {
+    var titles = ["Walk dog", "Pay rent", "Call mum"];
+    for (var i = 0; i < titles.length; i++) {
+        tx.exec("INSERT INTO tasks (title) VALUES (?)", [titles[i]]);
+    }
+});
+
 // Query
 var pending = db.query("SELECT * FROM tasks WHERE done = 0");
 sendJSONResp(pending);

+ 6 - 2
src/mod/agi/agi.go

@@ -311,7 +311,7 @@ func (g *Gateway) ExecuteAGIScript(scriptContent string, fsh *filesystem.FileSys
 	vm.Interrupt = make(chan func(), 1) // required for force-stop support
 	//Inject standard libs into the vm; capture execID for registry correlation
 	execID := g.injectStandardLibs(vm, scriptFile, scriptScope)
-	g.injectUserFunctions(vm, fsh, scriptFile, scriptScope, thisuser, w, r)
+	releaseLibResources := g.injectUserFunctions(vm, fsh, scriptFile, scriptScope, thisuser, w, r)
 
 	username := ""
 	if thisuser != nil {
@@ -328,6 +328,8 @@ func (g *Gateway) ExecuteAGIScript(scriptContent string, fsh *filesystem.FileSys
 	})
 	defer func() {
 		g.vmReg.unregister(execID)
+		//Release library resources (open SQLite handles, etc.) on every exit path
+		releaseLibResources()
 		if caught := recover(); caught != nil {
 			switch caught {
 			case errForceStop:
@@ -437,7 +439,7 @@ func (g *Gateway) ExecuteAGIScriptAsUser(fsh *filesystem.FileSystemHandler, scri
 	vm := otto.New()
 	//Inject standard libs into the vm; capture the execution ID for log correlation.
 	execID := g.injectStandardLibs(vm, scriptFile, "")
-	g.injectUserFunctions(vm, fsh, scriptFile, "", targetUser, w, r)
+	releaseLibResources := g.injectUserFunctions(vm, fsh, scriptFile, "", targetUser, w, r)
 
 	if r != nil {
 		//Inject serverless script to enable access to GET / POST paramters
@@ -458,6 +460,8 @@ func (g *Gateway) ExecuteAGIScriptAsUser(fsh *filesystem.FileSystemHandler, scri
 	//Create a panic recovery logic
 	defer func() {
 		g.vmReg.unregister(execID)
+		//Release library resources (open SQLite handles, etc.) on every exit path
+		releaseLibResources()
 		if caught := recover(); caught != nil {
 			if caught == errTimeout {
 				logger.PrintAndLog("Agi", fmt.Sprintf("[AGI] Execution timeout: %s (user: %s)", scriptFile, targetUser.Username), nil)

+ 116 - 3
src/mod/agi/agi.sqlite.go

@@ -8,6 +8,7 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"strconv"
 	"strings"
 	"sync"
 
@@ -30,10 +31,17 @@ import (
 	  var row     = db.queryRow("SELECT * FROM t WHERE id = ?", [1]);
 	  var tables  = db.tables();
 	  var schema  = db.schema("t");
+	  db.transaction(function(tx) { tx.exec("INSERT INTO t (name) VALUES (?)", ["Bob"]); });
 	  db.close();
 
 	Each call to sqlite.open() returns an object bound to a single connection.
-	Connections are cleaned up when the script ends or db.close() is called.
+	Connections are closed when db.close() is called, and any still open when the
+	script ends are released by a cleanup hook registered through the injection
+	payload - so a script that throws cannot leak a connection (and its file lock).
+
+	Databases are opened in WAL mode with a busy timeout so that concurrent ArozOS
+	requests touching the same file queue for the lock instead of failing with
+	SQLITE_BUSY; see sqliteBuildDSN.
 */
 
 func (g *Gateway) SQLiteLibRegister() {
@@ -59,6 +67,23 @@ func (g *Gateway) injectSQLiteLibFunctions(payload *static.AgiLibInjectionPayloa
 		return openDBs[h]
 	}
 
+	// Close every handle this VM left open. A script that throws (or calls exit)
+	// before its db.close() would otherwise leak the connection - and with it the
+	// SQLite file lock - for the lifetime of the process, eventually making the
+	// database permanently busy for everyone else.
+	payload.RunCleanup(func() {
+		mu.Lock()
+		leaked := openDBs
+		openDBs = make(map[int64]*sql.DB)
+		mu.Unlock()
+
+		for _, db := range leaked {
+			if db != nil {
+				db.Close()
+			}
+		}
+	})
+
 	// _sqlite_open(vpath) => handle integer, or throws on error
 	vm.Set("_sqlite_open", func(call otto.FunctionCall) otto.Value {
 		vpath, err := call.Argument(0).ToString()
@@ -82,10 +107,20 @@ func (g *Gateway) injectSQLiteLibFunctions(payload *static.AgiLibInjectionPayloa
 			panic(vm.MakeCustomError("IOError", err.Error()))
 		}
 
-		db, err := sql.Open("sqlite", rpath)
+		db, err := sql.Open("sqlite", sqliteBuildDSN(rpath))
 		if err != nil {
 			panic(vm.MakeCustomError("SQLiteError", err.Error()))
 		}
+
+		// A fresh VM (and therefore a fresh *sql.DB) is created per request, so
+		// several ArozOS requests can hold connections to the same file at once.
+		// Pin each *sql.DB to a single connection: the Otto VM is single
+		// threaded so it never needs more, and it keeps BEGIN/COMMIT issued from
+		// JS on one connection instead of being scattered across the pool.
+		db.SetMaxOpenConns(1)
+		db.SetMaxIdleConns(1)
+		db.SetConnMaxLifetime(0)
+
 		if err := db.Ping(); err != nil {
 			db.Close()
 			panic(vm.MakeCustomError("SQLiteError", err.Error()))
@@ -284,8 +319,9 @@ var sqlite = {};
 sqlite.open = function(path) {
     var handle = _sqlite_open(path);
     if (handle === null || handle === undefined) { return null; }
-    return {
+    var conn = {
         _handle: handle,
+        _inTx: false,
         exec: function(sql, params) {
             var r = _sqlite_exec(handle, sql, JSON.stringify(params || []));
             return JSON.parse(r);
@@ -304,14 +340,91 @@ sqlite.open = function(path) {
         schema: function(tableName) {
             return JSON.parse(_sqlite_schema(handle, tableName));
         },
+        /*
+            transaction(fn) runs fn inside a single write transaction, committing
+            when it returns and rolling back if it throws. Batching many writes
+            this way turns N implicit transactions (N lock cycles + N fsyncs)
+            into one, which is dramatically faster and holds the write lock for
+            a far shorter total time.
+
+                db.transaction(function(tx) {
+                    for (var i = 0; i < rows.length; i++) {
+                        tx.exec("INSERT INTO t (v) VALUES (?)", [rows[i]]);
+                    }
+                });
+
+            fn receives the same connection object. The return value of fn is
+            returned. Transactions cannot be nested.
+        */
+        transaction: function(fn) {
+            if (typeof fn !== "function") {
+                throw new Error("sqlite: transaction() requires a function");
+            }
+            if (conn._inTx) {
+                throw new Error("sqlite: nested transactions are not supported");
+            }
+            _sqlite_exec(handle, "BEGIN IMMEDIATE", "[]");
+            conn._inTx = true;
+            var result;
+            try {
+                result = fn(conn);
+            } catch (e) {
+                conn._inTx = false;
+                try {
+                    _sqlite_exec(handle, "ROLLBACK", "[]");
+                } catch (rollbackErr) {
+                    /* the original error is the useful one - keep throwing it */
+                }
+                throw e;
+            }
+            conn._inTx = false;
+            _sqlite_exec(handle, "COMMIT", "[]");
+            return result;
+        },
         close: function() {
             return _sqlite_close(handle);
         }
     };
+    return conn;
 };
 `)
 }
 
+// sqliteBusyTimeoutMs is how long SQLite waits for a held lock before giving up
+// with SQLITE_BUSY. Without it the default is 0 - any contention fails instantly,
+// which is fatal here because unrelated ArozOS requests routinely touch the same
+// per-user database at the same time (e.g. a background indexer writing while the
+// UI reads).
+const sqliteBusyTimeoutMs = 5000
+
+// sqliteBuildDSN turns a real file path into a driver DSN carrying the pragmas
+// every ArozOS database needs. The glebarez/go-sqlite driver treats everything
+// after the first '?' as a query string, so a path already containing '?' cannot
+// carry pragmas - in that case the bare path is returned and SQLite falls back to
+// its defaults rather than failing to open.
+func sqliteBuildDSN(rpath string) string {
+	if strings.Contains(rpath, "?") {
+		return rpath
+	}
+
+	pragmas := []string{
+		// Wait for locks instead of failing instantly.
+		"_pragma=busy_timeout(" + strconv.Itoa(sqliteBusyTimeoutMs) + ")",
+		// WAL lets readers run concurrently with a writer, instead of the default
+		// rollback journal where a writer locks the whole file. Silently ignored on
+		// network filesystems that cannot support it, which degrades to the old
+		// behaviour rather than erroring.
+		"_pragma=journal_mode(WAL)",
+		// Safe to relax with WAL and avoids an fsync per commit.
+		"_pragma=synchronous(NORMAL)",
+		// Take the write lock up front on BEGIN so busy_timeout can actually wait.
+		// Deferred transactions that upgrade mid-flight deadlock instead, and
+		// SQLite reports that as an immediate SQLITE_BUSY no timeout can absorb.
+		"_txlock=immediate",
+	}
+	return rpath + "?" + strings.Join(pragmas, "&")
+}
+
 // sqliteParseParams unmarshals a JSON array of query parameter values.
 func sqliteParseParams(paramsJSON string) []interface{} {
 	if paramsJSON == "" || paramsJSON == "null" || paramsJSON == "[]" {

+ 46 - 8
src/mod/agi/agi.user.go

@@ -7,6 +7,7 @@ import (
 	"net/http"
 	"os"
 	"path/filepath"
+	"sync"
 
 	"github.com/robertkrimen/otto"
 	"imuslab.com/arozos/mod/agi/static"
@@ -19,7 +20,41 @@ import (
 // Inject user based functions into the virtual machine
 // Note that the fsh might be nil and scriptPath must be real path of script being executed
 // **Use local file system check if fsh == nil**
-func (g *Gateway) injectUserFunctions(vm *otto.Otto, fsh *filesystem.FileSystemHandler, scriptPath string, scriptScope string, u *user.User, w http.ResponseWriter, r *http.Request) {
+//
+// Returns a teardown closure that releases every resource libraries registered
+// during this VM's lifetime (see static.AgiLibInjectionPayload.RegisterCleanup).
+// Callers MUST defer it - it is safe to call more than once.
+func (g *Gateway) injectUserFunctions(vm *otto.Otto, fsh *filesystem.FileSystemHandler, scriptPath string, scriptScope string, u *user.User, w http.ResponseWriter, r *http.Request) func() {
+	//Teardown closures registered by loadable libraries for this VM.
+	//Guarded by a mutex as a library may spawn goroutines that register late.
+	var cleanupMutex sync.Mutex
+	cleanups := []func(){}
+	registerCleanup := func(cleanup func()) {
+		cleanupMutex.Lock()
+		defer cleanupMutex.Unlock()
+		cleanups = append(cleanups, cleanup)
+	}
+
+	//Run every registered teardown once, in reverse registration order.
+	runCleanups := func() {
+		cleanupMutex.Lock()
+		pending := cleanups
+		cleanups = nil
+		cleanupMutex.Unlock()
+
+		for i := len(pending) - 1; i >= 0; i-- {
+			func(cleanup func()) {
+				//A panicking teardown must not prevent the remaining ones from running
+				defer func() {
+					if caught := recover(); caught != nil {
+						logger.PrintAndLog("Agi", fmt.Sprint("Library cleanup panicked: ", caught), nil)
+					}
+				}()
+				cleanup()
+			}(pending[i])
+		}
+	}
+
 	username := u.Username
 	vm.Set("USERNAME", username)
 	vm.Set("USERICON", u.GetUserIcon())
@@ -220,12 +255,13 @@ func (g *Gateway) injectUserFunctions(vm *otto.Otto, fsh *filesystem.FileSystemH
 			//Check if the library name exists. If yes, run the initiation script on the vm
 			if entryPoint, ok := g.LoadedAGILibrary[libname]; ok {
 				entryPoint(&static.AgiLibInjectionPayload{
-					VM:         vm,
-					User:       u,
-					ScriptFsh:  fsh,
-					ScriptPath: scriptPath,
-					Writer:     w,
-					Request:    r,
+					VM:              vm,
+					User:            u,
+					ScriptFsh:       fsh,
+					ScriptPath:      scriptPath,
+					Writer:          w,
+					Request:         r,
+					RegisterCleanup: registerCleanup,
 				})
 				return otto.TrueValue()
 			} else {
@@ -269,7 +305,8 @@ func (g *Gateway) injectUserFunctions(vm *otto.Otto, fsh *filesystem.FileSystemH
 			vm := otto.New()
 			//Inject standard libs into the vm
 			g.injectStandardLibs(vm, scriptPath, scriptScope)
-			g.injectUserFunctions(vm, fsh, scriptPath, scriptScope, u, w, r)
+			//Release any library resources this detached VM opens when it finishes
+			defer g.injectUserFunctions(vm, fsh, scriptPath, scriptScope, u, w, r)()
 
 			vm.Set("PARENT_DETACHED", true)
 			vm.Set("PARENT_PAYLOAD", payload)
@@ -284,4 +321,5 @@ func (g *Gateway) injectUserFunctions(vm *otto.Otto, fsh *filesystem.FileSystemH
 		return otto.TrueValue()
 	})
 
+	return runCleanups
 }

+ 362 - 0
src/mod/agi/agi_sqlite_test.go

@@ -4,7 +4,10 @@ package agi
 
 import (
 	"database/sql"
+	"fmt"
 	"path/filepath"
+	"strconv"
+	"strings"
 	"testing"
 
 	_ "github.com/glebarez/go-sqlite"
@@ -95,8 +98,367 @@ func TestSQLiteLibRegister_IdempotentDoesNotPanic(t *testing.T) {
 	}()
 }
 
+// ─── DSN construction ─────────────────────────────────────────────────────────
+
+func TestSQLiteBuildDSN_CarriesConcurrencyPragmas(t *testing.T) {
+	dsn := sqliteBuildDSN(filepath.Join("some", "dir", "data.db"))
+
+	// Without these two the library fails instantly on any lock contention,
+	// which is the SQLITE_BUSY class of bug this DSN exists to prevent.
+	for _, want := range []string{
+		"_pragma=busy_timeout(5000)",
+		"_pragma=journal_mode(WAL)",
+		"_pragma=synchronous(NORMAL)",
+		"_txlock=immediate",
+	} {
+		if !strings.Contains(dsn, want) {
+			t.Errorf("DSN %q is missing %q", dsn, want)
+		}
+	}
+}
+
+func TestSQLiteBuildDSN_PreservesPathAndSeparator(t *testing.T) {
+	dsn := sqliteBuildDSN("plain.db")
+	if !strings.HasPrefix(dsn, "plain.db?") {
+		t.Errorf("expected DSN to start with the path then '?', got %q", dsn)
+	}
+}
+
+func TestSQLiteBuildDSN_PathWithQuestionMarkReturnedBare(t *testing.T) {
+	// A '?' in the path would be parsed as the query separator, corrupting both
+	// the filename and the pragmas — such a path must be passed through as-is.
+	weird := filepath.Join("dir", "wh?at.db")
+	if got := sqliteBuildDSN(weird); got != weird {
+		t.Errorf("expected bare path %q, got %q", weird, got)
+	}
+}
+
+func TestSQLiteBuildDSN_OpensRealDatabaseInWALMode(t *testing.T) {
+	dbPath := filepath.Join(t.TempDir(), "wal.db")
+
+	db, err := sql.Open("sqlite", sqliteBuildDSN(dbPath))
+	if err != nil {
+		t.Fatalf("sql.Open with pragma DSN: %v", err)
+	}
+	defer db.Close()
+
+	if err := db.Ping(); err != nil {
+		t.Fatalf("Ping: %v", err)
+	}
+
+	var mode string
+	if err := db.QueryRow(`PRAGMA journal_mode`).Scan(&mode); err != nil {
+		t.Fatalf("reading journal_mode: %v", err)
+	}
+	if !strings.EqualFold(mode, "wal") {
+		t.Errorf("expected journal_mode=wal, got %q", mode)
+	}
+
+	var timeout int
+	if err := db.QueryRow(`PRAGMA busy_timeout`).Scan(&timeout); err != nil {
+		t.Fatalf("reading busy_timeout: %v", err)
+	}
+	if timeout != sqliteBusyTimeoutMs {
+		t.Errorf("expected busy_timeout=%d, got %d", sqliteBusyTimeoutMs, timeout)
+	}
+}
+
+// ─── Concurrency behaviour ────────────────────────────────────────────────────
+
+// Two connections to the same file, mimicking two simultaneous ArozOS requests:
+// one writing (the indexer) while the other reads (the UI). Before the WAL +
+// busy_timeout DSN this combination produced "database is locked (5)".
+func TestSQLiteDSN_ConcurrentReaderDuringWriteTransaction(t *testing.T) {
+	dbPath := filepath.Join(t.TempDir(), "concurrent.db")
+	dsn := sqliteBuildDSN(dbPath)
+
+	writer, err := sql.Open("sqlite", dsn)
+	if err != nil {
+		t.Fatalf("open writer: %v", err)
+	}
+	defer writer.Close()
+	writer.SetMaxOpenConns(1)
+
+	if _, err := writer.Exec(`CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)`); err != nil {
+		t.Fatalf("CREATE TABLE: %v", err)
+	}
+	if _, err := writer.Exec(`INSERT INTO t (v) VALUES ('seed')`); err != nil {
+		t.Fatalf("seed INSERT: %v", err)
+	}
+
+	reader, err := sql.Open("sqlite", dsn)
+	if err != nil {
+		t.Fatalf("open reader: %v", err)
+	}
+	defer reader.Close()
+	reader.SetMaxOpenConns(1)
+
+	// Hold an open write transaction while the reader works.
+	tx, err := writer.Begin()
+	if err != nil {
+		t.Fatalf("Begin: %v", err)
+	}
+	if _, err := tx.Exec(`INSERT INTO t (v) VALUES ('pending')`); err != nil {
+		tx.Rollback()
+		t.Fatalf("INSERT inside tx: %v", err)
+	}
+
+	// In WAL mode this reads the last committed snapshot instead of erroring.
+	var count int
+	if err := reader.QueryRow(`SELECT COUNT(*) FROM t`).Scan(&count); err != nil {
+		tx.Rollback()
+		t.Fatalf("concurrent read during open write transaction: %v", err)
+	}
+	if count != 1 {
+		t.Errorf("reader should see the pre-transaction snapshot (1 row), got %d", count)
+	}
+
+	if err := tx.Commit(); err != nil {
+		t.Fatalf("Commit: %v", err)
+	}
+
+	if err := reader.QueryRow(`SELECT COUNT(*) FROM t`).Scan(&count); err != nil {
+		t.Fatalf("read after commit: %v", err)
+	}
+	if count != 2 {
+		t.Errorf("expected 2 rows after commit, got %d", count)
+	}
+}
+
+// ─── Cleanup on VM teardown ───────────────────────────────────────────────────
+
+func TestInjectSQLiteLib_RegistersCleanup(t *testing.T) {
+	g := minimalGateway()
+	registered := 0
+	payload := &static.AgiLibInjectionPayload{
+		VM:              otto.New(),
+		User:            &user.User{Username: "test"},
+		RegisterCleanup: func(func()) { registered++ },
+	}
+	g.injectSQLiteLibFunctions(payload)
+
+	if registered != 1 {
+		t.Errorf("expected the sqlite lib to register exactly 1 cleanup, got %d", registered)
+	}
+}
+
+func TestInjectSQLiteLib_NilRegisterCleanupDoesNotPanic(t *testing.T) {
+	g := minimalGateway()
+	// init.agi-style payloads carry no cleanup registry; injection must still work.
+	defer func() {
+		if caught := recover(); caught != nil {
+			t.Errorf("injection panicked with nil RegisterCleanup: %v", caught)
+		}
+	}()
+	g.injectSQLiteLibFunctions(&static.AgiLibInjectionPayload{
+		VM:   otto.New(),
+		User: &user.User{Username: "test"},
+	})
+}
+
 // ─── JS object structure ──────────────────────────────────────────────────────
 
+func TestInjectSQLiteLib_TransactionExposed(t *testing.T) {
+	g := minimalGateway()
+	vm := otto.New()
+	g.injectSQLiteLibFunctions(&static.AgiLibInjectionPayload{
+		VM:   vm,
+		User: &user.User{Username: "test"},
+	})
+
+	// sqlite.open() needs a real user filesystem, so assert on the wrapper source
+	// instead: transaction must exist and must open with BEGIN IMMEDIATE so the
+	// busy timeout applies rather than deadlocking on a deferred upgrade.
+	for _, want := range []string{"transaction:", "BEGIN IMMEDIATE", "ROLLBACK", "COMMIT"} {
+		val, err := vm.Run(`sqlite.open.toString().indexOf(` + strconv.Quote(want) + `) >= 0`)
+		if err != nil {
+			t.Fatalf("inspecting wrapper for %q: %v", want, err)
+		}
+		found, _ := val.ToBoolean()
+		if !found {
+			t.Errorf("sqlite.open wrapper should contain %q", want)
+		}
+	}
+}
+
+// injectSQLiteWithStubbedNatives injects the library, then replaces the native
+// bridge functions with JS stubs that append every executed statement to a global
+// `log` array. This exercises the real JS wrapper (BEGIN/COMMIT/ROLLBACK ordering,
+// the nesting guard, error propagation) without needing a user filesystem.
+func injectSQLiteWithStubbedNatives(t *testing.T) *otto.Otto {
+	t.Helper()
+	g := minimalGateway()
+	vm := otto.New()
+	g.injectSQLiteLibFunctions(&static.AgiLibInjectionPayload{
+		VM:   vm,
+		User: &user.User{Username: "test"},
+	})
+
+	if _, err := vm.Run(`
+		var log = [];
+		_sqlite_open  = function(p)          { return 1; };
+		_sqlite_exec  = function(h, sql, pj) { log.push(sql); return '{"lastInsertId":0,"rowsAffected":1}'; };
+		_sqlite_query = function(h, sql, pj) { log.push(sql); return '[]'; };
+	`); err != nil {
+		t.Fatalf("installing native stubs: %v", err)
+	}
+	return vm
+}
+
+func jsStringSlice(t *testing.T, vm *otto.Otto, expr string) []string {
+	t.Helper()
+	val, err := vm.Run(expr)
+	if err != nil {
+		t.Fatalf("evaluating %s: %v", expr, err)
+	}
+	raw, err := val.Export()
+	if err != nil {
+		t.Fatalf("exporting %s: %v", expr, err)
+	}
+	// Otto exports a homogeneous array as []string but an empty one as []interface{}.
+	switch v := raw.(type) {
+	case []string:
+		return v
+	case []interface{}:
+		out := make([]string, 0, len(v))
+		for _, item := range v {
+			out = append(out, fmt.Sprint(item))
+		}
+		return out
+	default:
+		t.Fatalf("expected a string array from %s, got %T", expr, raw)
+		return nil
+	}
+}
+
+func TestSQLiteTransaction_CommitsOnSuccess(t *testing.T) {
+	vm := injectSQLiteWithStubbedNatives(t)
+
+	if _, err := vm.Run(`
+		var db = sqlite.open("x");
+		db.transaction(function(tx) {
+			tx.exec("INSERT 1");
+			tx.exec("INSERT 2");
+		});
+	`); err != nil {
+		t.Fatalf("transaction: %v", err)
+	}
+
+	got := jsStringSlice(t, vm, `log`)
+	want := []string{"BEGIN IMMEDIATE", "INSERT 1", "INSERT 2", "COMMIT"}
+	if len(got) != len(want) {
+		t.Fatalf("expected %v, got %v", want, got)
+	}
+	for i := range want {
+		if got[i] != want[i] {
+			t.Errorf("statement %d: expected %q, got %q", i, want[i], got[i])
+		}
+	}
+}
+
+func TestSQLiteTransaction_RollsBackAndRethrowsOnError(t *testing.T) {
+	vm := injectSQLiteWithStubbedNatives(t)
+
+	val, err := vm.Run(`
+		var db = sqlite.open("x");
+		var caught = "";
+		try {
+			db.transaction(function(tx) {
+				tx.exec("INSERT 1");
+				throw new Error("boom");
+			});
+		} catch (e) {
+			caught = e.message;
+		}
+		caught;
+	`)
+	if err != nil {
+		t.Fatalf("running transaction: %v", err)
+	}
+
+	// The caller's error must survive the rollback, not be masked by it.
+	msg, _ := val.ToString()
+	if !strings.Contains(msg, "boom") {
+		t.Errorf("expected the original error to propagate, got %q", msg)
+	}
+
+	got := jsStringSlice(t, vm, `log`)
+	want := []string{"BEGIN IMMEDIATE", "INSERT 1", "ROLLBACK"}
+	if len(got) != len(want) {
+		t.Fatalf("expected %v, got %v", want, got)
+	}
+	for i := range want {
+		if got[i] != want[i] {
+			t.Errorf("statement %d: expected %q, got %q", i, want[i], got[i])
+		}
+	}
+}
+
+func TestSQLiteTransaction_ReturnsCallbackValueAndAllowsReuse(t *testing.T) {
+	vm := injectSQLiteWithStubbedNatives(t)
+
+	val, err := vm.Run(`
+		var db = sqlite.open("x");
+		var first = db.transaction(function(tx) { return 42; });
+		// A committed transaction must leave the connection usable for the next one.
+		var second = db.transaction(function(tx) { return first + 1; });
+		second;
+	`)
+	if err != nil {
+		t.Fatalf("running transactions: %v", err)
+	}
+	n, _ := val.ToInteger()
+	if n != 43 {
+		t.Errorf("expected the callback's return value to propagate (43), got %d", n)
+	}
+}
+
+func TestSQLiteTransaction_RejectsNesting(t *testing.T) {
+	vm := injectSQLiteWithStubbedNatives(t)
+
+	val, err := vm.Run(`
+		var db = sqlite.open("x");
+		var caught = "";
+		try {
+			db.transaction(function(tx) {
+				tx.transaction(function() {});
+			});
+		} catch (e) {
+			caught = e.message;
+		}
+		caught;
+	`)
+	if err != nil {
+		t.Fatalf("running nested transaction: %v", err)
+	}
+	msg, _ := val.ToString()
+	if !strings.Contains(msg, "nested") {
+		t.Errorf("expected a nesting error, got %q", msg)
+	}
+}
+
+func TestSQLiteTransaction_RequiresFunction(t *testing.T) {
+	vm := injectSQLiteWithStubbedNatives(t)
+
+	val, err := vm.Run(`
+		var db = sqlite.open("x");
+		var caught = "";
+		try { db.transaction("not a function"); } catch (e) { caught = e.message; }
+		caught;
+	`)
+	if err != nil {
+		t.Fatalf("running transaction: %v", err)
+	}
+	msg, _ := val.ToString()
+	if !strings.Contains(msg, "requires a function") {
+		t.Errorf("expected an argument-type error, got %q", msg)
+	}
+	// Nothing should have been sent to SQLite.
+	if got := jsStringSlice(t, vm, `log`); len(got) != 0 {
+		t.Errorf("expected no statements executed, got %v", got)
+	}
+}
+
 func TestInjectSQLiteLib_JSObjectExposed(t *testing.T) {
 	g := minimalGateway()
 	vm := otto.New()

+ 19 - 0
src/mod/agi/static/static.go

@@ -22,6 +22,25 @@ type AgiLibInjectionPayload struct {
 	ScriptPath string
 	Writer     http.ResponseWriter
 	Request    *http.Request
+
+	// RegisterCleanup lets a library hand back a teardown closure that the AGI
+	// runtime runs when the VM terminates - on normal return, exit(), timeout or
+	// force-stop alike. Libraries that hold Go resources across JS calls (open
+	// database handles, file descriptors) must use this so a script that throws
+	// before its explicit close() does not leak them.
+	//
+	// May be nil when a payload is constructed outside a script execution
+	// context (e.g. init.agi appdata injection); always nil-check before use.
+	RegisterCleanup func(cleanup func())
+}
+
+// RunCleanup registers a teardown closure if the payload supports it.
+// Safe to call on payloads with a nil RegisterCleanup.
+func (p *AgiLibInjectionPayload) RunCleanup(cleanup func()) {
+	if p == nil || p.RegisterCleanup == nil || cleanup == nil {
+		return
+	}
+	p.RegisterCleanup(cleanup)
 }
 
 // Get the full vpath if the passing value is a relative path

+ 73 - 0
src/mod/agi/static/static_test.go

@@ -0,0 +1,73 @@
+package static
+
+import "testing"
+
+// RunCleanup is the guarded entry point libraries use to register a teardown
+// closure. It must forward to RegisterCleanup when one is present and stay
+// silent — never panic — in every configuration where one is not, because
+// payloads built outside a script execution context (init.agi injection, tests)
+// legitimately carry no cleanup registry.
+func TestRunCleanup(t *testing.T) {
+	tests := []struct {
+		name        string
+		nilPayload  bool
+		hasRegistry bool
+		nilCleanup  bool
+		wantCalls   int
+	}{
+		{name: "forwards to registry", hasRegistry: true, wantCalls: 1},
+		{name: "no registry is a no-op", hasRegistry: false, wantCalls: 0},
+		{name: "nil cleanup is not registered", hasRegistry: true, nilCleanup: true, wantCalls: 0},
+		{name: "nil payload is a no-op", nilPayload: true, wantCalls: 0},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			registered := 0
+
+			var payload *AgiLibInjectionPayload
+			if !tt.nilPayload {
+				payload = &AgiLibInjectionPayload{}
+				if tt.hasRegistry {
+					payload.RegisterCleanup = func(func()) { registered++ }
+				}
+			}
+
+			cleanup := func() {}
+			if tt.nilCleanup {
+				cleanup = nil
+			}
+
+			defer func() {
+				if caught := recover(); caught != nil {
+					t.Fatalf("RunCleanup panicked: %v", caught)
+				}
+			}()
+			payload.RunCleanup(cleanup)
+
+			if registered != tt.wantCalls {
+				t.Errorf("expected %d registration(s), got %d", tt.wantCalls, registered)
+			}
+		})
+	}
+}
+
+// The closure handed to RunCleanup must reach the registry unchanged, so the
+// runtime executes exactly the teardown the library intended.
+func TestRunCleanup_PassesClosureThrough(t *testing.T) {
+	var captured func()
+	payload := &AgiLibInjectionPayload{
+		RegisterCleanup: func(cleanup func()) { captured = cleanup },
+	}
+
+	ran := false
+	payload.RunCleanup(func() { ran = true })
+
+	if captured == nil {
+		t.Fatal("expected the cleanup closure to reach the registry")
+	}
+	captured()
+	if !ran {
+		t.Error("the registered closure was not the one passed to RunCleanup")
+	}
+}

+ 9 - 2
src/web/Terminal/docs/api.json

@@ -641,7 +641,7 @@
     {
      "name": "sqlite.open",
      "sig": "sqlite.open(vpath)",
-     "desc": "Open or create a SQLite database at the virtual path. Returns a connection object with exec, query, queryRow, tables, schema, and close methods. Throws SQLiteError on failure.",
+     "desc": "Open or create a SQLite database at the virtual path. Returns a connection object with exec, query, queryRow, tables, schema, transaction, and close methods. Throws SQLiteError on failure. Databases open in WAL mode with a 5s busy timeout, so concurrent requests queue for the write lock instead of failing. Any handle still open when the script ends (including on error or exit) is closed automatically.",
      "ret": "object (db)",
      "example": "requirelib(\"sqlite\");\nvar db = sqlite.open(\"user:/.appdata/myapp/data.sqlite\");\ndb.exec(\"CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, name TEXT)\");\ndb.close();"
     },
@@ -680,10 +680,17 @@
      "ret": "object[]",
      "example": "var cols = db.schema(\"t\");\nsendJSONResp(cols);"
     },
+    {
+     "name": "db.transaction",
+     "sig": "db.transaction(fn)",
+     "desc": "Run fn inside a single write transaction, committing when it returns and rolling back if it throws. fn receives the connection object and its return value is passed through. Use this for bulk writes: it collapses N lock cycles and N fsyncs into one. Opens with BEGIN IMMEDIATE so the busy timeout applies to acquiring the lock. Cannot be nested.",
+     "ret": "any",
+     "example": "db.transaction(function(tx) {\n    for (var i = 0; i < items.length; i++) {\n        tx.exec(\"INSERT INTO t (name) VALUES (?)\", [items[i]]);\n    }\n});"
+    },
     {
      "name": "db.close",
      "sig": "db.close()",
-     "desc": "Close the database connection and release the handle.",
+     "desc": "Close the database connection and release the handle. Optional - handles are also closed automatically when the script ends.",
      "ret": "bool",
      "example": "db.close();"
     }