중복 실행 막기 (228): 데이터 폴더의 잠금 파일 (instance.lock, 핸들 단위) · 포트 0 + 비밀값 · 응답 없으면 묻기

관리자 검토 (10/12): 이름 있는 뮤텍스는 쥔 OS 스레드가 끝나면 버려진 상태가 되어 다음 실행이 첫 실행으로
판단할 수 있다 → 데이터 폴더의 instance.lock 을 파일 핸들 잠금 (LockFileEx) 으로 앱이 끝날 때까지 쥔다.
스레드와 상관없고 프로세스가 죽으면 Windows 가 푼다. GetLastError · FFI 를 쓰지 않는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tor_dns
RyuJongJin 2026-10-12 01:26:59 +09:00
parent 620cd63e34
commit 893f6759ff
6 changed files with 277 additions and 49 deletions

View File

@ -1603,6 +1603,7 @@
"이미 동영상 목록에 있습니다 ({0}개).": "Already in the video list ({0}).",
"이미 목록에 있는 동영상입니다 ({0}개).": "These videos are already in the list ({0}).",
"이미 받는 중이거나 받을 수 없는 주소입니다.": "Already downloading or a URL that cannot be downloaded.",
"이미 실행 중인 창이 응답하지 않습니다.\n\n[예] 그래도 열기 - 같은 데이터 위에 창이 두 개가 되어 설정 · 목록이 서로 덮어쓸 수 있습니다.\n[아니요] 닫기 - 응답하지 않는 창을 작업 관리자에서 끝낸 뒤 다시 실행하세요.": "A window that is already running is not responding.\n\n[Yes] Open anyway - two windows will share the same data, and settings or lists may overwrite each other.\n[No] Close - end the unresponsive window in Task Manager, then run the app again.",
"이미 이 폴더에 있습니다": "Already in this folder",
"이미 이 폴더에 있습니다: {0}": "Already in this folder: {0}",
"이미 이 폴더에 있어 건너뜀: {0}": "Skipped, already in this folder: {0}",
@ -1817,6 +1818,7 @@
"중국어": "Chinese",
"중국어 간체": "Chinese (Simplified)",
"중국어 번체": "Chinese (Traditional)",
"중복 실행 막기 정리": "Clean up duplicate-launch guard",
"중지": "Stop",
"즐겨찾기": "Bookmarks",
"즐겨찾기 HTML 가져오기": "Import bookmarks HTML",

View File

@ -1603,6 +1603,7 @@
"이미 동영상 목록에 있습니다 ({0}개).": "既に動画リストにあります({0}個)。",
"이미 목록에 있는 동영상입니다 ({0}개).": "既にリストにある動画です({0}個)。",
"이미 받는 중이거나 받을 수 없는 주소입니다.": "既にダウンロード中か、ダウンロードできない URL です。",
"이미 실행 중인 창이 응답하지 않습니다.\n\n[예] 그래도 열기 - 같은 데이터 위에 창이 두 개가 되어 설정 · 목록이 서로 덮어쓸 수 있습니다.\n[아니요] 닫기 - 응답하지 않는 창을 작업 관리자에서 끝낸 뒤 다시 실행하세요.": "すでに実行中のウィンドウが応答しません。\n\n[はい] それでも開く - 同じデータの上にウィンドウが 2 つになり、設定 · 一覧が互いに上書きされることがあります。\n[いいえ] 閉じる - 応答しないウィンドウをタスク マネージャーで終了してから、もう一度実行してください。",
"이미 이 폴더에 있습니다": "既にこのフォルダーにあります",
"이미 이 폴더에 있습니다: {0}": "すでにこのフォルダーにあります: {0}",
"이미 이 폴더에 있어 건너뜀: {0}": "既にこのフォルダーにあるためスキップ: {0}",
@ -1817,6 +1818,7 @@
"중국어": "中国語",
"중국어 간체": "中国語(簡体字)",
"중국어 번체": "中国語(繁体字)",
"중복 실행 막기 정리": "重複起動防止の後片付け",
"중지": "中止",
"즐겨찾기": "ブックマーク",
"즐겨찾기 HTML 가져오기": "ブックマーク HTML を取り込む",

View File

@ -1603,6 +1603,7 @@
"이미 동영상 목록에 있습니다 ({0}개).": "已在视频列表中({0} 个)。",
"이미 목록에 있는 동영상입니다 ({0}개).": "这些视频已在列表中({0} 个)。",
"이미 받는 중이거나 받을 수 없는 주소입니다.": "已在下载中,或该地址无法下载。",
"이미 실행 중인 창이 응답하지 않습니다.\n\n[예] 그래도 열기 - 같은 데이터 위에 창이 두 개가 되어 설정 · 목록이 서로 덮어쓸 수 있습니다.\n[아니요] 닫기 - 응답하지 않는 창을 작업 관리자에서 끝낸 뒤 다시 실행하세요.": "已在运行的窗口没有响应。\n\n[是] 仍然打开 - 同一数据上将有两个窗口,设置和列表可能互相覆盖。\n[否] 关闭 - 请在任务管理器中结束无响应的窗口后重新运行。",
"이미 이 폴더에 있습니다": "已在此文件夹中",
"이미 이 폴더에 있습니다: {0}": "已在此文件夹中: {0}",
"이미 이 폴더에 있어 건너뜀: {0}": "已在此文件夹中,已跳过: {0}",
@ -1817,6 +1818,7 @@
"중국어": "中文",
"중국어 간체": "简体中文",
"중국어 번체": "繁体中文",
"중복 실행 막기 정리": "清理防止重复运行",
"중지": "中止",
"즐겨찾기": "书签",
"즐겨찾기 HTML 가져오기": "导入书签 HTML",

View File

@ -92,10 +92,20 @@ Future<void> main(List<String> args) async {
// 이미 실행 중이면 인수(탐색기에서 고른 파일)를 넘기고 끝낸다
final request = LaunchRequest.parse(args);
final newWindow = wantsNewPlayerWindow(request, await SettingsStore().load());
// 시험판은 사용자가 켜 둔 앱에 넘기지 않고 따로 뜬다
final instance = SingleInstance(port: dataDirOverride == null ? 47821 : 47822);
if (!await instance.claim(request, waitForExit: args.contains('--restart'), forward: !newWindow)) {
final startSettings = await SettingsStore().load();
final newWindow = wantsNewPlayerWindow(request, startSettings);
// 228: 데이터 폴더마다 하나 (데이터 폴더의 잠금 파일) - 시험판 (따로 데이터) 은 사용자가 켜 둔 앱 · 다른 시험판에 넘기지 않고 따로 뜬다
final instance = SingleInstance(dataDir: dataDir);
final claimed = await instance.claim(request, waitForExit: args.contains('--restart'), forward: !newWindow);
if (claimed == ClaimResult.unresponsive) {
// 같은 데이터 위에 창이 두 개 뜨지 않게 묻는다 (처음 고른 것은 [닫기] - 관리자 결정 10/12)
await i18n.apply(startSettings.uiLanguage, save: false);
if (askOpenAnyway(appTitle)) {
await instance.takeOverAnyway(request);
} else {
endProcessNow();
}
} else if (claimed != ClaimResult.first) {
// 설정이 "새 창에서" 이고 이미 켜져 있으면: 재생만 하는 창으로 따로 뜬다
if (newWindow) return runSecondWindow(request.files.where(isVideoFile).toList(), dataDir);
endProcessNow(); // exit(0) 은 막 뜬 엔진을 정리하다 coremessaging.dll 에서 꺼진다
@ -195,6 +205,8 @@ Future<void> main(List<String> args) async {
() async => controller.cancel()),
ExitStep(tr('다운로드 정리 (yt-dlp · aria2 종료)'), downloads.shutdown),
ExitStep(tr('Tor 끄기'), () => TorService.instance.stop()),
// 228: 다음 실행이 이 창에 넘기려 하지 않게 (instance.json 지우기 · 잠금 파일 놓기)
ExitStep(tr('중복 실행 막기 정리'), instance.close),
ExitStep(tr('쓰레드 확인 (AI · 변환 작업이 멈출 때까지)'), () async {
while (controller.busy) {
await Future<void>.delayed(const Duration(milliseconds: 100));

View File

@ -1,8 +1,15 @@
import 'dart:async';
import 'dart:convert';
import 'dart:ffi';
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:ffi/ffi.dart';
import 'package:path/path.dart' as p;
import '../../core/playlist.dart';
import '../../l10n/tr.dart';
/// 이 프로세스를 바로 끝낸다. Dart 의 exit() 는 엔진 · 창 · 플러그인 DLL 을 정리하는 중에 Windows 의
/// coremessaging.dll 등에서 접근 위반 (0xc0000005) 으로 꺼지기도 한다 - 특히 막 뜬 두 번째 실행이 바로 끝날 때
@ -12,43 +19,91 @@ Never endProcessNow() {
exit(0);
}
/// 프로그램을 하나만 실행하고, 나중에 실행된 것은 인수를 첫 번째 창에 넘기고 끝낸다.
///
/// 탐색기에서 파일 여러 개를 골라 "재생" 하면 파일마다 프로그램이 실행되므로,
/// 짧은 시간([gather]) 안에 들어온 요청을 하나로 모아 전달한다.
class SingleInstance {
static const _magic = 'JJMKVMAKER1';
/// [SingleInstance.claim] 의 결과
enum ClaimResult {
/// 첫 실행 (요청을 받을 준비가 됨)
first,
final int port;
/// 이미 실행 중인 창에 넘겼다 (이 실행은 끝낸다)
forwarded,
/// 이미 실행 중인데 넘기지 않음 ([SingleInstance.claim] 의 forward 가 false - 새 재생 창 등)
running,
/// 이미 실행 중인 창이 있는데 응답하지 않는다 (사용자에게 물어 그래도 열지 정한다)
unresponsive,
}
/// 프로그램을 데이터 폴더마다 하나만 실행하고, 나중에 실행된 것은 인수를 첫 번째 창에 넘기고 끝낸다 (228).
///
/// - 첫 실행 판단: 데이터 폴더의 instance.lock 을 잠가 쥔다 (파일 핸들 단위 잠금) - 포트가 아니라 Windows 가 막아 둔 포트 범위
/// (Hyper-V · WSL) 나 다른 프로그램과 부딪히지 않고, 데이터 폴더가 다르면 (시험판 JJ_MKVMAKER_DATA) 따로 뜬다.
/// 프로세스가 끝나면 (죽어도) Windows 가 잠금을 푼다.
/// - 인수 넘기기: 첫 실행이 127.0.0.1 의 포트 0 (OS 가 고름) 에 열고, 포트 · 무작위 비밀값을 데이터 폴더의 instance.json 에 쓴다.
/// 나중 실행은 그 비밀값과 함께 보낸다 (같은 PC 의 다른 프로그램이 요청을 넣지 못하게). 비밀값은 작업 기록에 쓰지 않는다.
///
/// 탐색기에서 파일 여러 개를 골라 "재생" 하면 파일마다 프로그램이 실행되므로, 짧은 시간([gather]) 안에 들어온 요청을 하나로 모아 전달한다.
class SingleInstance {
static const _magic = 'JJMKVMAKER2';
final String dataDir;
final Duration gather;
ServerSocket? _server;
RandomAccessFile? _lock;
String? _token;
final _pending = <LaunchAction, List<String>>{};
Timer? _timer;
void Function(LaunchRequest)? _onRequest;
SingleInstance({this.port = 47821, this.gather = const Duration(milliseconds: 700)});
SingleInstance({required this.dataDir, this.gather = const Duration(milliseconds: 700)});
/// 첫 번째 실행이면 true (요청을 받을 준비). 이미 실행 중이면 인수를 넘기고 false.
/// 이 데이터 폴더의 잠금 파일 (첫 실행이 앱이 끝날 때까지 잠가 쥔다)
String get lockPath => p.join(dataDir, 'instance.lock');
File get _file => File(p.join(dataDir, 'instance.json'));
/// 첫 실행인지 정하고, 아니면 [initial] 을 첫 창에 넘긴다.
/// [waitForExit]: 재시작 - 기존 창이 닫힐 때까지 (최대 15초) 기다렸다가 첫 번째가 된다.
/// [forward] 가 false 면 이미 실행 중이어도 인수를 넘기지 않는다 (새 재생 창으로 따로 뜰 때).
Future<bool> claim(LaunchRequest initial, {bool waitForExit = false, bool forward = true}) async {
/// [forward] 가 false 면 이미 실행 중이어도 넘기지 않는다 (새 재생 창으로 따로 뜰 때).
Future<ClaimResult> claim(LaunchRequest initial, {bool waitForExit = false, bool forward = true}) async {
for (var i = 0;; i++) {
try {
_server = await ServerSocket.bind(InternetAddress.loopbackIPv4, port);
break;
} on SocketException {
if (waitForExit && i < 60) {
await Future<void>.delayed(const Duration(milliseconds: 250));
continue;
}
if (!forward) return false;
if (await _forward(initial)) return false;
return true; // 다른 프로그램이 포트를 쓰는 경우: 넘기지 못했으므로 그냥 실행
if (_takeLock()) break;
if (waitForExit && i < 60) {
await Future<void>.delayed(const Duration(milliseconds: 250));
continue;
}
if (!forward) return ClaimResult.running;
// 첫 창이 막 뜨는 중이면 instance.json 이 아직 없을 수 있다 - 잠깐 기다리며 몇 번
for (var k = 0; k < 10; k++) {
if (await _forward(initial)) return ClaimResult.forwarded;
await Future<void>.delayed(const Duration(milliseconds: 300));
}
return ClaimResult.unresponsive;
}
_server!.listen(_accept);
await _listen();
_add(initial);
return true;
return ClaimResult.first;
}
/// 응답하지 않는 첫 창이 있어도 이 실행이 그대로 뜰 때 (사용자가 [그래도 열기]): 요청은 이 창이 받는다
Future<void> takeOverAnyway(LaunchRequest initial) async {
await _listen();
_add(initial);
}
Future<void> _listen() async {
_server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
_token = _randomToken();
try {
Directory(dataDir).createSync(recursive: true);
_file.writeAsStringSync(jsonEncode({'port': _server!.port, 'token': _token, 'pid': pid}));
} catch (_) {}
_server!.listen(_accept);
}
static String _randomToken() {
final r = math.Random.secure();
return base64Url.encode(List.generate(24, (_) => r.nextInt(256))).replaceAll('=', '');
}
/// 모인 요청을 처리할 함수 (앱 준비가 끝난 뒤 등록)
@ -59,9 +114,10 @@ class SingleInstance {
Future<bool> _forward(LaunchRequest r) async {
try {
final s = await Socket.connect(InternetAddress.loopbackIPv4, port,
timeout: const Duration(seconds: 2));
s.write('$_magic ${jsonEncode(r.toArgs())}\n');
final j = jsonDecode(_file.readAsStringSync()) as Map;
final port = j['port'] as int, token = j['token'] as String;
final s = await Socket.connect(InternetAddress.loopbackIPv4, port, timeout: const Duration(seconds: 2));
s.write('$_magic $token ${jsonEncode(r.toArgs())}\n');
await s.flush();
final reply = await utf8.decoder.bind(s).join().timeout(const Duration(seconds: 3));
await s.close();
@ -73,8 +129,10 @@ class SingleInstance {
void _accept(Socket s) {
utf8.decoder.bind(s).transform(const LineSplitter()).first.then((line) {
if (line.startsWith('$_magic ')) {
final args = (jsonDecode(line.substring(_magic.length + 1)) as List).cast<String>();
final head = '$_magic $_token ';
// 비밀값이 맞을 때만 (같은 PC 의 다른 프로그램이 파일 열기 등을 넣지 못하게)
if (_token != null && line.startsWith(head)) {
final args = (jsonDecode(line.substring(head.length)) as List).cast<String>();
_add(LaunchRequest.parse(args));
s.write('OK');
}
@ -100,8 +158,77 @@ class SingleInstance {
});
}
/// 시험용: 받는 곳만 닫는다 (잠금 · instance.json 은 그대로 - 응답하지 않는 창)
@visibleForTesting
Future<void> debugStopListening() async => _server?.close();
/// 끝낼 때: instance.json (이 실행이 쓴 것일 때만) 을 지우고 잠금 파일을 놓는다
Future<void> close() async {
_timer?.cancel();
await _server?.close();
try {
final j = jsonDecode(_file.readAsStringSync()) as Map;
if (j['token'] == _token) _file.deleteSync();
} catch (_) {}
_releaseLock();
}
// ───────── 잠금 파일 ─────────
/// 이 데이터 폴더의 첫 실행이면 instance.lock 을 잠가 쥔다 (true). 다른 실행이 쥐고 있으면 false.
/// 잠금은 파일 핸들에 묶인다 (Windows LockFileEx) - 스레드와 상관없이 이 프로세스가 끝날 때까지 쥐고,
/// 프로세스가 죽으면 Windows 가 푼다. 이름 있는 뮤텍스는 쥔 OS 스레드가 끝나면 "버려진" 상태가 되어
/// 다음 실행이 첫 실행으로 판단할 수 있어 쓰지 않는다 (관리자 검토 10/12). GetLastError 도 쓰지 않는다.
bool _takeLock() {
if (_lock != null) return true;
RandomAccessFile? raf;
try {
Directory(dataDir).createSync(recursive: true);
raf = File(p.join(dataDir, 'instance.lock')).openSync(mode: FileMode.append);
raf.lockSync(FileLock.exclusive);
_lock = raf;
return true;
} on FileSystemException {
try {
raf?.closeSync();
} catch (_) {}
// 열지조차 못하면 (쓸 수 없는 폴더 등) 막지 않는다. 잠금만 실패면 다른 실행이 쥐고 있다
return raf == null;
}
}
void _releaseLock() {
final raf = _lock;
_lock = null;
if (raf == null) return;
try {
raf.unlockSync();
} catch (_) {}
try {
raf.closeSync();
} catch (_) {}
}
}
/// 228: 같은 데이터 폴더의 창이 이미 있는데 응답하지 않을 때 묻는다 (앱 화면이 뜨기 전이라 Windows 메시지 상자).
/// [예] = 그래도 열기, [아니요] = 닫기 (처음 고른 것 - 같은 데이터 위에 창이 두 개 뜨는 것을 막는 쪽, 관리자 결정 10/12)
bool askOpenAnyway(String title) {
if (!Platform.isWindows) return true;
const mbYesNo = 0x4, mbIconWarning = 0x30, mbDefButton2 = 0x100, idYes = 6;
final text = tr('이미 실행 중인 창이 응답하지 않습니다.\n\n'
'[예] 그래도 열기 - 같은 데이터 위에 창이 두 개가 되어 설정 · 목록이 서로 덮어쓸 수 있습니다.\n'
'[아니요] 닫기 - 응답하지 않는 창을 작업 관리자에서 끝낸 뒤 다시 실행하세요.')
.toNativeUtf16();
final caption = title.toNativeUtf16();
try {
final box = DynamicLibrary.open('user32.dll').lookupFunction<Int32 Function(IntPtr, Pointer<Utf16>, Pointer<Utf16>, Uint32),
int Function(int, Pointer<Utf16>, Pointer<Utf16>, int)>('MessageBoxW');
return box(0, text, caption, mbYesNo | mbIconWarning | mbDefButton2) == idYes;
} catch (_) {
return false;
} finally {
calloc.free(text);
calloc.free(caption);
}
}

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/gestures.dart' show kSecondaryButton;
@ -173,24 +174,106 @@ void main() {
});
});
test('중복 실행 방지: 나중 실행은 넘기고, 짧은 시간의 요청은 하나로', () async {
final first = SingleInstance(port: 47999, gather: const Duration(milliseconds: 300));
expect(await first.claim(const LaunchRequest(LaunchAction.play, ['a.mp4'])), isTrue);
final got = <LaunchRequest>[];
first.listen(got.add);
group('중복 실행 방지 (228: 데이터 폴더마다 잠금 파일 · 포트 0 · 비밀값)', () {
late Directory d1, d2;
setUp(() {
d1 = Directory.systemTemp.createTempSync('jj_inst1_');
d2 = Directory.systemTemp.createTempSync('jj_inst2_');
});
tearDown(() {
d1.deleteSync(recursive: true);
d2.deleteSync(recursive: true);
});
// 탐색기가 파일마다 프로그램을 실행한 상황
for (final f in ['b.mp4', 'c.mp4']) {
expect(await SingleInstance(port: 47999).claim(LaunchRequest(LaunchAction.play, [f])), isFalse);
}
expect(await SingleInstance(port: 47999).claim(const LaunchRequest(LaunchAction.subtitle, ['d.mp4'])), isFalse);
await Future<void>.delayed(const Duration(milliseconds: 600));
await first.close();
test('나중 실행은 넘기고, 짧은 시간의 요청은 하나로 · 끝내면 instance.json 을 지운다', () async {
final first = SingleInstance(dataDir: d1.path, gather: const Duration(milliseconds: 300));
expect(await first.claim(const LaunchRequest(LaunchAction.play, ['a.mp4'])), ClaimResult.first);
final got = <LaunchRequest>[];
first.listen(got.add);
expect(File(p.join(d1.path, 'instance.json')).existsSync(), isTrue);
expect(got, hasLength(2));
final play = got.firstWhere((r) => r.action == LaunchAction.play);
expect(play.files, ['a.mp4', 'b.mp4', 'c.mp4']);
expect(got.firstWhere((r) => r.action == LaunchAction.subtitle).files, ['d.mp4']);
// 탐색기가 파일마다 프로그램을 실행한 상황
for (final f in ['b.mp4', 'c.mp4']) {
expect(await SingleInstance(dataDir: d1.path).claim(LaunchRequest(LaunchAction.play, [f])), ClaimResult.forwarded);
}
expect(await SingleInstance(dataDir: d1.path).claim(const LaunchRequest(LaunchAction.subtitle, ['d.mp4'])),
ClaimResult.forwarded);
// 새 재생 창으로 따로 뜰 때는 넘기지 않는다
expect(await SingleInstance(dataDir: d1.path).claim(const LaunchRequest(LaunchAction.play, ['e.mp4']), forward: false),
ClaimResult.running);
await Future<void>.delayed(const Duration(milliseconds: 600));
await first.close();
expect(got, hasLength(2));
final play = got.firstWhere((r) => r.action == LaunchAction.play);
expect(play.files, ['a.mp4', 'b.mp4', 'c.mp4']);
expect(got.firstWhere((r) => r.action == LaunchAction.subtitle).files, ['d.mp4']);
expect(File(p.join(d1.path, 'instance.json')).existsSync(), isFalse);
// 끝낸 뒤에는 새 실행이 첫 실행
final again = SingleInstance(dataDir: d1.path);
expect(await again.claim(const LaunchRequest(LaunchAction.play, [])), ClaimResult.first);
await again.close();
}, skip: !Platform.isWindows);
test('데이터 폴더가 다르면 따로 뜬다 (시험판 · 다른 데이터끼리 넘기지 않음)', () async {
final a = SingleInstance(dataDir: d1.path);
final b = SingleInstance(dataDir: d2.path);
expect(a.lockPath, isNot(b.lockPath));
expect(await a.claim(const LaunchRequest(LaunchAction.play, [])), ClaimResult.first);
expect(await b.claim(const LaunchRequest(LaunchAction.play, [])), ClaimResult.first);
await a.close();
await b.close();
}, skip: !Platform.isWindows);
test('비밀값이 틀린 요청은 받지 않는다 (같은 PC 의 다른 프로그램)', () async {
final first = SingleInstance(dataDir: d1.path, gather: const Duration(milliseconds: 100));
expect(await first.claim(const LaunchRequest(LaunchAction.play, [])), ClaimResult.first);
final got = <LaunchRequest>[];
first.listen(got.add);
await Future<void>.delayed(const Duration(milliseconds: 300));
got.clear();
final port = (jsonDecode(File(p.join(d1.path, 'instance.json')).readAsStringSync()) as Map)['port'] as int;
for (final line in ['JJMKVMAKER2 wrong ["--play","x.mp4"]', 'JJMKVMAKER1 ["--play","x.mp4"]']) {
final s = await Socket.connect(InternetAddress.loopbackIPv4, port);
s.write('$line\n');
await s.flush();
final reply = await utf8.decoder.bind(s).join();
expect(reply, isNot(startsWith('OK')));
s.destroy();
}
await Future<void>.delayed(const Duration(milliseconds: 300));
expect(got, isEmpty);
await first.close();
}, skip: !Platform.isWindows);
test('첫 창이 응답하지 않으면 unresponsive (묻기 - 같은 데이터 위에 창 두 개를 막는다)', () async {
final holder = SingleInstance(dataDir: d1.path);
expect(await holder.claim(const LaunchRequest(LaunchAction.play, [])), ClaimResult.first);
// 응답하지 않는 창: 받는 곳을 닫았다
await holder.debugStopListening();
expect(await SingleInstance(dataDir: d1.path).claim(const LaunchRequest(LaunchAction.play, [])),
ClaimResult.unresponsive);
await holder.close();
}, skip: !Platform.isWindows);
test('잠금을 쥔 다른 프로세스가 죽으면 풀려서 다음 실행이 첫 실행', () async {
// 다른 프로세스가 instance.lock 을 잠근 채 응답하지 않는다 (instance.json 없음)
final lock = p.join(d1.path, 'instance.lock');
final holder = await Process.start('powershell', [
'-NoProfile',
'-Command',
"\$f = [IO.File]::Open('$lock', 'OpenOrCreate', 'ReadWrite', 'ReadWrite'); \$f.Lock(0, 1); "
"[Console]::Out.WriteLine('locked'); [Console]::Out.Flush(); Start-Sleep 60",
]);
await holder.stdout.transform(utf8.decoder).firstWhere((l) => l.contains('locked'));
expect(await SingleInstance(dataDir: d1.path).claim(const LaunchRequest(LaunchAction.play, [])),
ClaimResult.unresponsive);
holder.kill();
await holder.exitCode;
final next = SingleInstance(dataDir: d1.path);
expect(await next.claim(const LaunchRequest(LaunchAction.play, [])), ClaimResult.first);
await next.close();
}, skip: !Platform.isWindows, timeout: const Timeout(Duration(minutes: 1)));
});
test('탐색기 메뉴 등록 · 해제 (테스트 전용 확장자)', () async {