embed: add tacky_destroy_wait, a destroy bounded by the host #7

Closed
asxpi wants to merge 0 commits from asxpi/libtacky:fix/destroy-wait into develop
First-time contributor

The bug. another.im's desktop app (0.5.4 to 0.5.7, Flatpak) segfaults every time it quits. Closing the window hangs for ~15 s while systemd writes the core dump, and the backend's last store transaction may be lost. Symbolized with the app's debug extension and org.gnome.Sdk.Debug//50:

Thread LWP 48 (crashing): BackendThread -> Tcl_DoOneEvent -> StopEventProc -> Tcl_EvalEx
  -> DbTransPostCmd -> sqlite3_exec -> sqlite3BtreeCommitPhaseOne -> pagerWalFrames
  -> walWriteOneFrame -> sqlite3Codec                                     SIGSEGV
Thread LWP 2 (main): tauri App::run -> tao EventLoop::run -> std::process::exit
  -> __run_exit_handlers

Why. The app's RunEvent::Exit drops the backend (tacky_destroy), and Tauri calls process::exit() as soon as that returns. tacky_destroy waits two seconds (TACKY_REAP_MS) and then returns, leaving a slower destroy to finish on its own (embed/tacky.h). With three accounts the destroy outlasts the bound: the accounts shut down one after another, and the stop is deferred while a request with a nested event loop is on the stack (StopEventProc, t->depth > 0). So the process exits while the backend is still committing, and the commit dies under the exit handlers. The same frame shows up at every quit in every build I ran today.

The change. tacky_destroy_wait(tacky *t, unsigned int max_ms) is tacky_destroy with the host's own bound, 0 for none. A host about to end its process can now wait for the destroy to finish. A host that destroys a backend and carries on keeps the two-second bound.

  • tacky_destroy(t) is now tacky_destroy_wait(t, TACKY_REAP_MS), so its behaviour is unchanged.
  • Reap takes the bound as a parameter.
  • The guarantees are the same at any bound: no callback is running when the call returns, and none fires afterwards.

Tests. embed_host's hooked cases gain stall waited and stall unbounded: the existing 3 s stop script, destroyed with a 6 s bound and with none. Each returns only once the destroy has ended, the stop script's late emit reaches the host before the call returns, and nothing arrives after. make test-embed-host: all cases pass.

stall: ok (destroy 2004 ms)
stall waited: ok (destroy 3021 ms)
stall unbounded: ok (destroy 3022 ms)

README.md, docs/using.md and ROBOTS.md name the new call.

What a host does with it. another.im's shell should hide the window, then call tacky_destroy_wait with a generous bound (not 0, so a wedged backend can't hold the quit forever) before Tauri exits. That's a small follow-up in anotherim-js, sent once this is in.

🤖 Generated with Claude Code

**The bug.** another.im's desktop app (0.5.4 to 0.5.7, Flatpak) segfaults every time it quits. Closing the window hangs for ~15 s while systemd writes the core dump, and the backend's last store transaction may be lost. Symbolized with the app's debug extension and `org.gnome.Sdk.Debug//50`: ``` Thread LWP 48 (crashing): BackendThread -> Tcl_DoOneEvent -> StopEventProc -> Tcl_EvalEx -> DbTransPostCmd -> sqlite3_exec -> sqlite3BtreeCommitPhaseOne -> pagerWalFrames -> walWriteOneFrame -> sqlite3Codec SIGSEGV Thread LWP 2 (main): tauri App::run -> tao EventLoop::run -> std::process::exit -> __run_exit_handlers ``` **Why.** The app's `RunEvent::Exit` drops the backend (`tacky_destroy`), and Tauri calls `process::exit()` as soon as that returns. `tacky_destroy` waits two seconds (`TACKY_REAP_MS`) and then returns, leaving a slower destroy to finish on its own (`embed/tacky.h`). With three accounts the destroy outlasts the bound: the accounts shut down one after another, and the stop is deferred while a request with a nested event loop is on the stack (`StopEventProc`, `t->depth > 0`). So the process exits while the backend is still committing, and the commit dies under the exit handlers. The same frame shows up at every quit in every build I ran today. **The change.** `tacky_destroy_wait(tacky *t, unsigned int max_ms)` is `tacky_destroy` with the host's own bound, `0` for none. A host about to end its process can now wait for the destroy to finish. A host that destroys a backend and carries on keeps the two-second bound. - `tacky_destroy(t)` is now `tacky_destroy_wait(t, TACKY_REAP_MS)`, so its behaviour is unchanged. - `Reap` takes the bound as a parameter. - The guarantees are the same at any bound: no callback is running when the call returns, and none fires afterwards. **Tests.** `embed_host`'s hooked cases gain `stall waited` and `stall unbounded`: the existing 3 s stop script, destroyed with a 6 s bound and with none. Each returns only once the destroy has ended, the stop script's late emit reaches the host before the call returns, and nothing arrives after. `make test-embed-host`: all cases pass. ``` stall: ok (destroy 2004 ms) stall waited: ok (destroy 3021 ms) stall unbounded: ok (destroy 3022 ms) ``` `README.md`, `docs/using.md` and `ROBOTS.md` name the new call. **What a host does with it.** another.im's shell should hide the window, then call `tacky_destroy_wait` with a generous bound (not 0, so a wedged backend can't hold the quit forever) before Tauri exits. That's a small follow-up in anotherim-js, sent once this is in. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
tacky_destroy waits two seconds (TACKY_REAP_MS) for the backend's
destroy and then returns, leaving a slower one to finish on its own
with the callback disarmed. That is right for a host that destroys a
backend and carries on, but not for one about to end its process:
the destroy is still running when the process exits, and exit tears
down state it still uses.

another.im's desktop app quits exactly so. With three accounts its
destroy outlasts the bound (a stop held by a request in flight, the
accounts shut down one after another), Tauri's event loop calls
process::exit() as soon as tacky_destroy returns, and the backend's
last store commit crashes in SQLCipher under the exit handlers, every
time the app quits:

  BackendThread -> StopEventProc -> DbTransPostCmd -> sqlite3_exec
    -> walWriteOneFrame -> sqlite3Codec              SIGSEGV
  main: tao event loop -> std::process::exit -> __run_exit_handlers

Add tacky_destroy_wait(t, max_ms): tacky_destroy with the host's own
bound, 0 for none, so a quitting host can wait for the destroy to
end. tacky_destroy is tacky_destroy_wait(t, TACKY_REAP_MS) and is
unchanged. Reap takes the bound as a parameter.

embed_host's hooked cases gain stall waited and stall unbounded: the
3 s stop script waited for with a 6 s bound and with none, its late
emit reaching the host before the call returns and nothing after.
README, docs/using.md and ROBOTS.md name the call.

Assisted-by: Claude:opus-5-5
Owner

Merged into develop by hand as 0cffcae7 (merge of 5fc35e36 plus the review fixes, 246294ac, pushed as fix/destroy-wait-review), pushed 2026-10-16. Review summary: the change is right, and under it sat an older bug that explains the crash without any slow destroy: Reap counted each wake-up as 100 ms and every emit woke it, so a destroy that emitted twenty times used up tacky_destroy's two seconds at once and returned with the destroy still running. The bound is now a clock deadline (Tcl_GetTime), 0 still no bound; tacky.h states the contract only and says 0 never returns from a destroy that never ends, so a quitting host passes a generous finite bound; tackyd-json waits up to 15 s; make host-swift wraps tacky.h (the Swift wording and ios-check's "three" fixed); stop_wait moved under TACKY_TEST_HOOKS. New embed_host cases: chatty destroy (30 emits during the destroy under plain tacky_destroy; 96 ms and the late emit lost before the fix, 1.66 s and seen after) and stall bounded (destroy_wait 500 against the 3 s stall). Gate on develop: Tcl unit suite 5526 passed (one known test_omemo_devices load flake, 44/44 alone), embed_host and hooks PASS, check-api/headers/native-neutral clean. Thank you for the report and the trace.

Merged into develop by hand as 0cffcae7 (merge of 5fc35e36 plus the review fixes, 246294ac, pushed as fix/destroy-wait-review), pushed 2026-10-16. Review summary: the change is right, and under it sat an older bug that explains the crash without any slow destroy: Reap counted each wake-up as 100 ms and every emit woke it, so a destroy that emitted twenty times used up tacky_destroy's two seconds at once and returned with the destroy still running. The bound is now a clock deadline (Tcl_GetTime), 0 still no bound; tacky.h states the contract only and says 0 never returns from a destroy that never ends, so a quitting host passes a generous finite bound; tackyd-json waits up to 15 s; make host-swift wraps tacky.h (the Swift wording and ios-check's "three" fixed); stop_wait moved under TACKY_TEST_HOOKS. New embed_host cases: chatty destroy (30 emits during the destroy under plain tacky_destroy; 96 ms and the late emit lost before the fix, 1.66 s and seen after) and stall bounded (destroy_wait 500 against the 3 s stall). Gate on develop: Tcl unit suite 5526 passed (one known test_omemo_devices load flake, 44/44 alone), embed_host and hooks PASS, check-api/headers/native-neutral clean. Thank you for the report and the trace.
ann closed this pull request 2026-10-16 11:18:34 +00:00
ann referenced this pull request from a commit 2026-10-25 23:27:10 +00:00
AUDIT-2026-10-25 #7 (Medium, the rest): a Telegram secret chat's files are kept encrypted at rest, on the user's ruling of 2026-10-26 (rule 8's "kept decrypted" was our own shortcut): file.tcl opens a downloaded mtproto: file in memory (mtproto.tcl OpenFile, AES-256-IGE, cut to the plaintext's size, replacing the streamed DecryptFile and its .dec) and keeps it as an OMEMO file is kept (KeepMtproto): in the default decrypt-on-the-fly mode sealed again under AES-256-GCM with a fresh key and iv recorded first as its attachment_key row, so read decrypts it on demand, a download reports it encrypted with no local path and a locked store is an unreadable file; under keep_attachments_decrypted plaintext. Its url carries no key, so Seal seals a plaintext one (a keep-decrypted spell's, or one cached before this change) under a fresh key, only when it has the reference's plaintext size (a sealed file whose row is gone is left, never sealed twice), and the mode's sweeps and Settle take mtproto: files as they take aesgcm:// ones (OmemoUrls, messagestore omemoAttachmentLists). Nothing decrypted is written on the way: the .part is the ciphertext and goes on success and failure. A Telegram document (a sticker, a GIF) is kept the same way. mtproto.tcl's rule 8, file.tcl's header and the wire spec say so. Tests: test_mtproto.tcl mtproto-a-file-at-rest-is-sealed-and-opens (on disk not plaintext and not openable under another key, opens with its row, a lost row does not reseal, keep-decrypted unseals and back seals; fails before: the file was plaintext with no row) and mtproto-a-failed-decrypt-leaves-no-partial-plaintext (now through a download: nothing of the file left, no key row); the suite's other file tests read through file read.

Pull request closed

Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
another-im/libtacky!7
No description provided.