Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions docs/superpowers/plans/2026-08-10-windows-exit-cleanup-watchdog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Windows Exit Cleanup Watchdog Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Ensure normal exit cleanup is not interrupted by the old three-second forced termination timer.

**Architecture:** A common helper owns the cancellable watchdog timer. `SystemAction.handleExit` supplies its existing cleanup closure, a ten-second timeout, and the forced-exit logging callback. Its existing final exit remains the only normal completion path.

**Tech Stack:** Dart, Flutter test.

## Global Constraints

- Preserve existing cleanup ordering and platform-specific conditions.
- Do not remove the hard fallback for a hung cleanup.
- Add tests before production code.

---

### Task 1: Testable cleanup watchdog

**Files:**
- Create: `lib/common/exit_cleanup.dart`
- Modify: `lib/common/common.dart`
- Create: `test/common/exit_cleanup_test.dart`

**Interfaces:**
- Produces: `Future<void> runExitCleanupWithWatchdog({required Future<void> Function() cleanup, required Duration timeout, required void Function() onTimeout})`

- [ ] **Step 1: Write failing tests**

```dart
await runExitCleanupWithWatchdog(
cleanup: () async {},
timeout: const Duration(milliseconds: 10),
onTimeout: () => timeoutCalls++,
);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(timeoutCalls, 0);
```

Also test that an error from `cleanup` cancels the timer, and that an unfinished cleanup invokes `onTimeout` once.

- [ ] **Step 2: Verify tests fail**

Run: `flutter test test/common/exit_cleanup_test.dart`
Expected: compile failure because `runExitCleanupWithWatchdog` does not exist.

- [ ] **Step 3: Implement minimal helper**

```dart
final timer = Timer(timeout, onTimeout);
try {
await cleanup();
} finally {
timer.cancel();
}
```

- [ ] **Step 4: Verify tests pass**

Run: `flutter test test/common/exit_cleanup_test.dart`
Expected: PASS.

### Task 2: Use watchdog for application exit

**Files:**
- Modify: `lib/providers/action.dart:675-699`
- Test: `test/common/exit_cleanup_test.dart`

**Interfaces:**
- Consumes: `runExitCleanupWithWatchdog` from Task 1.

- [ ] **Step 1: Replace uncancellable delayed exit**

Wrap the existing `handleExit` cleanup body in `runExitCleanupWithWatchdog` with a ten-second timeout. Its timeout callback logs a warning through `commonPrint` and calls `system.exit()`.

- [ ] **Step 2: Preserve normal final exit**

Keep the current `finally { system.exit(); }` so successful cleanup and cleanup failures retain the existing application termination behaviour.

- [ ] **Step 3: Verify targeted tests and analysis**

Run: `flutter test test/common/exit_cleanup_test.dart && flutter analyze lib/common/exit_cleanup.dart lib/common/common.dart lib/providers/action.dart test/common/exit_cleanup_test.dart`
Expected: PASS with no diagnostics.

### Task 3: Full regression verification

**Files:**
- No additional files.

- [ ] **Step 1: Run the full test suite**

Run: `flutter test`
Expected: PASS.

- [ ] **Step 2: Commit the reviewed change**

Run: `git add -f docs/superpowers/specs/2026-08-10-windows-exit-cleanup-watchdog-design.md docs/superpowers/plans/2026-08-10-windows-exit-cleanup-watchdog.md && git add lib/common/exit_cleanup.dart lib/common/common.dart lib/providers/action.dart test/common/exit_cleanup_test.dart && git commit -m "fix: wait for exit cleanup before forced termination"`
Expected: one focused commit.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Windows Exit Cleanup Watchdog Design

## Goal

Prevent the application from terminating a normal desktop exit while its proxy, tray, window, core process, and macOS IP-forwarding cleanup is still running.

## Root Cause

`SystemAction.handleExit` starts an uncancellable three-second delayed call to `system.exit()` before asynchronous cleanup. The core-helper shutdown alone may consume two seconds, so normal Windows cleanup can exceed the remaining budget and be cut off midway.

## Chosen Design

Add a small common helper, `runExitCleanupWithWatchdog`, that starts a cancellable `Timer`, runs a supplied cleanup operation, and always cancels that timer in `finally`. `SystemAction.handleExit` will retain its existing cleanup order and its final `system.exit()` call, but run that cleanup through the helper with a ten-second watchdog. If the watchdog expires, it logs a warning and performs the existing forced process exit.

## Alternatives Rejected

1. Only increase the old delay from three to ten seconds. This leaves a redundant delayed forced-exit callback alive after successful cleanup.
2. Remove forced exit entirely. This could leave the application stuck forever if a platform cleanup call hangs.

## Behaviour and Safety

- Successful cleanup: cancel the watchdog before the final normal exit.
- Cleanup failure: cancel the watchdog before propagating to the existing final normal exit.
- Cleanup never returns: force exit after ten seconds and emit a warning suitable for logs.
- macOS IP-forwarding restoration and every existing cleanup call retain their order and conditions.

## Verification

Unit tests cover successful cleanup, failed cleanup, and a genuinely delayed cleanup. The full Flutter suite, static analysis, and all four platform CI builds must pass before the issue is closed.
1 change: 1 addition & 0 deletions lib/common/common.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export 'constant.dart';
export 'context.dart';
export 'converter.dart';
export 'datetime.dart';
export 'exit_cleanup.dart';
export 'file.dart';
export 'font.dart';
export 'fixed.dart';
Expand Down
14 changes: 14 additions & 0 deletions lib/common/exit_cleanup.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import 'dart:async';

Future<void> runExitCleanupWithWatchdog({
required Future<void> Function() cleanup,
required Duration timeout,
required void Function() onTimeout,
}) async {
final timer = Timer(timeout, onTimeout);
try {
await cleanup();
} finally {
timer.cancel();
}
}
39 changes: 24 additions & 15 deletions lib/providers/action.dart
Original file line number Diff line number Diff line change
Expand Up @@ -673,22 +673,31 @@ class SystemAction extends _$SystemAction {
}

Future<void> handleExit([bool needSave = false]) async {
Future.delayed(const Duration(seconds: 3), () {
system.exit();
});
try {
if (system.isMacOS) {
await ref.read(coreActionProvider.notifier).setIpForwarding(false);
}
await Future.wait([
if (needSave) preferences.saveConfig(ref.read(configProvider)),
if (macOS != null) macOS!.updateDns(true),
if (proxy != null) proxy!.stopProxy(),
if (tray != null) tray!.destroy(),
]);
await window?.close();
await coreController.destroy();
commonPrint.log('exit');
await runExitCleanupWithWatchdog(
timeout: const Duration(seconds: 10),
onTimeout: () {
commonPrint.log(
'Exit cleanup timed out after 10 seconds; forcing application exit.',
logLevel: LogLevel.warning,
);
system.exit();
},
cleanup: () async {
if (system.isMacOS) {
await ref.read(coreActionProvider.notifier).setIpForwarding(false);
}
await Future.wait([
if (needSave) preferences.saveConfig(ref.read(configProvider)),
if (macOS != null) macOS!.updateDns(true),
if (proxy != null) proxy!.stopProxy(),
if (tray != null) tray!.destroy(),
]);
await window?.close();
await coreController.destroy();
commonPrint.log('exit');
},
);
} finally {
system.exit();
}
Expand Down
57 changes: 57 additions & 0 deletions test/common/exit_cleanup_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import 'dart:async';

import 'package:fl_clash/common/exit_cleanup.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
group('runExitCleanupWithWatchdog', () {
test('cancels the fallback after cleanup succeeds', () async {
var timeoutCalls = 0;

await runExitCleanupWithWatchdog(
cleanup: () async {},
timeout: const Duration(milliseconds: 20),
onTimeout: () => timeoutCalls++,
);
await Future<void>.delayed(const Duration(milliseconds: 40));

expect(timeoutCalls, 0);
});

test('cancels the fallback after cleanup fails', () async {
var timeoutCalls = 0;

await expectLater(
runExitCleanupWithWatchdog(
cleanup: () async => throw StateError('cleanup failed'),
timeout: const Duration(milliseconds: 20),
onTimeout: () => timeoutCalls++,
),
throwsStateError,
);
await Future<void>.delayed(const Duration(milliseconds: 40));

expect(timeoutCalls, 0);
});

test('runs the fallback once when cleanup does not finish', () async {
var timeoutCalls = 0;
final cleanupCompleter = Completer<void>();

final cleanup = runExitCleanupWithWatchdog(
cleanup: () => cleanupCompleter.future,
timeout: const Duration(milliseconds: 20),
onTimeout: () => timeoutCalls++,
);
await Future<void>.delayed(const Duration(milliseconds: 40));

expect(timeoutCalls, 1);

cleanupCompleter.complete();
await cleanup;
await Future<void>.delayed(const Duration(milliseconds: 40));

expect(timeoutCalls, 1);
});
});
}
Loading