Skip to content

Give Core's components a threading contract, and enforce it - #158

Open
jagerman wants to merge 5 commits into
session-foundation:clientfrom
jagerman:core-component-threading
Open

Give Core's components a threading contract, and enforce it#158
jagerman wants to merge 5 commits into
session-foundation:clientfrom
jagerman:core-component-threading

Conversation

@jagerman

Copy link
Copy Markdown
Member

Client dispatches every one of its methods onto Core's loop. Core's own components don't, and nothing marks the difference: client.set_display_name(name, cb) is correct, and client.core.configs.user_profile().set_name(name) compiles just as cleanly and is a data race. The comment on core.globals invites the second outright ("can also be used by the application to persist settings").

The database isn't the problem — session-sqlite hands each thread its own connection and says to use it that way. What's unsynchronised is everything a component holds beside its tables: _adopt_seed rewrites a std::string and a secure_buffer that the loop reads while polling, and the config objects are built lazily and then mutated by merge(). There isn't an atomic or a mutex anywhere in globals, configs or devices.

So CoreComponent grows on_loop(), and everything that touches component state asserts it. Where an application legitimately calls one of these from its own thread, the method now comes in the two forms Client already uses — a failable_function and an await_t — and no third: call_get runs inline when it is already the loop thread, so code already there uses the blocking form and pays nothing for it.

API change

Client::loop is removed. It read as the obvious way to defer work and was the wrong one — see the lifetime fixes below. Client and Core now have call / call_soon / call_later / call_get instead, and those are what everything inside Client uses.

core.loop() stays public, and now documents what it costs: a job left on the loop's own queue is not discarded until ~Loop, which is the last thing ~Core does. It is the escape hatch, not the default.

Two live bugs, fixed here

The assertion found both on the first run. They are in this PR because the suite doesn't pass without them.

  • Network builds its own quic::Loop, so send_request completions run on its thread. Core::_send_poll called _handle_poll_response straight from one — merging configs and flushing their dumps off Core's loop, on every poll.
  • Configs::_send_push does the same on every config push: clears _push_in_flight, confirms the pushed configs, and dumps them, all from the network's thread.

Both are now marshalled onto Core's queue, with the body split out (_handle_push_response) so the callback is one line.

Note what does not change: the _alive canary on those callbacks stays, and now says why. The Network owns them, so they can outlive Core entirely, and a stopped queue cannot cancel something that was never queued — the canary is what makes reaching the queue safe in the first place, and the queue takes over from there.

Lifetime

Three fixes falling out of the same reasoning:

  • Core gains a JobQueue of its own, declared so that it stops — cancelling outstanding component work — before the components those jobs reach are destroyed, and while _loop is still alive to process the stop.
  • Client::_jq existed to do exactly that and was carrying one job. _async and nine other sites deferred onto the loop's own queue instead, which is not emptied until ~Loop. Every one of those jobs holds this and reaches through it into Core's members, so they were being drained throughout the destruction of every one of them. call_get stays on the loop: its caller is blocked inside it and cannot have gone away.
  • _poll_ticker was declared with the rest of the polling machinery, so it was destroyed after the components a firing poll reaches. It is declared last now, and before _jq, so that a poll cannot try to queue its response onto a queue that has already stopped.

Tests

Migrated to run on the loop, the way an application would — TestHelper::on_loop wraps a block, and in_configs covers the tests that reach past Client to check what was written underneath. Both hand back values: a reference or a string_view into a config is dangling the moment the excursion ends, which several of these were relying on.

Three tests were already doing this hop by hand with loop().call_get, which is where the idea came from.

392 test cases pass.

Core's components expose database and config access with no stated thread
contract, while every Client method carefully dispatches onto Core's loop.
Nothing distinguishes the two halves: `client.set_display_name(name, cb)` is
correct and `client.core.configs.user_profile().set_name(name)` compiles just
as cleanly and is a data race.  The header comment on `globals` invites the
second outright ("can also be used by the application to persist settings").

The database itself is not the problem -- session-sqlite hands each thread its
own connection and says to use it that way.  What is unsynchronised is
everything a component holds beside its tables: `_adopt_seed` rewrites a
std::string and a secure_buffer that the loop reads while polling, and the
config objects are built lazily and then mutated by `merge()`.  There is not an
atomic or a mutex anywhere in globals, configs or devices.

So `CoreComponent` grows `on_loop()`, and every method that touches component
state asserts it.  `on_loop()` is also true during construction, since a
component's `init()` necessarily runs on the constructing thread and no other
thread can have reached it yet.

Where an application legitimately calls one of these from its own thread, the
method now comes in the two forms Client already uses -- a `failable_function`
and a `block_t` -- and nothing else.  Not three: `call_get` runs the job inline
when it is already the loop thread, so code already there uses the blocking
form and pays nothing.  That covers `create_account`, `restore_account`,
`device_info`, `update_info` and `build_link_request`.  `Globals`' get/set/erase
stay as they are and say why: one self-contained query each, touching nothing
cached.

`failable_function` and `block_t` move to <session/handler.hpp> so Core can use
the same convention; `session::client` re-exports them, so nothing that names
them changes.

The assertion immediately found a live bug, fixed here too because the suite
does not pass without it.  `Network` builds its own `quic::Loop`, so a
`send_request` completion handler runs on the network's thread -- and
`Core::_send_poll` called `_handle_poll_response` straight from one, merging
configs and flushing their dumps off Core's loop on every single poll.  It is
now marshalled, and `TestHelper::drain` lets the tests that drive a response by
hand wait for it the way production does.

Two related lifetime fixes fall out of the same reasoning.  Core gains a
`JobQueue` of its own, declared last so it stops -- cancelling outstanding
component work -- before the components those jobs reach are destroyed, and
while `_loop` is still alive to process the stop.  And Client's `_jq` existed
to cancel its deferred work on destruction but carried exactly one job:
`_async` and nine other sites deferred onto the loop's own queue instead, which
is not emptied until `~Loop`, the last thing `~Core` does.  Every one of those
jobs holds `this` and reaches through it into Core's members, so they were
being drained throughout the destruction of every one of them.  They now go on
`_jq`; `call_get` stays on the loop, since its caller is blocked inside it and
cannot have gone away.
Deferring work correctly meant remembering to reach for `_jq` rather than
`loop`, and the two read identically at the call site -- which is why nine
Client sites and `_async` itself got it wrong.  A comment on `_jq` does not fix
that; removing the alternative does.

`Client::loop` is gone.  Everything it was used for now has a wrapper, so the
mistake is no longer writable inside Client, and the compiler found every site
rather than leaving it to review.  Anything that genuinely wants the loop still
says `core.loop()`, which stays public and now documents what it costs: a job
on the loop's own queue is not discarded until `~Loop`, the last thing `~Core`
does.

The wrappers are private on Client, whose deferring is all internal, and public
on Core, where `loop()` was already reachable and this is the safer spelling of
the same thing.

`call_get` returns by value rather than perfectly forwarding.  A reference
handed back through it has outlived the job that produced it, which is the
hazard the queue exists to close, so decaying it is the point rather than a
limitation.
…them

`test_core_configs.cpp` reached into the configs from the test thread, which is
what the new assertion is about: a test is an application like any other, and
the configs belong to Core's loop.  `TestHelper::on_loop` wraps a block rather
than a call, since a test case does several config operations in a row and they
all want the same excursion.

Three places could not simply be wrapped whole, and say so where they are:
`reopen()` destroys the Core and so the very loop a wrapper would be running
on, and Catch2's GENERATE and SECTION have to stay at test scope because the
case is re-run for each.

`test_client/`'s shared `*_from_another_device` helpers get the same treatment.
The remaining direct reads in `test_client/` are not converted yet: several
return a `string_view` into the config, which would outlive the excursion, so
they need looking at one at a time rather than wrapping.
Two things the poll fix left behind, both the same shape as it.

A config push's completion runs on the Network's own loop -- Network builds its
own quic::Loop -- and clears `_push_in_flight`, confirms the pushed configs and
dumps them, all of which is Configs' state and none of which is safe off Core's
loop.  The body moves into `_handle_push_response` so the callback can be one
line of marshalling, the way `Core::_handle_poll_response` already is, and
`Pending` moves to the class with it.

Note what does *not* change: the `_alive` canary stays, and there is now a
comment saying why.  The Network owns these callbacks, so they can outlive Core
entirely, and a stopped queue cannot cancel something that was never queued --
the canary is what makes reaching `jq()` safe in the first place, and the queue
takes over from there.

And `_poll_ticker` was declared with the rest of the polling machinery, so it
was destroyed *after* the components a firing poll reaches.  It is declared last
now, which destroys it first: no poll can be in flight by the time anything it
touches is being torn down.  Before `_jq` rather than after, so that a poll
cannot try to queue its response onto a queue that has already stopped, which
throws rather than being ignored.
The remaining reads of `core.configs` from `test_client/`, which is the other
half of what the assertion is about: a test is an application like any other,
and the configs belong to Core's loop.

`in_configs` goes in `common.hpp` rather than `config_helpers.hpp`, since it is
not about config reconciliation -- it is for any test reaching past `Client` to
check what was written underneath.  It hands back a value on purpose: several of
these read `get_name()`, which returns a `string_view` into the config, and that
is dangling the moment the excursion ends.  Those copy inside the lambda.

Three sites bound `auto& contacts = c->core.configs.contacts()` and used it
across several statements.  A reference is exactly what cannot leave the loop,
so they read what they need each time instead.

`TestHelper::sync_contact` and `sync_convo_volatile` hop for themselves rather
than making every caller do it: what they reach is a `_`-form on Client, which
is only ever called from inside `_async` in the real thing.

The `merge_*` helpers wrap their `receive_messages` for the same reason -- a
real poll's merge arrives on the loop.  Three tests were already doing this hop
by hand with `loop().call_get`, which is where the idea came from; one of those
is now spelled the same way as the rest.
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.

1 participant