Shutdown & signals
runApp starts the UI. exitApp() finishes it. After Fleury restores the
terminal, your command continues with cleanup, output, or another UI.
Choose Finish, then run again and simulate Ctrl+C. Both close the UI, but only the interrupt produces exit code 130. Switch to Finish work first to keep the UI visible while the current item finishes.
The widget and its disposal are real Fleury. The browser simulates signal delivery and command output; it does not send operating-system signals or own a terminal. The examples below run natively. All sample work stays in memory.
Finish the UI
Section titled “Finish the UI”Call exitApp() from an action, then print after awaiting runApp:
import 'package:fleury/fleury.dart';
Future<void> main() async { await runApp( FleuryApp( title: 'One step', home: Button(text: 'Done', onPressed: () => exitApp()), ), mode: const TerminalMode.inline(rows: 5, mouse: true), enableHotReload: false, ); print('Back in the command.');}exitApp() starts orderly shutdown in either screen mode, leaving the Dart
process running. Await runApp for terminal restoration to finish before
continuing your command. Calling exitApp() again while it is stopping is safe.
Clean up and preserve the exit status
Section titled “Clean up and preserve the exit status”Most commands can use Fleury’s default interrupt handling. Keep resources owned
by the command in try/finally, and use AppExit.signal to choose the process
exit code:
import 'dart:io';import 'package:fleury/fleury.dart';import 'demo_work.dart';
Future<void> main() async { final work = DemoWork(); print('Sample task · PID $pid'); try { final result = await runApp( FleuryApp( title: 'Sample task', home: ShutdownPanel(work: work, onFinish: () => exitApp()), ), mode: const TerminalMode.inline(rows: 7, mouse: true), enableHotReload: false, ); exitCode = signalExitCode(result.signal); } finally { await work.close(); } print('Resources closed. Exit code: $exitCode');}Fleury disposes its widget tree and restores the terminal before runApp
completes. Your finally closes resources you created outside that tree. Set
Dart’s exitCode and let main return so this cleanup can finish.
The samples pass enableHotReload: false so that main() runs in one process.
Under a plain dart run with hot reload on, main() runs twice: the first
process becomes the hot-reload supervisor, and its call to runApp never
returns. That process ends with exit(), so its finally never runs, and
nothing it created before runApp is cleaned up. It also prints a PID that
isn’t the UI’s. See
Keep startup work inside the app.
| Cause | AppExit.signal | Conventional exit code |
|---|---|---|
exitApp() | null | 0 for successful completion |
| Unhandled Ctrl+C or SIGINT | AppSignal.interrupt | 130 |
| SIGTERM | AppSignal.terminate | 143 |
| Terminal hangup / SIGHUP | AppSignal.hangup | 129 |
signalExitCode in the sample implements this mapping. An orderly UI exit does
not decide whether your command succeeded: validation errors or cancellation
may need a different status. If runApp throws, the same finally still runs.
View the shared sample widget, work, and exit-code mapping
import 'package:fleury/fleury_core.dart';
// A finite, in-memory task for this example. No files or network requests.class DemoWork extends Notifier { Future<void>? _finishing; bool finished = false; bool _closed = false; bool get finishing => _finishing != null;
Future<void> finish() => _finishing ??= _finish();
Future<void> _finish() async { // Notify after finish() has assigned the shared future. await Future<void>.delayed(Duration.zero); if (_closed) return; notify(); await Future<void>.delayed(const Duration(milliseconds: 900)); finished = true; if (!_closed) notify(); }
Future<void> close() async { _closed = true; await _finishing; dispose(); }}
class ShutdownPanel extends StatelessWidget { const ShutdownPanel({required this.work, required this.onFinish, super.key}); final DemoWork work; final void Function() onFinish;
@override Widget build(BuildContext context) => NotifierBuilder( notifier: work, builder: (context, work) => Padding( padding: const EdgeInsets.all(1), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(work.finishing ? 'Finishing current item…' : 'Sample task'), const SizedBox(height: 1), Button( text: 'Finish', autofocus: true, onPressed: work.finishing ? null : onFinish, ), ], ), ), );}
int signalExitCode(AppSignal? signal) => switch (signal) { AppSignal.interrupt => 130, AppSignal.terminate => 143, AppSignal.hangup => 129, null => 0,};Finish work before closing
Section titled “Finish work before closing”Use this when the UI should show a final operation, such as finishing a pending
write. Claim the interrupt, stop accepting new work, then call exitApp() when
the operation finishes.
There are two inputs to handle. In raw terminal mode, Ctrl+C is a key event;
a widget’s KeyBinding can claim it. An operating-system signal arrives as a
SignalEvent; return EventHandled from onEvent to claim it. This example
routes both through one shutdown operation:
import 'dart:async';import 'dart:io';import 'package:fleury/fleury.dart';import 'demo_work.dart';
Future<void> main() async { final work = DemoWork(); AppSignal? interruptedBy; Future<void>? stopping;
Future<void> finish() => stopping ??= () async { try { await work.finish(); } finally { exitApp(); } }();
void interrupt(AppSignal signal) { interruptedBy ??= signal; unawaited(finish()); }
print('Sample task · PID $pid'); try { final result = await runApp( FleuryApp( title: 'Sample task', home: KeyBindings( bindings: [ KeyBinding( KeySequence.ctrl.c, onTrigger: (_) => interrupt(AppSignal.interrupt), ), ], child: ShutdownPanel(work: work, onFinish: () => unawaited(finish())), ), ), onEvent: (event) { if (event is SignalEvent) { interrupt(event.signal); return const EventHandled(); } return null; }, mode: const TerminalMode.inline(rows: 7, mouse: true), enableHotReload: false, ); exitCode = signalExitCode(interruptedBy ?? result.signal); } finally { await work.close(); } print('Resources closed. Exit code: $exitCode');}The shared stopping future makes repeated actions reuse the same work. The
button becomes unavailable while finishing. interruptedBy preserves the
original signal because the later exitApp() produces an orderly AppExit,
without a signal of its own.
Keep signal cleanup bounded. The native POSIX driver allows five seconds from signal delivery by default; a repeated instance of the same pending OS signal forces an immediate exit. On that forced path, Fleury attempts terminal restoration, but application cleanup may not finish. A Ctrl+C binding you claim is application-owned input and does not start that OS-signal deadline.
Put ordinary resource cleanup after runApp when you don’t need a visible
shutdown step. The signal deadline ends when the driver restores the terminal;
it is not a timeout for the rest of your command. No shutdown hook can promise
cleanup after SIGKILL or loss of power.
Try real signals
Section titled “Try real signals”From a Fleury checkout:
cd website/examplesdart run doc_snippets/shutdown/signals.dartdart run doc_snippets/shutdown/finish_work.dartEach command prints its PID before opening the UI. Press Ctrl+C, or send
kill -TERM <PID> from another terminal. The first example closes immediately;
the second displays Finishing current item… before closing. Both preserve
the signal’s exit status: 130 after Ctrl+C, 143 after kill -TERM.
Use echo $status in fish or echo $? in a POSIX shell to inspect it.
Next steps
Section titled “Next steps”- Full-screen and inline UIs shows how each mode returns to the shell and how to open another UI.
- Key handling explains shortcut routing and focused controls.
- App entry points separates framework-owned services from your application’s resources.