Skip to content

shell: a process app may refuse a close and ask first (#179) - #187

Merged
ymote merged 1 commit into
mainfrom
feat/process-close-confirm
Sep 30, 2026
Merged

ymote merged 1 commit into
mainfrom
feat/process-close-confirm

Conversation

@ymote

@ymote ymote commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

Closes the last gap in #179. Stacked on #186, which covers in-process modules; the makepad side is OctoSense-org/makepad#68.

The gap: on macOS the Terminal runs as a separate process by default. Closing its tile made request_close send CloseRequested and Kill back to back, so running jobs ended without asking.

The close state machine (crates/shell/src/process_close.rs, pure and unit-tested)

Each process client is in one of three states: Idle, Awaiting (asked, no answer yet) or Asking (it refused and is asking the person).

Event Result
Close of an app that never sent AsksBeforeClose Legacy: CloseRequested + Kill, the tile leaves at once, as today
Close while Idle or Asking Ask: send CloseRequested alone, keep the tile and bring it to the front, go to Awaiting
Close while Awaiting, less than 0.5 s after the ask Wait: treated as the same click (a double click)
Close while Awaiting, 0.5 s or more after the ask Force: Kill, then SIGTERM to the process group and SIGKILL after 300 ms
CloseRefused while Awaiting go to Asking; no deadline
The app's own WmRequest::Close its "yes": closed at once, no question back
Exit removed; counted as a close, not a crash
Awaiting for 5 s (ANSWER_TIMEOUT) ended, as Force

Shell quit (menu Quit, Cmd+Q, the window's close button, /quit) asks every declared app the same way, together with #186's modules. It waits while any module or app is deciding and goes once the last one says yes (process_close::take_quit_ready). A second quit forces apps that have not answered. A close of one app drops a waiting quit, as in #186. A termination signal or a crash of the shell asks nobody.

Fallback policy, and why

  • An explicit acknowledgement is needed. Without one the shell cannot tell an app showing its question from a hung one. So the app declares itself up front (AsksBeforeClose) and answers every ask (CloseRefused or Close).
  • Undeclared apps keep today's path. No ~1 s lag for apps that never learned the new words. A Terminal built from an older makepad closes as before: jobs are not protected, but nothing regresses.
  • An asking app is never killed. CloseRefused has no deadline. Every new close asks again: a live Terminal re-answers at once, a frozen one does not and falls to the timeout or to a second close. So a hung app is always closable, and an app visibly showing its question never is.
  • 5 s timeout. It only catches apps that promised to answer and did not. A short timeout could kill a slow but live Terminal before its prompt shows, which is the very loss this protects against. A person who does not want to wait can close again after 0.5 s.
  • Compatibility. The words are matched on their exact wire text, so this works with the current pin (bcb0583b) before makepad#68 lands. on_wm_request gains an #[allow(unreachable_patterns)] _ => {} arm, so the repin to makepad#68 compiles unchanged.

Found on the way: under the hosted runtime cx.quit() does nothing, so a Terminal that says yes cannot end itself. makepad#68 makes it send WmRequest::Close. With the current pin, a confirmed close falls to the 5 s timeout; this was seen end to end before that fix.

Tests

  • cd phone && cargo test --locked --features mobile-apps -p octosense-shell: 607 passed. 12 are new in process_close: acks and exits, refuses and stays, never acks and times out, second close forces, a repeat within 0.5 s waits, an asking app is re-asked, shell quit waits for every app, quit waits on modules and apps together, a no holds the quit and a close abandons it, a hung app under quit, an undeclared app keeps the legacy path, wire parsing.
  • cargo check --locked -p octosense: clean. python3 tools/setup.py --check --cargo: passes.

End to end

A hidden release desktop with temporary OCTOSENSE_HOME, OCTOSENSE_APP_DATA and OCTOS_APP_CORE_DIR, MAKEPAD_REMOTE, and a process Terminal (the default hosting). makepad#68 was applied to .sources/makepad for the run and restored afterwards. Driven by remote input only; every step passed.

  1. sleep 300, close the tile: the Terminal's bar asks Closing the terminal ends "sleep". Close anyway? Esc, then 7 s (past the timeout): the tile, the Terminal and sleep are all still there.
  2. /quit: the log says quit waits: … apps [1] are answering and the prompt shows. After 7 s the shell, the Terminal and sleep are still running. Esc.
  3. Close + Enter: the Terminal and sleep are gone in about 0.5 s. The shell keeps running, because the close dropped the waiting quit.
  4. A stuck Terminal (SIGSTOP): the first close only asks. The second close ends it (closed again before it answered; ending it).
  5. A stuck Terminal left alone: ended after 6.8 s (did not answer its close in 5s: the 5 s timeout plus the 1 s reaper tick).
  6. A Terminal with no job: closes with no prompt.
  7. /quit with a job-free Terminal: the shell exits in 0.07 s and no Terminal is left.

Screenshots are local to the test session (grabs of the hidden window): 01-sleep-running, 02-close-prompt, 03-after-esc, 04-quit-prompt, 05-close-again-prompt, 06-closed, 07-stuck-first-close, 08-stuck-forced.

Not verified: an undeclared process app (none ships on the macOS desktop besides the Terminal). Its path is the unchanged legacy code, covered by the unit test.

🤖 Generated with Claude Code

A process client's close sent CloseRequested and Kill back to back, so
closing a process Terminal's tile (the macOS default hosting) still
ended its running jobs without asking. Stacked on #186, which did the
same for in-process modules.

- process_close.rs: a pure close state machine per process client.
  An app that says WmRequest::AsksBeforeClose (makepad
  feat/wm-close-refused) is asked with CloseRequested alone; its tile
  and process stay. Its answer is its own WmRequest::Close (yes: closed
  at once, without asking back), CloseRefused (it is asking the person:
  no deadline, the tile comes to the front) or an exit.
- Fallback for an app that does not answer: a second close of the same
  tile while the first is unanswered ends it at once (a repeat within
  0.5 s is the same click); left alone it is ended after 5 s. A refusal
  is never a licence to hang: every close asks again, a live app
  re-answers at once, a frozen one falls to the fallback. So an app that
  is visibly asking is never killed, and a hung one is always closable.
- An app that never declared closes exactly as before (CloseRequested +
  Kill, the tile leaves at once), so apps without the new words, and a
  Terminal from an older makepad, lose nothing.
- Shell quit (menu, Cmd+Q, the window's close, /quit) asks declared
  apps the same way and waits while any module or app is deciding;
  it goes when the last says yes. A second quit forces unanswered apps.
  A termination signal or a crash still asks nobody.
- The words are read off the wire by their exact text, so this works
  with the current makepad pin; on_wm_request gains a catch-all arm so
  a repin to the new variants compiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ymote
ymote changed the base branch from feat/hosted-close-confirm to main September 30, 2026 05:46
@ymote
ymote merged commit 57f4f61 into main Sep 30, 2026
5 checks passed
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