- SchemaWatcher ran its stat sweeps and catalog scans with
asyncio.to_thread(), which starts threads: with num_sql_threads=0 they
now run inline, so Datasette still starts where threads cannot be
started at all (Pyodide). New test simulates that in a subprocess with
Thread.start() raising, covering reads, writes, isolated functions,
owned and external schema detection and a scratch database.
- refresh_schemas(force=True) waited on an asyncio.Lock, which binds to
whichever event loop first contends for it. It is now a threading.Lock
only ever acquired without blocking; a forced refresh that finds one
running runs its own sweep instead of waiting.
- Database.close() no longer copies the set of in-use write connections
(membership tests only), which could race a running write thread.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwkLo2rJWidpkDVT3HkvNv
Callbacks passed to execute_write_fn() - and the internal callbacks of
execute_write(), execute_write_many() and execute_write_script(), plus
write_wrapper hooks - and execute_isolated_fn() now receive a
LeasedConnection that raises ConnectionLeaseError once the callback has
returned, in threaded and non-threaded mode. "with conn as c" yields the
proxy, not the raw connection. Write callbacks may still return a cursor
(lambda conn: conn.execute(...) is common, and the write_many/script
methods return theirs).
The threaded execute_isolated_fn() path for immutable databases now runs
as a tracked executor future, so Database.close() waits for it instead
of closing its connection mid-query (the segfault in the review, P03).
close() also leaves alone the connections of a write thread that is
still running after the 10s join; that thread closes them itself.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwkLo2rJWidpkDVT3HkvNv