Skip to content

fix: resolve SIGALRM longjmping out of the signal handler - #933

Closed
georglauterbach wants to merge 1 commit into
HalFrgrd:masterfrom
georglauterbach:fix/reentrancy-and-tmout
Closed

fix: resolve SIGALRM longjmping out of the signal handler #933
georglauterbach wants to merge 1 commit into
HalFrgrd:masterfrom
georglauterbach:fix/reentrancy-and-tmout

Conversation

@georglauterbach

Copy link
Copy Markdown
Contributor

As discussed in #931, here are two prerequisites that fix issues concerning re-entrancy and TMOUT. The comments are kept concise; I checked what was written and I think it's appropriate given the fact that the logic is not easy to grok.

ref #852

ref #931

@HalFrgrd

HalFrgrd commented Aug 16, 2026

Copy link
Copy Markdown
Owner

My understanding for the need for reentrancy is so that bash can run a flyline ... command while flyline get char is running?

If that is correct, I think a much cleaner approach would be to move settings into a standalone, global, concurrently available object. flyline_call_command only requires access to the settings struct. If we moved settings out of the Flyline struct, provided a convenient settings() function in lib.rs, then I think that would solve reentrancy cleanly.

@HalFrgrd

Copy link
Copy Markdown
Owner

Do you mind explain what the SIGALRM stuff is about? I saw that it is mentioned in the claude report you commented, but I didn't read thousands of claude written words. I'd appreciate hearing from you a concise idea of why it's needed for your goal. I thought you were interested in handling SIGUSR2?

@georglauterbach

georglauterbach commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

My understanding for the need for reentrancy is so that bash can run a flyline ... command while flyline get char is running?

That is correct.

If that is correct, I think a much cleaner approach would be to move settings into a standalone, global, concurrently available object.

That would also be an approach, although I have not checked the pros and cons here myself. The current design works, but if you think a global object would be cleaner I could also give it a shot. I have just factored out what I had from #931 as promised. IIRC there was no mention yet of such a refactoring.

Do you mind explain what the SIGALRM stuff is about?

SIGALRM is the one signal whose Bash handler does not return. Hence, it breaks signal handling while the app is running (because alrm_catcher never returns to the interrupted code, as it longjmps out of the handler, so the app never gets the chance to notice the signal).

This is not intrinsically coupled to SIGUSR2, but I'd argue it'd be good to have when flyline handles signals (otherwise SIGALRM will cause problems down the line).

@HalFrgrd

Copy link
Copy Markdown
Owner

Yes please to moving settings into a global var. I think it will be a lot cleaner. If anything make Flyline reentrant is wrong. We would never want to have two App instances running at once. I didn't review #931 fully. There was a lot of complexity so I didnt do an in depth review.

@HalFrgrd

HalFrgrd commented Aug 16, 2026

Copy link
Copy Markdown
Owner

I think lets split this PR into two. the settings reentrancy part and the sigalrm part. I don't see a reason for them to be together.

@georglauterbach

Copy link
Copy Markdown
Contributor Author

I'll drop the reentrancy and only keep the SIGALRM part here for now. Then open another PR for the global object.

With TMOUT set, Bash arms alarm(TMOUT) around the read and points SIGALRM at
alrm_catcher, which longjmps straight out of the signal handler. Delivered
while the app owns the terminal, that unwinds past every Rust frame in
App::run: no destructor runs, the inline viewport is left half-drawn, and the
user is logged out with the terminal still in raw mode.

Divert SIGALRM to a handler that only records the delivery for as long as the
terminal is in raw mode, exit the main loop when it fires, and re-raise once
the terminal is restored and Bash's disposition is back. alrm_catcher then
logs out from a clean terminal; a user SIGALRM trap is queued and Bash runs it
from parse_command after we return the empty line.

The disposition is saved and restored as a full struct sigaction, like
SigchldGuard does, so Bash keeps its sa_mask and flags.
@georglauterbach
georglauterbach force-pushed the fix/reentrancy-and-tmout branch from 2436e56 to 2c3f1ab Compare August 16, 2026 14:03
@georglauterbach georglauterbach changed the title fix: resolve re-entrancy and tmout issues fix: resolve SIGALRM longjmping out of the signal handler Aug 16, 2026
@georglauterbach

Copy link
Copy Markdown
Contributor Author

#935 is now concerned with refactoring the global settings

@HalFrgrd

Copy link
Copy Markdown
Owner

Im going to investigate how flyline should handle signals. There is already some signal handling logic around sigchld so I want a consolidated approach.

@HalFrgrd

Copy link
Copy Markdown
Owner

I found a nice way for flyline to handle traps: b3ab32a

@georglauterbach

georglauterbach commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

I found a nice way for flyline to handle traps: b3ab32a

I'm not 100% what b3ab32a already solves, but I guess #935 is still required? Here is the current state:

screenrecord.mp4

The theme dark sends SIGUSR2.

HalFrgrd added a commit that referenced this pull request Aug 16, 2026
This change resolves a deadlock that would occur when calling `flyline`
from a signal handler (e.g. for `SIGUSR2` or `SIGALRM`) that flyline
itself executes (i.e. flyline handles the signal itself).

---

- ref #852
- ref #931
- ref #933

---

<details>
<summary>Agent Plan</summary>

# Suggested Plan

## Global settings object to fix builtin re-entrancy

### Verification of HalFrgrd's claim

Confirmed, with one caveat worth stating in the PR.

- `Flyline::call` (`src/cli.rs` (`src/cli.rs#L997`)) touches only
self.settings — `rg --pcre2 'self\.(?!settings\b)' src/cli.rs` finds
nothing. So moving settings out is sufficient to make
`flyline_call_command` independent of `FLYLINE_INSTANCE_PTR`.
- Caveat: a global behind a lock does not fix anything. App would hold
that lock for the whole session and the re-entrant builtin would block
on it exactly as it blocks on `FLYLINE_INSTANCE_PTR` today. The global
must hand out a borrow per access, never a guard held across a call into
Bash's evaluator.
- Corroboration that same-thread re-entry is already an accepted reality
here: `BASH_LOCK` is a `parking_lot::ReentrantMutex`
(`src/shell/bash/symbols.rs` (`src/shell/bash/symbols.rs#L462`)) for
this exact reason.
- Done per-access, this is strictly sounder than the dropped commit: the
app holds a zero-sized handle, not a &mut Settings, so during re-entry
there is no live borrow to alias. The thread-local pointer publish had
two overlapping `&mut Flyline`.
- Flyline never spawns threads (`git grep 'thread::spawn' origin/master
-- src crates` is empty; `subshell_ipc::spawn_subshell` forks), so a
lock buys nothing but deadlocks.

### Where re-entry happens

```txt
┌───────────────────────────────────────┐
│                                       │
│                                       │
│            flyline_get_char           │
│       locks FLYLINE_INSTANCE_PTR      │
│                                       │
└───────────────────┬───────────────────┘
                    ▼
┌───────────────────────────────────────┐
│                                       │
│                App::run               │
│                                       │
└───────────────────┬───────────────────┘
                    ▼
┌───────────────────────────────────────┐
│                                       │
│ evaluate_shell_string / decode_prompt │
│                                       │
└───────────────────┬───────────────────┘
                    ▼
┌───────────────────────────────────────┐
│                                       │
│     Bash evaluator runs user code     │
│                                       │
└───────────────────┬───────────────────┘
                    ▼
┌───────────────────────────────────────┐
│                                       │
│                                       │
│            flyline builtin            ├─────────────────┐
│          flyline_call_command         │     after: settings() handle
│                                       │                 │
└──────today:─locks─the─same─mutex──────┘                 │
                    ▼                                     ▼
┌───────────────────────────────────────┐   ┌───────────────────────────┐
│                                       │   │                           │
│                deadlock               │   │ mutates settings, returns │
│                                       │   │                           │
└───────────────────────────────────────┘   └───────────────────────────┘
```

Three evaluator entry points: `src/app/mod.rs:1213`
(`src/app/mod.rs#L1213`) (`run_bash_command`), `src/app/mod.rs:1707`
(`src/app/mod.rs#L1707`) (`flycomp` script), and prompt expansion via
decode_prompt. `flyline_unget_char` is not reachable during eval because
`evalstring` pushes its own input stream.

## Design

A lock-free global plus a zero-sized handle that derefs to it. The
handle is what keeps the diff small: `App.settings` changes type but all
~136 `self.settings.foo` sites compile unchanged through
`Deref`/`DerefMut`, including ones that hand out borrows like
`src/app/mod.rs:693` (`src/app/mod.rs#L693`) (-> &mut
HistoryManager), which a closure- or guard-based API cannot express.
In `src/settings.rs` (`src/settings.rs`), next to Settings:

```rust
struct GlobalSettings(std::cell::UnsafeCell<Settings>);
// SAFETY: only ever touched from Bash's main thread; flyline spawns no threads
// and `spawn_subshell` forks. A lock here would deadlock rather than serialise,
// because Bash re-enters the `flyline` builtin on that same thread.
unsafe impl Sync for GlobalSettings {}

static GLOBAL_SETTINGS: std::sync::LazyLock<GlobalSettings> = ...;

/// Handle to the process-wide [`Settings`]. Zero-sized, and materialises a
/// borrow only for the duration of each access, so a `flyline set-style` that
/// re-enters while `App` holds one of these does not alias a live borrow.
/// A borrow derived from a handle MUST NOT be held across a call into Bash's
/// evaluator (`evaluate_shell_string`, `decode_prompt`) -- that is where the
/// re-entry lands.
#[derive(Clone, Copy, Debug, Default)]
pub struct SettingsRef;

impl Deref for SettingsRef { /* unsafe { &*GLOBAL_SETTINGS.0.get() } */ }
impl DerefMut for SettingsRef { /* ... */ }

pub fn settings() -> SettingsRef { SettingsRef }
```

`pub(crate) use settings::settings;` in `src/lib.rs` gives
`crate::settings()` as requested; the module and the function occupy
different namespaces, so crate::settings::Settings keeps working.
I audited the three evaluator sites for the "no live borrow" invariant:
`run_bash_command` holds none; `poll_flycomp`'s `output_dir()` borrow
ends on the line before the eval; the
`get_ps1_lines(self.settings.show_animations, ...)` read is a bool copy
whose borrow NLL ends before the call.

## Edits

- `src/settings.rs`: add the global, SettingsRef, settings(), and the
unit test below.
- `src/lib.rs`: drop settings from Flyline (keeps content/position); in
`Flyline::get`, open with `let mut settings = crate::settings();` and
rewrite `self.settings.` to `settings.`; `app::get_command()` loses its
argument; `flyline_call_command` becomes `catch_unwind_safe(||
cli::call(words))` and stops
locking `FLYLINE_INSTANCE_PTR`; reset the global in `setup_bash_input`
where `Flyline::new()` is stored, so enable -d / enable -f still starts
from defaults.
- `src/cli.rs`: turn `impl Flyline { fn call(&mut self, words) }` into a
free `pub(crate) fn call(words) -> c_int` opening with `let mut settings
= crate::settings();`, and rewrite the 58 `self.settings.` occurrences
to `settings.`. Watch the `settings::AgentModeCommand` paths —
multi-segment paths
resolve in the type namespace, so the local does not shadow the module,
but confirm at compile time.
- `src/app/mod.rs`: `get_command()` and `App::new()` lose the parameter
(`App::new` opens with `let mut settings = crate::settings();`, body
otherwise unchanged); `struct App<'a>` becomes `struct App`; field
becomes `settings: SettingsRef`.
- Drop the now-unused lifetime in four more impl headers:
`src/app/ui.rs:115`, `src/app/auto_close.rs:104`
(src/app/auto_close.rs#L104), src/app/actions/keyboard.rs:3296
(src/app/actions/keyboard.rs#L3296),
src/completions/tab_completion.rs:1078
  (`src/completions/tab_completion.rs#L1078`).

Everything else that reads settings stays byte-identical, including
&self.settings passed to &Settings parameters (deref coercion) and
show_settings(&settings, all).

## Check

One unit test in src/settings.rs (src/settings.rs), the smallest thing
that fails if the design regresses — it deadlocks if a lock reappears in
settings() and asserts if the handle ever starts copying:

```rust
/// A re-entrant builtin call must reach the same settings the app is holding.
/// The only test that touches the global, so it cannot race the others.
#[test]
fn reentrant_handles_share_one_settings_instance() {
    let mut app_view = settings();  // stands in for `App::settings`
    app_view.frame_rate = 11;
    settings().frame_rate = 30;     // stands in for `flyline --frame-rate 30`
    assert_eq!(app_view.frame_rate, 30);
}
```

Then cargo fmt, cargo clippy --quiet --bins --tests --benches --no-deps
-- -D warnings, cargo test --lib.
Manual, on bash 5.2.21 with cargo build and enable -f
target/debug/libflyline.so flyline:
- In-process re-entry: flyline key bind Ctrl+g
'always=runBashCommand("flyline --frame-rate 5")', then Ctrl+g in the
app. Hangs the shell today; must return and take effect (flyline
settings shows frame_rate: 5).
- Forked re-entry, no keystrokes needed: PS1='$(flyline --version)\$ '.
The command-substitution child inherits the locked mutex today and
hangs; afterwards the prompt renders the version.
- No regression: flyline --frame-rate 30 && flyline settings | grep
frame_rate, settings still survive across builtin calls and the app
honours them.

I will not run the Docker matrix (tests/docker_integration_tests.rs);
nothing here touches Bash symbols or version gating.

</details>

---------

Signed-off-by: Georg Lauterbach <44545919+georglauterbach@users.noreply.github.com>
Co-authored-by: Hal Frigaard <4559349+HalFrgrd@users.noreply.github.com>
@HalFrgrd

Copy link
Copy Markdown
Owner

Yes exactly. b3ab32a means that flyline will try and run the hooks / traps. But if you run a trap that tries to access the Flyline struct, it will deadlock because Flyline struct is not reentrant. With #935, we the cli call flyilne ... no longer tries to access the Flyline struct, so there is deadlock anymore!

Now it should work! demo:

demo

Note that I merged 3903eed to get this working.

@HalFrgrd

Copy link
Copy Markdown
Owner

I think this PR is no longer needed. If the user wants to trap ALRM, they can with e.g. trap 'echo foo\n\n\n' ALRM and flyline will call the handler.

@georglauterbach

Copy link
Copy Markdown
Contributor Author

Awesome!

@georglauterbach
georglauterbach deleted the fix/reentrancy-and-tmout branch August 16, 2026 20:43
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