Skip to content

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.

$ sample-task
Simulate UI running

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.

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.

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.

CauseAppExit.signalConventional exit code
exitApp()null0 for successful completion
Unhandled Ctrl+C or SIGINTAppSignal.interrupt130
SIGTERMAppSignal.terminate143
Terminal hangup / SIGHUPAppSignal.hangup129

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,
};

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.

From a Fleury checkout:

Terminal window
cd website/examples
dart run doc_snippets/shutdown/signals.dart
dart run doc_snippets/shutdown/finish_work.dart

Each 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.