Skip to content

Bind hooks and reactive flush to their runtime generation - #436

Merged
ospfranco merged 8 commits into
mainfrom
pr-435-rebase
Aug 14, 2026
Merged

Bind hooks and reactive flush to their runtime generation#436
ospfranco merged 8 commits into
mainfrom
pr-435-rebase

Conversation

@ospfranco

Copy link
Copy Markdown
Contributor

Rebase of #435

babzcraig and others added 8 commits August 13, 2026 07:39
During a bridgeless reload two JS runtime generations overlap: the outgoing
one is invalidated while the incoming one is already installing. The process
globals invoker and invalidated cannot describe that, so a database created by
the outgoing generation would post its hook and reactive-query callbacks into
the runtime that replaced it, and then call asFunction() on a jsi::Value owned
by the dead one.

Each generation now gets its own liveness flag, and DBHostObject binds both the
invoker and that flag at construction, on the JS thread. The members shadow the
globals inside member functions, so every invokeAsync site is fixed without
touching each call site.
Same problem as the hooks, on the thread pool. A task read opsqlite::invoker
when it completed rather than when it was queued, so a query started by the
outgoing runtime could resolve into the incoming one and call asFunction() on a
jsi::Value that belongs to the dead runtime.

The invoker and the liveness flag are now captured on the JS thread while the
promise is constructed, and the early return checks this generation rather than
the process-global invalidated, which is set by whichever generation tore down
last.

Note the early return still drops the last references on the pool thread, so
~jsi::Value runs there. That is pre-existing, and the sqlite3_interrupt change
that follows makes it very hard to reach, but it is not airtight without either
leaking the values or handing them to the JS thread.
invalidate() waits on the thread pool but, unlike close() and delete(), never
asks SQLite to stop. A query inside sqlite3_step therefore runs to completion
while the JS runtime is being torn down. React Native gives module invalidation
ten seconds before it destroys the runtime regardless, so an uninterrupted
drain can lose that race and leave a worker touching state the runtime owned.

This is the same three lines close() already runs, moved to the one teardown
path that lacked them.
open() runs on the JS thread and invalidate() runs on the TurboModule queue.
During a bridgeless reload those belong to different generations and overlap,
because the new instance is constructed before the old one finishes
invalidating, so both can reach the dbs vector at once. Iterating it while
another thread emplaces can walk a reallocated buffer.

invalidate() now swaps the vector out under the lock and works on its own copy,
which also keeps the lock off the invalidate() calls themselves.
waitFinished() and doWork() wait on the same condition variable, so
notify_one() can wake a worker when the waiter needed the wakeup, or the other
way round, and leave the other one asleep until the next event. With a single
pool thread that is easy to hit at teardown, where waitFinished() is the only
waiter that matters.

done is read in the doWork() loop condition outside the mutex while the
destructor writes it, which is a data race; make it atomic.
If the last shared_ptr to a ThreadPool is released on one of its own workers,
the destructor tries to join the running thread and std::thread::join throws
"Resource deadlock avoided", which aborts. Dropping the pool capture from the
promisify task removes the known way to get there, so this is a backstop for
any future owner that ends up released on a worker.
@ospfranco

Copy link
Copy Markdown
Contributor Author

From previous PR:

Deliberately left out
Our patch also bounds the drain (waitFinished(timeout)) and, on timeout, leaks the pool and the sqlite handle rather than freeing them underneath a live worker. That is a policy call about what to do when a query will not stop, and it did not belong inside a crash fix, so it is not here. Happy to open it separately if you want it.

can you open a PR for that? I'm not sure what would be the best way to handle these. Shouldn't sqlite3_interrupt minimize the possibility of timeouts? Leaking the handle and pool seems ok for the lifecycle of the mobile apps, I doubt it would cause a problem long term?

Known limit
The early return in promisify still drops the last references on the pool thread, so ~jsi::Value runs there rather than on the JS thread. That behaviour is pre-existing, and the sqlite3_interrupt change makes it very hard to reach, but it is not airtight without either leaking the values or handing them to the JS thread.

Same as previous point, leaking a few them doesn't seem so bad, unless this causes a crash?

Also worth noting: after this change, opsqlite::invalidated is written but never read, since promisify was its only reader.

Should be taken care of, the alive flag is indeed replacing that functionality. Right?

@babzcraig

@babzcraig

Copy link
Copy Markdown
Contributor

@ospfranco I'd like to test with these changes first. I

From previous PR:

Deliberately left out
Our patch also bounds the drain (waitFinished(timeout)) and, on timeout, leaks the pool and the sqlite handle rather than freeing them underneath a live worker. That is a policy call about what to do when a query will not stop, and it did not belong inside a crash fix, so it is not here. Happy to open it separately if you want it.

can you open a PR for that? I'm not sure what would be the best way to handle these. Shouldn't sqlite3_interrupt minimize the possibility of timeouts? Leaking the handle and pool seems ok for the lifecycle of the mobile apps, I doubt it would cause a problem long term?

Known limit
The early return in promisify still drops the last references on the pool thread, so ~jsi::Value runs there rather than on the JS thread. That behaviour is pre-existing, and the sqlite3_interrupt change makes it very hard to reach, but it is not airtight without either leaking the values or handing them to the JS thread.

Same as previous point, leaking a few them doesn't seem so bad, unless this causes a crash?

Also worth noting: after this change, opsqlite::invalidated is written but never read, since promisify was its only reader.

Should be taken care of, the alive flag is indeed replacing that functionality. Right?

@babzcraig

I'd like us to test with these changes first and only if absolutely necessary, we can add the add the bounded drain PR with the leak

@babzcraig babzcraig left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me

@ospfranco
ospfranco merged commit 00d68e8 into main Aug 14, 2026
10 checks passed
@ospfranco
ospfranco deleted the pr-435-rebase branch August 14, 2026 13:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants