From 862e0af21ceffeb9939083217eef7f469e18ad52 Mon Sep 17 00:00:00 2001 From: mafreud Date: Fri, 2 Oct 2026 11:38:44 +0900 Subject: [PATCH 1/5] Fix variadic ioctl binding on macOS ARM64 --- .github/workflows/dart.yml | 2 +- lib/src/ffi/unix/termios.dart | 3 +- test/window_size_process.dart | 69 +++++++++++++++++++++++++++++++++++ test/window_size_test.dart | 32 ++++++++++++++++ 4 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 test/window_size_process.dart create mode 100644 test/window_size_test.dart diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 2a99663..7c9de98 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -11,7 +11,7 @@ jobs: runs-on: ${{matrix.os}} strategy: matrix: - os: [windows-latest, ubuntu-latest] + os: [windows-latest, ubuntu-latest, macos-latest] sdk: [stable] steps: diff --git a/lib/src/ffi/unix/termios.dart b/lib/src/ffi/unix/termios.dart index aa9232f..21d635a 100644 --- a/lib/src/ffi/unix/termios.dart +++ b/lib/src/ffi/unix/termios.dart @@ -140,11 +140,12 @@ typedef TCSetAttrDart = int Function(int fildes, int optional_actions, Pointer termios); // int ioctl(int, unsigned long, ...); +// The winsize pointer is variadic, which matters for the macOS ARM64 ABI. typedef IOCtlNative = Int32 Function( Int32 fildes, UnsignedLong request, - Pointer winsize, + VarArgs<(Pointer,)> winsize, ); typedef IOCtlDart = int Function(int fildes, int request, Pointer winsize); diff --git a/test/window_size_process.dart b/test/window_size_process.dart new file mode 100644 index 0000000..3e725d8 --- /dev/null +++ b/test/window_size_process.dart @@ -0,0 +1,69 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:dart_console/dart_console.dart'; +import 'package:dart_console/src/ffi/unix/termios.dart'; +import 'package:ffi/ffi.dart'; + +void main(List args) { + final libc = Platform.isMacOS + ? DynamicLibrary.open('/usr/lib/libSystem.dylib') + : DynamicLibrary.open('libc.so.6'); + final libutil = Platform.isMacOS ? libc : DynamicLibrary.open('libutil.so.1'); + final openpty = libutil + .lookupFunction< + Int32 Function( + Pointer, + Pointer, + Pointer, + Pointer, + Pointer, + ), + int Function( + Pointer, + Pointer, + Pointer, + Pointer, + Pointer, + ) + >('openpty'); + final dup2 = libc + .lookupFunction( + 'dup2', + ); + final close = libc.lookupFunction( + 'close', + ); + final master = calloc(); + final slave = calloc(); + final size = calloc() + ..ref.ws_col = int.parse(args[0]) + ..ref.ws_row = int.parse(args[1]); + var opened = false; + + try { + if (openpty(master, slave, nullptr, nullptr, size) != 0) { + throw StateError('Could not open a pseudo-terminal'); + } + opened = true; + if (dup2(slave.value, 0) == -1 || dup2(slave.value, 1) == -1) { + throw StateError('Could not attach the pseudo-terminal'); + } + + final console = Console(); + if (!console.hasTerminal) { + throw StateError('Expected a terminal'); + } + // Keep stderr piped to the parent, since stdout now belongs to the PTY. + stderr.writeln('SIZE:${console.windowWidth}x${console.windowHeight}'); + } finally { + if (opened) { + close(slave.value); + close(master.value); + } + calloc.free(size); + calloc.free(slave); + calloc.free(master); + } + // Return normally so the parent can detect VM shutdown crashes as well. +} diff --git a/test/window_size_test.dart b/test/window_size_test.dart new file mode 100644 index 0000000..49230e9 --- /dev/null +++ b/test/window_size_test.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:test/test.dart'; + +void main() { + group('Unix terminal window size', () { + for (final (columns, rows) in [(80, 24), (137, 43)]) { + test('reads ${columns}x$rows and exits cleanly', () async { + final process = await Process.start(Platform.resolvedExecutable, [ + 'run', + 'test/window_size_process.dart', + '$columns', + '$rows', + ], workingDirectory: Directory.current.path); + addTearDown(() => process.kill()); + await process.stdin.close(); + + final stdout = process.stdout.transform(utf8.decoder).join(); + final stderr = process.stderr.transform(utf8.decoder).join(); + final exitCode = await process.exitCode.timeout( + const Duration(seconds: 10), + ); + final output = await stderr; + await stdout; + + expect(exitCode, 0, reason: output); + expect(output.trim(), 'SIZE:${columns}x$rows'); + }); + } + }, skip: !Platform.isMacOS && !Platform.isLinux); +} From ea3308e1aa99ca300a466ce91a3c9ff08a29258e Mon Sep 17 00:00:00 2001 From: Brett Sutton Date: Fri, 2 Oct 2026 13:36:07 +1000 Subject: [PATCH 2/5] Strengthen ioctl regression coverage and explicit CI architectures --- .github/workflows/dart.yml | 37 +++++++++++--- doc/ci-architectures.md | 37 ++++++++++++++ test/window_size_process.dart | 69 +++++++++++++++++++++---- test/window_size_test.dart | 96 ++++++++++++++++++++++++++--------- 4 files changed, 199 insertions(+), 40 deletions(-) create mode 100644 doc/ci-architectures.md diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 7c9de98..8b7c6f1 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -6,22 +6,39 @@ on: pull_request: branches: [ main ] +permissions: + contents: read + jobs: build: - runs-on: ${{matrix.os}} + name: ${{ matrix.os }} / ${{ matrix.arch }} / Dart ${{ matrix.sdk }} + runs-on: ${{ matrix.os }} + timeout-minutes: 20 strategy: + fail-fast: false matrix: - os: [windows-latest, ubuntu-latest, macos-latest] - sdk: [stable] + include: + - {os: ubuntu-24.04, arch: x64, sdk: stable} + - {os: ubuntu-24.04-arm, arch: arm64, sdk: stable} + - {os: macos-15-intel, arch: x64, sdk: stable} + - {os: macos-15, arch: arm64, sdk: stable} + - {os: windows-2025, arch: x64, sdk: stable} + - {os: windows-11-arm, arch: arm64, sdk: stable} + - {os: ubuntu-24.04, arch: x64, sdk: '3.10.0'} + # Linux ARM32 and RISC-V lack standard GitHub-hosted runners. + # See doc/ci-architectures.md for the unresolved execution requirements. steps: - name: Fetch sources - uses: actions/checkout@v2 + uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Dart uses: dart-lang/setup-dart@v1 with: sdk: ${{matrix.sdk}} + architecture: ${{ matrix.arch }} - name: Print Dart SDK version run: dart --version @@ -29,22 +46,26 @@ jobs: - name: Install dependencies run: dart pub get + # Exercise each ABI even if a style check subsequently fails. + - name: Run tests + run: dart test --concurrency=2 + - name: Verify formatting run: dart format --output=none --set-exit-if-changed . - name: Analyze project source run: dart analyze --fatal-warnings - - name: Run tests - run: dart test - - name: Add code coverage package + if: matrix.os == 'ubuntu-24.04' && matrix.sdk == 'stable' run: dart pub global activate coverage - name: Generate code coverage + if: matrix.os == 'ubuntu-24.04' && matrix.sdk == 'stable' run: dart pub global run coverage:test_with_coverage - name: Upload code coverage + if: github.event_name == 'push' && matrix.os == 'ubuntu-24.04' && matrix.sdk == 'stable' uses: codecov/codecov-action@v3 with: - files: coverage/lcov.info \ No newline at end of file + files: coverage/lcov.info diff --git a/doc/ci-architectures.md b/doc/ci-architectures.md new file mode 100644 index 0000000..de342a3 --- /dev/null +++ b/doc/ci-architectures.md @@ -0,0 +1,37 @@ +# CI architecture coverage + +The package declares Linux, macOS, and Windows without CPU restrictions. +The current Dart support table lists Linux x64, ARM32, ARM64, and RISC-V; +macOS x64 and ARM64; and Windows x64 and ARM64. IA32 is unsupported by +current Dart. Linux here means glibc: the implementation loads libc.so.6. + +The workflow explicitly selects six native hosted OS/CPU pairs and verifies +stable Dart on each, plus the declared minimum Dart 3.10.0 on Linux x64. +Unix tests execute the PTY helper in both JIT and AOT modes; Windows runs the +remaining suite and skips the Unix-specific PTY tests. + +## Unresolved targets + +Linux ARM32 and Linux RISC-V have no standard GitHub-hosted runner labels. +They are not silently treated as covered by ubuntu-24.04. Completing coverage +requires either a reviewed QEMU userspace/container setup on a disposable +hosted runner, with matching glibc and Dart SDK, or isolated ephemeral native +runners. Do not run fork PR code on persistent self-hosted machines containing +credentials. RISC-V SDK installation also requires verification beyond the +setup-dart action's documented architecture list. + +Before advertising ARM32 support, correct the pre-existing TermIOS definition: +it uses the macOS layout (44 bytes on a 32-bit ABI), while Linux glibc uses a +60-byte struct. tcgetattr can therefore overrun the allocation on ARM32. +The macOS layout is also incorrect for Linux 64-bit field accesses, although +its larger allocation avoids this specific overflow. This issue predates the +ioctl variadic fix and requires separate platform-specific termios work. + +Workflow configuration is not evidence of a completed platform test run. +Record actual runs and results before claiming a target passed. + +Sources: +- https://dart.dev/get-dart#system-requirements +- https://docs.github.com/en/actions/reference/runners/github-hosted-runners +- https://github.com/dart-lang/setup-dart/blob/main/action.yml +- https://github.com/bminor/glibc/blob/master/sysdeps/unix/sysv/linux/bits/termios-struct.h diff --git a/test/window_size_process.dart b/test/window_size_process.dart index 3e725d8..06d565e 100644 --- a/test/window_size_process.dart +++ b/test/window_size_process.dart @@ -3,9 +3,13 @@ import 'dart:io'; import 'package:dart_console/dart_console.dart'; import 'package:dart_console/src/ffi/unix/termios.dart'; +import 'package:dart_console/src/ffi/unix/termlib_unix.dart'; import 'package:ffi/ffi.dart'; void main(List args) { + final columns = int.parse(args[0]); + final rows = int.parse(args[1]); + final mode = args[2]; final libc = Platform.isMacOS ? DynamicLibrary.open('/usr/lib/libSystem.dylib') : DynamicLibrary.open('libc.so.6'); @@ -37,8 +41,8 @@ void main(List args) { final master = calloc(); final slave = calloc(); final size = calloc() - ..ref.ws_col = int.parse(args[0]) - ..ref.ws_row = int.parse(args[1]); + ..ref.ws_col = columns + ..ref.ws_row = rows; var opened = false; try { @@ -46,16 +50,63 @@ void main(List args) { throw StateError('Could not open a pseudo-terminal'); } opened = true; - if (dup2(slave.value, 0) == -1 || dup2(slave.value, 1) == -1) { - throw StateError('Could not attach the pseudo-terminal'); + final descriptors = switch (mode) { + 'both' => [0, 1], + 'stdin' => [0], + 'stdout' => [1], + 'stderr' => [2], + 'none' => [], + _ => throw ArgumentError.value(mode), + }; + for (final fd in descriptors) { + if (dup2(slave.value, fd) == -1) { + throw StateError('Could not attach the pseudo-terminal to $fd'); + } } - final console = Console(); - if (!console.hasTerminal) { - throw StateError('Expected a terminal'); + // Query the package binding directly: Console can mask a failed ioctl + // by falling back to dart:io's terminal dimensions. + final terminal = TermLibUnix(); + if (sizeOf() != 8) throw StateError('Unexpected winsize layout'); + if (terminal.ioctl( + -1, + Platform.isMacOS ? TIOCGWINSZ_MACOS : TIOCGWINSZ_LINUX, + size, + ) != + -1) { + throw StateError('Invalid descriptor should fail'); } - // Keep stderr piped to the parent, since stdout now belongs to the PTY. - stderr.writeln('SIZE:${console.windowWidth}x${console.windowHeight}'); + final expectedWidth = mode == 'none' || columns == 0 || rows == 0 + ? null + : columns; + final expectedHeight = expectedWidth == null ? null : rows; + for (var i = 0; i < 100; i++) { + if (terminal.windowWidth != expectedWidth || + terminal.windowHeight != expectedHeight) { + throw StateError('Package ioctl returned incorrect dimensions'); + } + } + if (mode == 'both' && expectedWidth != null) { + final console = Console(); + if (!console.hasTerminal || + console.windowWidth != columns || + console.windowHeight != rows) { + throw StateError('Console returned incorrect dimensions'); + } + } + if (mode == 'none') { + final console = Console(); + if (console.hasTerminal || + console.windowWidth != 80 || + console.windowHeight != 25) { + throw StateError('Incorrect redirected-console fallback'); + } + } + // One output pipe always remains attached to the parent. + (mode == 'stderr' ? stdout : stderr).writeln( + 'SIZE:$expectedWidth' + 'x$expectedHeight', + ); } finally { if (opened) { close(slave.value); diff --git a/test/window_size_test.dart b/test/window_size_test.dart index 49230e9..c353527 100644 --- a/test/window_size_test.dart +++ b/test/window_size_test.dart @@ -1,32 +1,82 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:test/test.dart'; +Future runChild(String executable, List arguments) async { + final process = await Process.start(executable, arguments); + final output = process.stdout.transform(utf8.decoder).join(); + final errors = process.stderr.transform(utf8.decoder).join(); + try { + await process.stdin.close(); + final result = await Future.wait([ + process.exitCode, + output, + errors, + ]).timeout(const Duration(seconds: 60)); + expect(result[0], 0, reason: '${result[1]}${result[2]}'); + return '${result[1]}${result[2]}'.trim(); + } finally { + // SIGKILL also handles a child stuck in native code; await exit to reap it. + process.kill(ProcessSignal.sigkill); + await process.exitCode; + } +} + void main() { - group('Unix terminal window size', () { - for (final (columns, rows) in [(80, 24), (137, 43)]) { - test('reads ${columns}x$rows and exits cleanly', () async { - final process = await Process.start(Platform.resolvedExecutable, [ - 'run', + group( + 'Unix terminal window size', + () { + late Directory temporary; + late String executable; + setUpAll(() async { + temporary = await Directory.systemTemp.createTemp('dart-console-aot-'); + addTearDown(() => temporary.delete(recursive: true)); + executable = '${temporary.path}/window-size'; + await runChild(Platform.resolvedExecutable, [ + 'compile', + 'exe', 'test/window_size_process.dart', - '$columns', - '$rows', - ], workingDirectory: Directory.current.path); - addTearDown(() => process.kill()); - await process.stdin.close(); - - final stdout = process.stdout.transform(utf8.decoder).join(); - final stderr = process.stderr.transform(utf8.decoder).join(); - final exitCode = await process.exitCode.timeout( - const Duration(seconds: 10), - ); - final output = await stderr; - await stdout; - - expect(exitCode, 0, reason: output); - expect(output.trim(), 'SIZE:${columns}x$rows'); + '-o', + executable, + ]); }); - } - }, skip: !Platform.isMacOS && !Platform.isLinux); + for (final aot in [false, true]) { + for (final (columns, rows, mode) in [ + (80, 24, 'both'), + (137, 43, 'both'), + (40001, 32769, 'both'), + (137, 43, 'stdout'), + (137, 43, 'stdin'), + (137, 43, 'stderr'), + (137, 43, 'none'), + (0, 43, 'both'), + (137, 0, 'both'), + ]) { + test( + '${aot ? 'AOT' : 'JIT'} ${columns}x$rows $mode exits cleanly', + () async { + final output = await runChild( + aot ? executable : Platform.resolvedExecutable, + [ + if (!aot) 'test/window_size_process.dart', + '$columns', + '$rows', + mode, + ], + ); + final valid = mode != 'none' && columns != 0 && rows != 0; + expect( + output, + valid ? 'SIZE:${columns}x$rows' : 'SIZE:nullxnull', + ); + }, + ); + } + } + }, + skip: !Platform.isMacOS && !Platform.isLinux, + timeout: const Timeout(Duration(minutes: 2)), + ); } From 37a171c0225d3fb9f70a9e70d52ade17852cb0f8 Mon Sep 17 00:00:00 2001 From: Brett Sutton Date: Fri, 2 Oct 2026 13:52:07 +1000 Subject: [PATCH 3/5] Normalize existing formatting to unblock CI checks --- example/kilo.dart | 6 ++++-- lib/src/calendar.dart | 2 +- lib/src/progressbar.dart | 5 +++-- lib/src/string_utils.dart | 3 ++- lib/src/table.dart | 5 +++-- 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/example/kilo.dart b/example/kilo.dart index 02d399e..a26316d 100644 --- a/example/kilo.dart +++ b/example/kilo.dart @@ -75,7 +75,8 @@ void editorInsertChar(String char) { fileRows.add(char); renderRows.add(char); } else { - fileRows[cursorRow] = fileRows[cursorRow].substring(0, cursorCol) + + fileRows[cursorRow] = + fileRows[cursorRow].substring(0, cursorCol) + char + fileRows[cursorRow].substring(cursorCol); } @@ -92,7 +93,8 @@ void editorBackspaceChar() { if (cursorCol == 0 && cursorRow == 0) return; if (cursorCol > 0) { - fileRows[cursorRow] = fileRows[cursorRow].substring(0, cursorCol - 1) + + fileRows[cursorRow] = + fileRows[cursorRow].substring(0, cursorCol - 1) + fileRows[cursorRow].substring(cursorCol); editorUpdateRenderRow(cursorRow); cursorCol--; diff --git a/lib/src/calendar.dart b/lib/src/calendar.dart index ad05024..505e502 100644 --- a/lib/src/calendar.dart +++ b/lib/src/calendar.dart @@ -12,7 +12,7 @@ class Calendar extends Table { List dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; Calendar(DateTime dateTime) - : calendarDate = dateTime.subtract(Duration(days: dateTime.day - 1)) { + : calendarDate = dateTime.subtract(Duration(days: dateTime.day - 1)) { for (final day in dayLabels) { insertColumn(header: day, alignment: TextAlignment.right); } diff --git a/lib/src/progressbar.dart b/lib/src/progressbar.dart index f2837fb..3af0c95 100644 --- a/lib/src/progressbar.dart +++ b/lib/src/progressbar.dart @@ -106,8 +106,9 @@ class ProgressBar { _tickCount * _innerWidth ~/ maxValue - 1, ); final remaining = _innerWidth - fractionComplete - 1; - final spinner = - showSpinner ? tickCharacters[_tickCount % tickCharacters.length] : ' '; + final spinner = showSpinner + ? tickCharacters[_tickCount % tickCharacters.length] + : ' '; _printProgressBar( '[${tickCharacters[0] * fractionComplete}$spinner${' ' * remaining}]', diff --git a/lib/src/string_utils.dart b/lib/src/string_utils.dart index 25978df..3520703 100644 --- a/lib/src/string_utils.dart +++ b/lib/src/string_utils.dart @@ -176,7 +176,8 @@ extension StringUtils on String { } RegExp get _emojiRegex => RegExp( - r'[#*0-9]\uFE0F?\u20E3|[\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23ED-\u23EF\u23F1\u23F2\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB\u25FC\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692\u2694-\u2697\u2699\u269B\u269C\u26A0\u26A7\u26AA\u26B0\u26B1\u26BD\u26BE\u26C4\u26C8\u26CF\u26D1\u26D3\u26E9\u26F0-\u26F5\u26F7\u26F8\u26FA\u2702\u2708\u2709\u270F\u2712\u2714\u2716\u271D\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u27A1\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B55\u3030\u303D\u3297\u3299]\uFE0F?|[\u261D\u270C\u270D](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\u270A\u270B](?:\uD83C[\uDFFB-\uDFFF])?|[\u23E9-\u23EC\u23F0\u23F3\u25FD\u2693\u26A1\u26AB\u26C5\u26CE\u26D4\u26EA\u26FD\u2705\u2728\u274C\u274E\u2753-\u2755\u2795-\u2797\u27B0\u27BF\u2B50]|\u26F9(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\u2764\uFE0F?(?:\u200D(?:\uD83D\uDD25|\uD83E\uDE79))?|\uD83C(?:[\uDC04\uDD70\uDD71\uDD7E\uDD7F\uDE02\uDE37\uDF21\uDF24-\uDF2C\uDF36\uDF7D\uDF96\uDF97\uDF99-\uDF9B\uDF9E\uDF9F\uDFCD\uDFCE\uDFD4-\uDFDF\uDFF5\uDFF7]\uFE0F?|[\uDF85\uDFC2\uDFC7](?:\uD83C[\uDFFB-\uDFFF])?|[\uDFC3\uDFC4\uDFCA](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDFCB\uDFCC](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDCCF\uDD8E\uDD91-\uDD9A\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF84\uDF86-\uDF93\uDFA0-\uDFC1\uDFC5\uDFC6\uDFC8\uDFC9\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF8-\uDFFF]|\uDDE6\uD83C[\uDDE8-\uDDEC\uDDEE\uDDF1\uDDF2\uDDF4\uDDF6-\uDDFA\uDDFC\uDDFD\uDDFF]|\uDDE7\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEF\uDDF1-\uDDF4\uDDF6-\uDDF9\uDDFB\uDDFC\uDDFE\uDDFF]|\uDDE8\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDEE\uDDF0-\uDDF5\uDDF7\uDDFA-\uDDFF]|\uDDE9\uD83C[\uDDEA\uDDEC\uDDEF\uDDF0\uDDF2\uDDF4\uDDFF]|\uDDEA\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDED\uDDF7-\uDDFA]|\uDDEB\uD83C[\uDDEE-\uDDF0\uDDF2\uDDF4\uDDF7]|\uDDEC\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEE\uDDF1-\uDDF3\uDDF5-\uDDFA\uDDFC\uDDFE]|\uDDED\uD83C[\uDDF0\uDDF2\uDDF3\uDDF7\uDDF9\uDDFA]|\uDDEE\uD83C[\uDDE8-\uDDEA\uDDF1-\uDDF4\uDDF6-\uDDF9]|\uDDEF\uD83C[\uDDEA\uDDF2\uDDF4\uDDF5]|\uDDF0\uD83C[\uDDEA\uDDEC-\uDDEE\uDDF2\uDDF3\uDDF5\uDDF7\uDDFC\uDDFE\uDDFF]|\uDDF1\uD83C[\uDDE6-\uDDE8\uDDEE\uDDF0\uDDF7-\uDDFB\uDDFE]|\uDDF2\uD83C[\uDDE6\uDDE8-\uDDED\uDDF0-\uDDFF]|\uDDF3\uD83C[\uDDE6\uDDE8\uDDEA-\uDDEC\uDDEE\uDDF1\uDDF4\uDDF5\uDDF7\uDDFA\uDDFF]|\uDDF4\uD83C\uDDF2|\uDDF5\uD83C[\uDDE6\uDDEA-\uDDED\uDDF0-\uDDF3\uDDF7-\uDDF9\uDDFC\uDDFE]|\uDDF6\uD83C\uDDE6|\uDDF7\uD83C[\uDDEA\uDDF4\uDDF8\uDDFA\uDDFC]|\uDDF8\uD83C[\uDDE6-\uDDEA\uDDEC-\uDDF4\uDDF7-\uDDF9\uDDFB\uDDFD-\uDDFF]|\uDDF9\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDED\uDDEF-\uDDF4\uDDF7\uDDF9\uDDFB\uDDFC\uDDFF]|\uDDFA\uD83C[\uDDE6\uDDEC\uDDF2\uDDF3\uDDF8\uDDFE\uDDFF]|\uDDFB\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDEE\uDDF3\uDDFA]|\uDDFC\uD83C[\uDDEB\uDDF8]|\uDDFD\uD83C\uDDF0|\uDDFE\uD83C[\uDDEA\uDDF9]|\uDDFF\uD83C[\uDDE6\uDDF2\uDDFC]|\uDFF3\uFE0F?(?:\u200D(?:\u26A7\uFE0F?|\uD83C\uDF08))?|\uDFF4(?:\u200D\u2620\uFE0F?|\uDB40\uDC67\uDB40\uDC62\uDB40(?:\uDC65\uDB40\uDC6E\uDB40\uDC67|\uDC73\uDB40\uDC63\uDB40\uDC74|\uDC77\uDB40\uDC6C\uDB40\uDC73)\uDB40\uDC7F)?)|\uD83D(?:[\uDC08\uDC26](?:\u200D\u2B1B)?|[\uDC3F\uDCFD\uDD49\uDD4A\uDD6F\uDD70\uDD73\uDD76-\uDD79\uDD87\uDD8A-\uDD8D\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA\uDECB\uDECD-\uDECF\uDEE0-\uDEE5\uDEE9\uDEF0\uDEF3]\uFE0F?|[\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDC8F\uDC91\uDCAA\uDD7A\uDD95\uDD96\uDE4C\uDE4F\uDEC0\uDECC](?:\uD83C[\uDFFB-\uDFFF])?|[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD74\uDD90](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\uDC00-\uDC07\uDC09-\uDC14\uDC16-\uDC25\uDC27-\uDC3A\uDC3C-\uDC3E\uDC40\uDC44\uDC45\uDC51-\uDC65\uDC6A\uDC79-\uDC7B\uDC7D-\uDC80\uDC84\uDC88-\uDC8E\uDC90\uDC92-\uDCA9\uDCAB-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDDA4\uDDFB-\uDE2D\uDE2F-\uDE34\uDE37-\uDE44\uDE48-\uDE4A\uDE80-\uDEA2\uDEA4-\uDEB3\uDEB7-\uDEBF\uDEC1-\uDEC5\uDED0-\uDED2\uDED5-\uDED7\uDEDC-\uDEDF\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB\uDFF0]|\uDC15(?:\u200D\uD83E\uDDBA)?|\uDC3B(?:\u200D\u2744\uFE0F?)?|\uDC41\uFE0F?(?:\u200D\uD83D\uDDE8\uFE0F?)?|\uDC68(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDC68\uDC69]\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFE])))?))?|\uDC69(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?[\uDC68\uDC69]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?|\uDC69\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?))|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFE])))?))?|\uDC6F(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDD75(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDE2E(?:\u200D\uD83D\uDCA8)?|\uDE35(?:\u200D\uD83D\uDCAB)?|\uDE36(?:\u200D\uD83C\uDF2B\uFE0F?)?)|\uD83E(?:[\uDD0C\uDD0F\uDD18-\uDD1F\uDD30-\uDD34\uDD36\uDD77\uDDB5\uDDB6\uDDBB\uDDD2\uDDD3\uDDD5\uDEC3-\uDEC5\uDEF0\uDEF2-\uDEF8](?:\uD83C[\uDFFB-\uDFFF])?|[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDDDE\uDDDF](?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD0D\uDD0E\uDD10-\uDD17\uDD20-\uDD25\uDD27-\uDD2F\uDD3A\uDD3F-\uDD45\uDD47-\uDD76\uDD78-\uDDB4\uDDB7\uDDBA\uDDBC-\uDDCC\uDDD0\uDDE0-\uDDFF\uDE70-\uDE7C\uDE80-\uDE88\uDE90-\uDEBD\uDEBF-\uDEC2\uDECE-\uDEDB\uDEE0-\uDEE8]|\uDD3C(?:\u200D[\u2640\u2642]\uFE0F?|\uD83C[\uDFFB-\uDFFF])?|\uDDD1(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1))|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFC-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFD-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFD\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFE]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?))?|\uDEF1(?:\uD83C(?:\uDFFB(?:\u200D\uD83E\uDEF2\uD83C[\uDFFC-\uDFFF])?|\uDFFC(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFD-\uDFFF])?|\uDFFD(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])?|\uDFFE(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFD\uDFFF])?|\uDFFF(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFE])?))?)'); + r'[#*0-9]\uFE0F?\u20E3|[\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23ED-\u23EF\u23F1\u23F2\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB\u25FC\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692\u2694-\u2697\u2699\u269B\u269C\u26A0\u26A7\u26AA\u26B0\u26B1\u26BD\u26BE\u26C4\u26C8\u26CF\u26D1\u26D3\u26E9\u26F0-\u26F5\u26F7\u26F8\u26FA\u2702\u2708\u2709\u270F\u2712\u2714\u2716\u271D\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u27A1\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B55\u3030\u303D\u3297\u3299]\uFE0F?|[\u261D\u270C\u270D](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\u270A\u270B](?:\uD83C[\uDFFB-\uDFFF])?|[\u23E9-\u23EC\u23F0\u23F3\u25FD\u2693\u26A1\u26AB\u26C5\u26CE\u26D4\u26EA\u26FD\u2705\u2728\u274C\u274E\u2753-\u2755\u2795-\u2797\u27B0\u27BF\u2B50]|\u26F9(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\u2764\uFE0F?(?:\u200D(?:\uD83D\uDD25|\uD83E\uDE79))?|\uD83C(?:[\uDC04\uDD70\uDD71\uDD7E\uDD7F\uDE02\uDE37\uDF21\uDF24-\uDF2C\uDF36\uDF7D\uDF96\uDF97\uDF99-\uDF9B\uDF9E\uDF9F\uDFCD\uDFCE\uDFD4-\uDFDF\uDFF5\uDFF7]\uFE0F?|[\uDF85\uDFC2\uDFC7](?:\uD83C[\uDFFB-\uDFFF])?|[\uDFC3\uDFC4\uDFCA](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDFCB\uDFCC](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDCCF\uDD8E\uDD91-\uDD9A\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF84\uDF86-\uDF93\uDFA0-\uDFC1\uDFC5\uDFC6\uDFC8\uDFC9\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF8-\uDFFF]|\uDDE6\uD83C[\uDDE8-\uDDEC\uDDEE\uDDF1\uDDF2\uDDF4\uDDF6-\uDDFA\uDDFC\uDDFD\uDDFF]|\uDDE7\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEF\uDDF1-\uDDF4\uDDF6-\uDDF9\uDDFB\uDDFC\uDDFE\uDDFF]|\uDDE8\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDEE\uDDF0-\uDDF5\uDDF7\uDDFA-\uDDFF]|\uDDE9\uD83C[\uDDEA\uDDEC\uDDEF\uDDF0\uDDF2\uDDF4\uDDFF]|\uDDEA\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDED\uDDF7-\uDDFA]|\uDDEB\uD83C[\uDDEE-\uDDF0\uDDF2\uDDF4\uDDF7]|\uDDEC\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEE\uDDF1-\uDDF3\uDDF5-\uDDFA\uDDFC\uDDFE]|\uDDED\uD83C[\uDDF0\uDDF2\uDDF3\uDDF7\uDDF9\uDDFA]|\uDDEE\uD83C[\uDDE8-\uDDEA\uDDF1-\uDDF4\uDDF6-\uDDF9]|\uDDEF\uD83C[\uDDEA\uDDF2\uDDF4\uDDF5]|\uDDF0\uD83C[\uDDEA\uDDEC-\uDDEE\uDDF2\uDDF3\uDDF5\uDDF7\uDDFC\uDDFE\uDDFF]|\uDDF1\uD83C[\uDDE6-\uDDE8\uDDEE\uDDF0\uDDF7-\uDDFB\uDDFE]|\uDDF2\uD83C[\uDDE6\uDDE8-\uDDED\uDDF0-\uDDFF]|\uDDF3\uD83C[\uDDE6\uDDE8\uDDEA-\uDDEC\uDDEE\uDDF1\uDDF4\uDDF5\uDDF7\uDDFA\uDDFF]|\uDDF4\uD83C\uDDF2|\uDDF5\uD83C[\uDDE6\uDDEA-\uDDED\uDDF0-\uDDF3\uDDF7-\uDDF9\uDDFC\uDDFE]|\uDDF6\uD83C\uDDE6|\uDDF7\uD83C[\uDDEA\uDDF4\uDDF8\uDDFA\uDDFC]|\uDDF8\uD83C[\uDDE6-\uDDEA\uDDEC-\uDDF4\uDDF7-\uDDF9\uDDFB\uDDFD-\uDDFF]|\uDDF9\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDED\uDDEF-\uDDF4\uDDF7\uDDF9\uDDFB\uDDFC\uDDFF]|\uDDFA\uD83C[\uDDE6\uDDEC\uDDF2\uDDF3\uDDF8\uDDFE\uDDFF]|\uDDFB\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDEE\uDDF3\uDDFA]|\uDDFC\uD83C[\uDDEB\uDDF8]|\uDDFD\uD83C\uDDF0|\uDDFE\uD83C[\uDDEA\uDDF9]|\uDDFF\uD83C[\uDDE6\uDDF2\uDDFC]|\uDFF3\uFE0F?(?:\u200D(?:\u26A7\uFE0F?|\uD83C\uDF08))?|\uDFF4(?:\u200D\u2620\uFE0F?|\uDB40\uDC67\uDB40\uDC62\uDB40(?:\uDC65\uDB40\uDC6E\uDB40\uDC67|\uDC73\uDB40\uDC63\uDB40\uDC74|\uDC77\uDB40\uDC6C\uDB40\uDC73)\uDB40\uDC7F)?)|\uD83D(?:[\uDC08\uDC26](?:\u200D\u2B1B)?|[\uDC3F\uDCFD\uDD49\uDD4A\uDD6F\uDD70\uDD73\uDD76-\uDD79\uDD87\uDD8A-\uDD8D\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA\uDECB\uDECD-\uDECF\uDEE0-\uDEE5\uDEE9\uDEF0\uDEF3]\uFE0F?|[\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDC8F\uDC91\uDCAA\uDD7A\uDD95\uDD96\uDE4C\uDE4F\uDEC0\uDECC](?:\uD83C[\uDFFB-\uDFFF])?|[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD74\uDD90](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\uDC00-\uDC07\uDC09-\uDC14\uDC16-\uDC25\uDC27-\uDC3A\uDC3C-\uDC3E\uDC40\uDC44\uDC45\uDC51-\uDC65\uDC6A\uDC79-\uDC7B\uDC7D-\uDC80\uDC84\uDC88-\uDC8E\uDC90\uDC92-\uDCA9\uDCAB-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDDA4\uDDFB-\uDE2D\uDE2F-\uDE34\uDE37-\uDE44\uDE48-\uDE4A\uDE80-\uDEA2\uDEA4-\uDEB3\uDEB7-\uDEBF\uDEC1-\uDEC5\uDED0-\uDED2\uDED5-\uDED7\uDEDC-\uDEDF\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB\uDFF0]|\uDC15(?:\u200D\uD83E\uDDBA)?|\uDC3B(?:\u200D\u2744\uFE0F?)?|\uDC41\uFE0F?(?:\u200D\uD83D\uDDE8\uFE0F?)?|\uDC68(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDC68\uDC69]\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFE])))?))?|\uDC69(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?[\uDC68\uDC69]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?|\uDC69\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?))|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFE])))?))?|\uDC6F(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDD75(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDE2E(?:\u200D\uD83D\uDCA8)?|\uDE35(?:\u200D\uD83D\uDCAB)?|\uDE36(?:\u200D\uD83C\uDF2B\uFE0F?)?)|\uD83E(?:[\uDD0C\uDD0F\uDD18-\uDD1F\uDD30-\uDD34\uDD36\uDD77\uDDB5\uDDB6\uDDBB\uDDD2\uDDD3\uDDD5\uDEC3-\uDEC5\uDEF0\uDEF2-\uDEF8](?:\uD83C[\uDFFB-\uDFFF])?|[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDDDE\uDDDF](?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD0D\uDD0E\uDD10-\uDD17\uDD20-\uDD25\uDD27-\uDD2F\uDD3A\uDD3F-\uDD45\uDD47-\uDD76\uDD78-\uDDB4\uDDB7\uDDBA\uDDBC-\uDDCC\uDDD0\uDDE0-\uDDFF\uDE70-\uDE7C\uDE80-\uDE88\uDE90-\uDEBD\uDEBF-\uDEC2\uDECE-\uDEDB\uDEE0-\uDEE8]|\uDD3C(?:\u200D[\u2640\u2642]\uFE0F?|\uD83C[\uDFFB-\uDFFF])?|\uDDD1(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1))|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFC-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFD-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFD\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFE]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?))?|\uDEF1(?:\uD83C(?:\uDFFB(?:\u200D\uD83E\uDEF2\uD83C[\uDFFC-\uDFFF])?|\uDFFC(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFD-\uDFFF])?|\uDFFD(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])?|\uDFFE(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFD\uDFFF])?|\uDFFF(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFE])?))?)', +); /// Returns the terminal display width of a single character. /// diff --git a/lib/src/table.dart b/lib/src/table.dart index f9c8477..e4b4f42 100644 --- a/lib/src/table.dart +++ b/lib/src/table.dart @@ -378,8 +378,9 @@ class Table { borderType == BorderType.vertical ? ' ' : _borderGlyphs.horizontalLine, ].join(); - final horizontalLine = - borderType == BorderType.vertical ? ' ' : _borderGlyphs.horizontalLine; + final horizontalLine = borderType == BorderType.vertical + ? ' ' + : _borderGlyphs.horizontalLine; return [ if (borderColor != null) borderColor!.ansiSetForegroundColorSequence, From 39a0059bf036cbfc30d387caae59e19d7dcdca28 Mon Sep 17 00:00:00 2001 From: Brett Sutton Date: Fri, 2 Oct 2026 14:01:38 +1000 Subject: [PATCH 4/5] Fix platform-specific termios ABI and verify native terminal modes --- .github/scripts/check_ioctl_regression.py | 42 ++++ .github/workflows/dart.yml | 4 + CHANGELOG.md | 6 + doc/ci-architectures.md | 35 ++- example/ffi/ffi_rawmode.dart | 178 ++------------ lib/src/console.dart | 2 +- lib/src/ffi/unix/termios.dart | 148 ++++------- lib/src/ffi/unix/termlib_unix.dart | 78 +++--- test/termios_native.c | 105 ++++++++ test/termios_process.dart | 283 ++++++++++++++++++++++ test/termios_test.dart | 84 +++++++ 11 files changed, 661 insertions(+), 304 deletions(-) create mode 100644 .github/scripts/check_ioctl_regression.py create mode 100644 test/termios_native.c create mode 100644 test/termios_process.dart create mode 100644 test/termios_test.dart diff --git a/.github/scripts/check_ioctl_regression.py b/.github/scripts/check_ioctl_regression.py new file mode 100644 index 0000000..0f59fcb --- /dev/null +++ b/.github/scripts/check_ioctl_regression.py @@ -0,0 +1,42 @@ +"""Prove the old ioctl signature fails on macOS ARM64 in an isolated copy.""" +import pathlib +import platform +import re +import shutil +import subprocess +import tempfile + +if platform.system() != "Darwin" or platform.machine() != "arm64": + raise SystemExit("This control must run natively on macOS ARM64") + +root = pathlib.Path(__file__).resolve().parents[2] +with tempfile.TemporaryDirectory(prefix="dart-console-old-ioctl-") as temporary: + target = pathlib.Path(temporary) + for name in ("lib", "test"): + shutil.copytree(root / name, target / name) + for name in ("pubspec.yaml", "pubspec.lock"): + if (root / name).exists(): + shutil.copy2(root / name, target / name) + binding = target / "lib/src/ffi/unix/termios.dart" + source = binding.read_text() + original = "VarArgs<(Pointer,)> winsize" + if source.count(original) != 1: + raise SystemExit("Expected exactly one reviewed variadic ioctl binding") + binding.write_text(source.replace(original, "Pointer winsize")) + subprocess.run(["dart", "pub", "get", "--offline"], cwd=target, + check=True, timeout=120) + for mode in ("JIT", "AOT"): + result = subprocess.run( + ["dart", "test", "--concurrency=1", "--reporter=expanded", + "test/window_size_test.dart", "--plain-name", f"{mode} 80x24 both"], + cwd=target, capture_output=True, text=True, timeout=180, + ) + output = result.stdout + result.stderr + print(output, flush=True) + # Accept an incorrect native result or a signal-killed helper, not an + # unrelated build/setup failure or a timeout, as regression evidence. + reproduced = ("Package ioctl returned incorrect dimensions" in output + or re.search(r"Actual: <-[0-9]+>", output)) + if result.returncode == 0 or not reproduced: + raise SystemExit(f"Old {mode} signature did not reproduce the regression") + print(f"Confirmed: old {mode} ioctl signature fails on macOS ARM64", flush=True) diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 8b7c6f1..1d1c77d 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -50,6 +50,10 @@ jobs: - name: Run tests run: dart test --concurrency=2 + - name: Confirm original ioctl regression on macOS ARM64 + if: matrix.os == 'macos-15' && matrix.arch == 'arm64' + run: python3 .github/scripts/check_ioctl_regression.py + - name: Verify formatting run: dart format --output=none --set-exit-if-changed . diff --git a/CHANGELOG.md b/CHANGELOG.md index 464da88..1385201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# Unreleased +- Fix the variadic ioctl binding on macOS ARM64 (mafreud, #15). +- Use platform-specific termios layouts and libc raw-mode flags on Linux and + macOS; preserve terminal settings and free temporary native allocations. +- Add native ABI and PTY regression tests and explicit x64/ARM64 CI jobs. + # 5.1.0 - Enhance table rendering and string handling for East Asian characters (#12) - Fix #9: query terminal window size via platform APIs (#13) diff --git a/doc/ci-architectures.md b/doc/ci-architectures.md index de342a3..5e54c25 100644 --- a/doc/ci-architectures.md +++ b/doc/ci-architectures.md @@ -1,16 +1,28 @@ # CI architecture coverage -The package declares Linux, macOS, and Windows without CPU restrictions. -The current Dart support table lists Linux x64, ARM32, ARM64, and RISC-V; -macOS x64 and ARM64; and Windows x64 and ARM64. IA32 is unsupported by -current Dart. Linux here means glibc: the implementation loads libc.so.6. +The package declares Linux, macOS, and Windows, without a documented CPU +support matrix. Its README records tests on those operating systems; the +changelog mentions an ARM64 compatibility fix. Neither explicitly promises +ARM32 or RISC-V support. This workflow targets six native x64/ARM64 combinations. + +Dart additionally supports Linux ARM32 and RISC-V. Those are potential package +expansion targets, not established dart_console support promises. Current Dart +does not support IA32. Linux here means glibc: the implementation loads libc.so.6. The workflow explicitly selects six native hosted OS/CPU pairs and verifies stable Dart on each, plus the declared minimum Dart 3.10.0 on Linux x64. Unix tests execute the PTY helper in both JIT and AOT modes; Windows runs the -remaining suite and skips the Unix-specific PTY tests. +remaining suite and skips the Unix-specific PTY tests. Native Unix regressions +compile `test/termios_native.c` with the system `cc` against the runner's actual +headers. Local contributors need a C compiler (Linux build tools or macOS +Command Line Tools) to run these tests; consumers do not need one. + +The macOS ARM64 job also runs a negative control in a temporary source copy: +it restores the old nonvariadic ioctl signature and requires the targeted JIT +and AOT tests to fail with an incorrect native result or a signal-killed helper. +The checked-out production source remains unchanged. -## Unresolved targets +## Potential expansion targets Linux ARM32 and Linux RISC-V have no standard GitHub-hosted runner labels. They are not silently treated as covered by ubuntu-24.04. Completing coverage @@ -20,12 +32,11 @@ runners. Do not run fork PR code on persistent self-hosted machines containing credentials. RISC-V SDK installation also requires verification beyond the setup-dart action's documented architecture list. -Before advertising ARM32 support, correct the pre-existing TermIOS definition: -it uses the macOS layout (44 bytes on a 32-bit ABI), while Linux glibc uses a -60-byte struct. tcgetattr can therefore overrun the allocation on ARM32. -The macOS layout is also incorrect for Linux 64-bit field accesses, although -its larger allocation avoids this specific overflow. This issue predates the -ioctl variadic fix and requires separate platform-specific termios work. +The prior code used the macOS termios layout on Linux. Separate glibc and Darwin +structs now correct that mismatch, and raw-mode flag masks come from libc's +cfmakeraw. Native C regression probes verify the host headers against Dart +fields and PTY mode restoration on the tested Unix targets. ARM32 and RISC-V +still need actual execution before claiming package support. Workflow configuration is not evidence of a completed platform test run. Record actual runs and results before claiming a target passed. diff --git a/example/ffi/ffi_rawmode.dart b/example/ffi/ffi_rawmode.dart index 070492f..f7366e0 100644 --- a/example/ffi/ffi_rawmode.dart +++ b/example/ffi/ffi_rawmode.dart @@ -1,168 +1,20 @@ -// Ignore these lints, since these are UNIX identifiers that we're replicating -// -// ignore_for_file: non_constant_identifier_names, constant_identifier_names - -import 'dart:ffi'; +// Use the package's platform-specific native bindings instead of duplicating +// the incompatible Linux and macOS termios layouts in this example. import 'dart:io'; -import 'package:ffi/ffi.dart'; - -// int tcgetattr(int, struct termios *); -typedef TCGetAttrNative = Int32 Function( - Int32 fildes, Pointer termios); -typedef TCGetAttrDart = int Function(int fildes, Pointer termios); - -// int tcsetattr(int, int, const struct termios *); -typedef TCSetAttrNative = Int32 Function( - Int32 fildes, - Int32 optional_actions, - Pointer termios, -); -typedef TCSetAttrDart = int Function( - int fildes, int optional_actions, Pointer termios); - -const STDIN_FILENO = 0; -const STDOUT_FILENO = 1; -const STDERR_FILENO = 2; - -// INPUT FLAGS -const int IGNBRK = 0x00000001; // ignore BREAK condition -const int BRKINT = 0x00000002; // map BREAK to SIGINTR -const int IGNPAR = 0x00000004; // ignore (discard) parity errors -const int PARMRK = 0x00000008; // mark parity and framing errors -const int INPCK = 0x00000010; // enable checking of parity errors -const int ISTRIP = 0x00000020; // strip 8th bit off chars -const int INLCR = 0x00000040; // map NL into CR -const int IGNCR = 0x00000080; // ignore CR -const int ICRNL = 0x00000100; // map CR to NL (ala CRMOD) -const int IXON = 0x00000200; // enable output flow control -const int IXOFF = 0x00000400; // enable input flow control -const int IXANY = 0x00000800; // any char will restart after stop -const int IMAXBEL = 0x00002000; // ring bell on input queue full -const int IUTF8 = 0x00004000; // maintain state for UTF-8 VERASE - -// OUTPUT FLAGS -const int OPOST = 0x00000001; // enable following output processing -const int ONLCR = 0x00000002; // map NL to CR-NL (ala CRMOD) -const int OXTABS = 0x00000004; // expand tabs to spaces -const int ONOEOT = 0x00000008; // discard EOT's (^D) on output) - -// CONTROL FLAGS -const int CIGNORE = 0x00000001; // ignore control flags -const int CSIZE = 0x00000300; // character size mask -const int CS5 = 0x00000000; // 5 bits (pseudo) -const int CS6 = 0x00000100; // 6 bits -const int CS7 = 0x00000200; // 7 bits -const int CS8 = 0x00000300; // 8 bits - -// LOCAL FLAGS -const int ECHOKE = 0x00000001; // visual erase for line kill -const int ECHOE = 0x00000002; // visually erase chars -const int ECHOK = 0x00000004; // echo NL after line kill -const int ECHO = 0x00000008; // enable echoing -const int ECHONL = 0x00000010; // echo NL even if ECHO is off -const int ECHOPRT = 0x00000020; // visual erase mode for hardcopy -const int ECHOCTL = 0x00000040; // echo control chars as ^(Char) -const int ISIG = 0x00000080; // enable signals INTR, QUIT, [D]SUSP -const int ICANON = 0x00000100; // canonicalize input lines -const int ALTWERASE = 0x00000200; // use alternate WERASE algorithm -const int IEXTEN = 0x00000400; // enable DISCARD and LNEXT -const int EXTPROC = 0x00000800; // external processing -const int TOSTOP = 0x00400000; // stop background jobs from output -const int FLUSHO = 0x00800000; // output being flushed (state) -const int NOKERNINFO = 0x02000000; // no kernel output from VSTATUS -const int PENDIN = 0x20000000; // retype pending input (state) -const int NOFLSH = 0x80000000; // don't flush after interrupt - -const int TCSANOW = 0; // make change immediate -const int TCSADRAIN = 1; // drain output, then change -const int TCSAFLUSH = 2; // drain output, flush input - -const int VMIN = 16; // minimum number of characters to receive -const int VTIME = 17; // time in 1/10s before returning - -// typedef unsigned long tcflag_t; -// typedef unsigned char cc_t; -// typedef unsigned long speed_t; -// #define NCCS 20 - -// struct termios { -// tcflag_t c_iflag; /* input flags */ -// tcflag_t c_oflag; /* output flags */ -// tcflag_t c_cflag; /* control flags */ -// tcflag_t c_lflag; /* local flags */ -// cc_t c_cc[NCCS]; /* control chars */ -// speed_t c_ispeed; /* input speed */ -// speed_t c_ospeed; /* output speed */ -// }; -base class TermIOS extends Struct { - @IntPtr() - external int c_iflag; - @IntPtr() - external int c_oflag; - @IntPtr() - external int c_cflag; - @IntPtr() - external int c_lflag; - - @Array(20) - external Array c_cc; - - @IntPtr() - external int c_ispeed; - @IntPtr() - external int c_ospeed; -} +import 'package:dart_console/src/ffi/unix/termlib_unix.dart'; void main() { - final libc = Platform.isMacOS - ? DynamicLibrary.open('/usr/lib/libSystem.dylib') - : DynamicLibrary.open('libc-2.28.so'); - - final tcgetattr = libc.lookupFunction( - 'tcgetattr', - ); - final tcsetattr = libc.lookupFunction( - 'tcsetattr', - ); - - final origTermIOS = calloc(); - var result = tcgetattr(STDIN_FILENO, origTermIOS); - print('result is $result'); - - print('origTermIOS.c_iflag: 0b${origTermIOS.ref.c_iflag.toRadixString(2)}'); - print('Copying and modifying...'); - - final newTermIOS = calloc() - ..ref.c_iflag = - origTermIOS.ref.c_iflag & ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON) - ..ref.c_oflag = origTermIOS.ref.c_oflag & ~OPOST - ..ref.c_cflag = origTermIOS.ref.c_cflag | CS8 - ..ref.c_lflag = origTermIOS.ref.c_lflag & ~(ECHO | ICANON | IEXTEN | ISIG) - ..ref.c_ispeed = origTermIOS.ref.c_ispeed - ..ref.c_oflag = origTermIOS.ref.c_ospeed - ..ref.c_cc = origTermIOS.ref.c_cc - ..ref.c_cc[VMIN] = 0 // VMIN -- return each byte, or 0 for timeout - ..ref.c_cc[VTIME] = 1; // VTIME -- 100ms timeout (unit is 1/10s) - - print('origTermIOS.c_iflag: 0b${origTermIOS.ref.c_iflag.toRadixString(2)}'); - print('newTermIOS.c_iflag: 0b${newTermIOS.ref.c_iflag.toRadixString(2)}'); - print('origTermIOS.c_oflag: 0b${origTermIOS.ref.c_oflag.toRadixString(2)}'); - print('newTermIOS.c_oflag: 0b${newTermIOS.ref.c_oflag.toRadixString(2)}'); - print('origTermIOS.c_cflag: 0b${origTermIOS.ref.c_cflag.toRadixString(2)}'); - print('newTermIOS.c_cflag: 0b${newTermIOS.ref.c_cflag.toRadixString(2)}'); - print('origTermIOS.c_lflag: 0b${origTermIOS.ref.c_lflag.toRadixString(2)}'); - print('newTermIOS.c_lflag: 0b${newTermIOS.ref.c_lflag.toRadixString(2)}'); - - result = tcsetattr(STDIN_FILENO, TCSAFLUSH, newTermIOS); - print('result is $result\n'); - - print('RAW MODE: Here is some text.\nHere is some more text.'); - result = tcsetattr(STDIN_FILENO, TCSAFLUSH, origTermIOS); - print('result is $result\n'); - - print('\nORIGINAL MODE: Here is some text.\nHere is some more text.'); - - calloc.free(origTermIOS); - calloc.free(newTermIOS); + if (!Platform.isLinux && !Platform.isMacOS || !stdin.hasTerminal) { + stderr.writeln('Run this example in a Linux or macOS terminal.'); + return; + } + final terminal = TermLibUnix(); + terminal.enableRawMode(); + try { + stdout.write('RAW MODE: Here is some text.\nHere is some more text.\r\n'); + } finally { + terminal.disableRawMode(); + } + stdout.writeln('ORIGINAL MODE: Here is some text.'); } diff --git a/lib/src/console.dart b/lib/src/console.dart index 886a6c3..0d98b29 100644 --- a/lib/src/console.dart +++ b/lib/src/console.dart @@ -84,12 +84,12 @@ class Console { /// correct results. /// set rawMode(bool value) { - _isRawMode = value; if (value) { _termlib.enableRawMode(); } else { _termlib.disableRawMode(); } + _isRawMode = value; } /// Returns whether the terminal is in raw mode. diff --git a/lib/src/ffi/unix/termios.dart b/lib/src/ffi/unix/termios.dart index 21d635a..ea90f30 100644 --- a/lib/src/ffi/unix/termios.dart +++ b/lib/src/ffi/unix/termios.dart @@ -8,105 +8,55 @@ import 'dart:ffi'; -// INPUT FLAGS -const int IGNBRK = 0x00000001; // ignore BREAK condition -const int BRKINT = 0x00000002; // map BREAK to SIGINTR -const int IGNPAR = 0x00000004; // ignore (discard) parity errors -const int PARMRK = 0x00000008; // mark parity and framing errors -const int INPCK = 0x00000010; // enable checking of parity errors -const int ISTRIP = 0x00000020; // strip 8th bit off chars -const int INLCR = 0x00000040; // map NL into CR -const int IGNCR = 0x00000080; // ignore CR -const int ICRNL = 0x00000100; // map CR to NL (ala CRMOD) -const int IXON = 0x00000200; // enable output flow control -const int IXOFF = 0x00000400; // enable input flow control -const int IXANY = 0x00000800; // any char will restart after stop -const int IMAXBEL = 0x00002000; // ring bell on input queue full -const int IUTF8 = 0x00004000; // maintain state for UTF-8 VERASE - -// OUTPUT FLAGS -const int OPOST = 0x00000001; // enable following output processing -const int ONLCR = 0x00000002; // map NL to CR-NL (ala CRMOD) -const int OXTABS = 0x00000004; // expand tabs to spaces -const int ONOEOT = 0x00000008; // discard EOT's (^D) on output) - -// CONTROL FLAGS -const int CIGNORE = 0x00000001; // ignore control flags -const int CSIZE = 0x00000300; // character size mask -const int CS5 = 0x00000000; // 5 bits (pseudo) -const int CS6 = 0x00000100; // 6 bits -const int CS7 = 0x00000200; // 7 bits -const int CS8 = 0x00000300; // 8 bits - -// LOCAL FLAGS -const int ECHOKE = 0x00000001; // visual erase for line kill -const int ECHOE = 0x00000002; // visually erase chars -const int ECHOK = 0x00000004; // echo NL after line kill -const int ECHO = 0x00000008; // enable echoing -const int ECHONL = 0x00000010; // echo NL even if ECHO is off -const int ECHOPRT = 0x00000020; // visual erase mode for hardcopy -const int ECHOCTL = 0x00000040; // echo control chars as ^(Char) -const int ISIG = 0x00000080; // enable signals INTR, QUIT, [D]SUSP -const int ICANON = 0x00000100; // canonicalize input lines -const int ALTWERASE = 0x00000200; // use alternate WERASE algorithm -const int IEXTEN = 0x00000400; // enable DISCARD and LNEXT -const int EXTPROC = 0x00000800; // external processing -const int TOSTOP = 0x00400000; // stop background jobs from output -const int FLUSHO = 0x00800000; // output being flushed (state) -const int NOKERNINFO = 0x02000000; // no kernel output from VSTATUS -const int PENDIN = 0x20000000; // retype pending input (state) -const int NOFLSH = 0x80000000; // don't flush after interrupt - -const int TCSANOW = 0; // make change immediate -const int TCSADRAIN = 1; // drain output, then change -const int TCSAFLUSH = 2; // drain output, flush input - -const int VMIN = 16; // minimum number of characters to receive -const int VTIME = 17; // time in 1/10s before returning - -const int TIOCGWINSZ_LINUX = 0x5413; -const int TIOCGWINSZ_MACOS = 0x40087468; - -// typedef unsigned long tcflag_t; -typedef tcflag_t = UnsignedLong; - -// typedef unsigned char cc_t; -typedef cc_t = UnsignedChar; - -// typedef unsigned long speed_t; -typedef speed_t = UnsignedLong; - -// #define NCCS 20 -const _NCCS = 20; - -// struct termios { -// tcflag_t c_iflag; /* input flags */ -// tcflag_t c_oflag; /* output flags */ -// tcflag_t c_cflag; /* control flags */ -// tcflag_t c_lflag; /* local flags */ -// cc_t c_cc[NCCS]; /* control chars */ -// speed_t c_ispeed; /* input speed */ -// speed_t c_ospeed; /* output speed */ -// }; -base class TermIOS extends Struct { - @tcflag_t() +// Layouts from glibc bits/termios-struct.h and Darwin sys/termios.h. +// These are different ABIs, even on the same CPU architecture. +// https://github.com/bminor/glibc/blob/master/sysdeps/unix/sysv/linux/bits/termios-struct.h +// https://github.com/apple-oss-distributions/xnu/blob/main/bsd/sys/termios.h +base class LinuxTermIOS extends Struct { + @UnsignedInt() external int c_iflag; - @tcflag_t() + @UnsignedInt() external int c_oflag; - @tcflag_t() + @UnsignedInt() external int c_cflag; - @tcflag_t() + @UnsignedInt() external int c_lflag; + @UnsignedChar() + external int c_line; + @Array(32) + external Array c_cc; + @UnsignedInt() + external int c_ispeed; + @UnsignedInt() + external int c_ospeed; +} - @Array(_NCCS) - external Array c_cc; - - @speed_t() +base class MacOSTermIOS extends Struct { + @UnsignedLong() + external int c_iflag; + @UnsignedLong() + external int c_oflag; + @UnsignedLong() + external int c_cflag; + @UnsignedLong() + external int c_lflag; + @Array(20) + external Array c_cc; + @UnsignedLong() external int c_ispeed; - @speed_t() + @UnsignedLong() external int c_ospeed; } +// TCSANOW is zero on both supported Unix platforms. +const int TCSANOW = 0; +const int VMIN_LINUX = 6; +const int VTIME_LINUX = 5; +const int VMIN_MACOS = 16; +const int VTIME_MACOS = 17; +const int TIOCGWINSZ_LINUX = 0x5413; +const int TIOCGWINSZ_MACOS = 0x40087468; + // struct winsize { // unsigned short ws_row; // unsigned short ws_col; @@ -125,19 +75,14 @@ base class WinSize extends Struct { } // int tcgetattr(int, struct termios *); -typedef TCGetAttrNative = - Int32 Function(Int32 fildes, Pointer termios); -typedef TCGetAttrDart = int Function(int fildes, Pointer termios); +typedef TCGetAttrNative = Int32 Function(Int32 fildes, Pointer termios); +typedef TCGetAttrDart = int Function(int fildes, Pointer termios); // int tcsetattr(int, int, const struct termios *); typedef TCSetAttrNative = - Int32 Function( - Int32 fildes, - Int32 optional_actions, - Pointer termios, - ); + Int32 Function(Int32 fildes, Int32 optional_actions, Pointer termios); typedef TCSetAttrDart = - int Function(int fildes, int optional_actions, Pointer termios); + int Function(int fildes, int optional_actions, Pointer termios); // int ioctl(int, unsigned long, ...); // The winsize pointer is variadic, which matters for the macOS ARM64 ABI. @@ -149,3 +94,8 @@ typedef IOCtlNative = ); typedef IOCtlDart = int Function(int fildes, int request, Pointer winsize); + +// void cfmakeraw(struct termios *); +// Let libc apply its own platform-specific flag masks. +typedef CFMakeRawNative = Void Function(Pointer termios); +typedef CFMakeRawDart = void Function(Pointer termios); diff --git a/lib/src/ffi/unix/termlib_unix.dart b/lib/src/ffi/unix/termlib_unix.dart index 4f8b75a..1c50bdb 100644 --- a/lib/src/ffi/unix/termlib_unix.dart +++ b/lib/src/ffi/unix/termlib_unix.dart @@ -9,6 +9,7 @@ import 'dart:ffi'; import 'dart:io'; +import 'dart:typed_data'; import 'package:ffi/ffi.dart'; @@ -19,7 +20,13 @@ import 'unistd.dart'; class TermLibUnix implements TermLib { late final DynamicLibrary _stdlib; - late final Pointer _origTermIOSPointer; + // Keep the snapshot in managed memory; temporary native buffers are freed + // on every path. A failed tcgetattr must never become a zeroed restore state. + Uint8List? _originalTermios; + late final CFMakeRawDart _cfmakeraw; + + int get _termiosSize => + Platform.isMacOS ? sizeOf() : sizeOf(); late final TCGetAttrDart tcgetattr; late final TCSetAttrDart tcsetattr; @@ -62,34 +69,39 @@ class TermLibUnix implements TermLib { return width; } - @override - void enableRawMode() { - final origTermIOS = _origTermIOSPointer.ref; - - final newTermIOSPointer = calloc() - ..ref.c_iflag = - origTermIOS.c_iflag & ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON) - ..ref.c_oflag = origTermIOS.c_oflag & ~OPOST - ..ref.c_cflag = (origTermIOS.c_cflag & ~CSIZE) | CS8 - ..ref.c_lflag = origTermIOS.c_lflag & ~(ECHO | ICANON | IEXTEN | ISIG) - ..ref.c_cc = origTermIOS.c_cc - ..ref.c_cc[VMIN] = - 0 // VMIN -- return each byte, or 0 for timeout - ..ref.c_cc[VTIME] = - 1 // VTIME -- 100ms timeout (unit is 1/10s) - ..ref.c_ispeed = origTermIOS.c_ispeed - ..ref.c_oflag = origTermIOS.c_ospeed; - - tcsetattr(STDIN_FILENO, TCSANOW, newTermIOSPointer); - - calloc.free(newTermIOSPointer); + void _applyTermios({required bool raw}) { + final original = _originalTermios; + if (original == null) return; + final buffer = calloc(_termiosSize); + try { + buffer.asTypedList(_termiosSize).setAll(0, original); + if (raw) { + _cfmakeraw(buffer.cast()); + if (Platform.isMacOS) { + final state = buffer.cast().ref; + state.c_cc[VMIN_MACOS] = 0; + state.c_cc[VTIME_MACOS] = 1; + } else { + final state = buffer.cast().ref; + state.c_cc[VMIN_LINUX] = 0; + state.c_cc[VTIME_LINUX] = 1; + } + } + if (tcsetattr(STDIN_FILENO, TCSANOW, buffer.cast()) != 0) { + throw StateError( + 'Could not ${raw ? "enable" : "restore"} terminal mode', + ); + } + } finally { + calloc.free(buffer); + } } @override - void disableRawMode() { - if (nullptr == _origTermIOSPointer.cast()) return; - tcsetattr(STDIN_FILENO, TCSANOW, _origTermIOSPointer); - } + void enableRawMode() => _applyTermios(raw: true); + + @override + void disableRawMode() => _applyTermios(raw: false); TermLibUnix() { _stdlib = Platform.isMacOS @@ -103,9 +115,17 @@ class TermLibUnix implements TermLib { 'tcsetattr', ); ioctl = _stdlib.lookupFunction('ioctl'); + _cfmakeraw = _stdlib.lookupFunction( + 'cfmakeraw', + ); - // store console mode settings so we can return them again as necessary - _origTermIOSPointer = calloc(); - tcgetattr(STDIN_FILENO, _origTermIOSPointer); + final buffer = calloc(_termiosSize); + try { + if (tcgetattr(STDIN_FILENO, buffer.cast()) == 0) { + _originalTermios = Uint8List.fromList(buffer.asTypedList(_termiosSize)); + } + } finally { + calloc.free(buffer); + } } } diff --git a/test/termios_native.c b/test/termios_native.c new file mode 100644 index 0000000..d4c5dda --- /dev/null +++ b/test/termios_native.c @@ -0,0 +1,105 @@ +#include +#include +#include +#include +#include + +#if defined(__APPLE__) +#include +#else +#include +#endif + +size_t helper_termios_size(void) { return sizeof(struct termios); } +size_t helper_iflag_offset(void) { return offsetof(struct termios, c_iflag); } +size_t helper_oflag_offset(void) { return offsetof(struct termios, c_oflag); } +size_t helper_cflag_offset(void) { return offsetof(struct termios, c_cflag); } +size_t helper_lflag_offset(void) { return offsetof(struct termios, c_lflag); } +size_t helper_cc_offset(void) { return offsetof(struct termios, c_cc); } +size_t helper_ispeed_offset(void) { return offsetof(struct termios, c_ispeed); } +size_t helper_ospeed_offset(void) { return offsetof(struct termios, c_ospeed); } + +static void fill(struct termios *value) { + value->c_iflag = 0x11223344; + value->c_oflag = 0x22334455; + value->c_cflag = 0x33445566; + value->c_lflag = 0x44556677; +#if !defined(__APPLE__) + value->c_line = 0x5a; +#endif + for (size_t i = 0; i < NCCS; ++i) value->c_cc[i] = (cc_t)((i * 11 + 3) & 0xff); + value->c_ispeed = 0x55667788; + value->c_ospeed = 0x66778899; +} + +void helper_fill_sentinels(void *value) { fill((struct termios *)value); } + +int helper_validate_sentinels(const void *raw) { + const struct termios *value = (const struct termios *)raw; + struct termios expected; + memset(&expected, 0, sizeof(expected)); + fill(&expected); + if (value->c_iflag != expected.c_iflag || value->c_oflag != expected.c_oflag || + value->c_cflag != expected.c_cflag || value->c_lflag != expected.c_lflag || + value->c_ispeed != expected.c_ispeed || value->c_ospeed != expected.c_ospeed) + return 0; +#if !defined(__APPLE__) + if (value->c_line != expected.c_line) return 0; +#endif + return memcmp(value->c_cc, expected.c_cc, NCCS) == 0; +} + +int helper_open_pty(int *master, int *slave) { return openpty(master, slave, NULL, NULL, NULL); } +int helper_close_fd(int fd) { return close(fd); } +int helper_dup_fd(int fd) { return dup(fd); } +int helper_dup2_fd(int from, int to) { return dup2(from, to); } + +int helper_configure_terminal(int fd) { + struct termios value; + if (tcgetattr(fd, &value) != 0) return 0; + value.c_iflag |= IGNBRK | BRKINT | PARMRK | INPCK | ISTRIP | INLCR | IGNCR | ICRNL | IXON; + value.c_oflag |= OPOST; + value.c_cflag |= CS8 | PARENB; + value.c_lflag |= ECHO | ECHONL | ICANON | ISIG | IEXTEN; + value.c_cc[VERASE] = 0x08; + value.c_cc[VINTR] = 0x03; + if (cfsetispeed(&value, B9600) != 0 || cfsetospeed(&value, B19200) != 0) return 0; + return tcsetattr(fd, TCSANOW, &value) == 0; +} + +int helper_snapshot(int fd, void *raw) { + return tcgetattr(fd, (struct termios *)raw) == 0; +} + +int helper_raw_matches(int fd, const void *raw_original) { + struct termios current; + const struct termios *original = (const struct termios *)raw_original; + if (tcgetattr(fd, ¤t) != 0) return 0; + if (current.c_iflag & (BRKINT | PARMRK | ISTRIP | INLCR | IGNCR | ICRNL | IXON)) return 0; +#if defined(__APPLE__) + if ((current.c_iflag & IGNBRK) == 0 || (current.c_iflag & INPCK)) return 0; +#else + if ((current.c_iflag & IGNBRK) || (current.c_iflag & INPCK) == 0) return 0; +#endif + if (current.c_oflag & OPOST) return 0; + if ((current.c_cflag & CSIZE) != CS8 || (current.c_cflag & PARENB)) return 0; + if (current.c_lflag & (ECHO | ECHONL | ICANON | ISIG | IEXTEN)) return 0; + if (current.c_cc[VMIN] != 0 || current.c_cc[VTIME] != 1) return 0; + return cfgetispeed(¤t) == cfgetispeed(original) && + cfgetospeed(¤t) == cfgetospeed(original) && + current.c_ispeed == original->c_ispeed && current.c_ospeed == original->c_ospeed; +} + +int helper_matches_snapshot(int fd, const void *raw_original) { + struct termios current; + const struct termios *original = (const struct termios *)raw_original; + if (tcgetattr(fd, ¤t) != 0) return 0; + if (current.c_iflag != original->c_iflag || current.c_oflag != original->c_oflag || + current.c_cflag != original->c_cflag || current.c_lflag != original->c_lflag || + current.c_ispeed != original->c_ispeed || current.c_ospeed != original->c_ospeed) + return 0; +#if !defined(__APPLE__) + if (current.c_line != original->c_line) return 0; +#endif + return memcmp(current.c_cc, original->c_cc, NCCS) == 0; +} diff --git a/test/termios_process.dart b/test/termios_process.dart new file mode 100644 index 0000000..d7aa350 --- /dev/null +++ b/test/termios_process.dart @@ -0,0 +1,283 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:ffi/ffi.dart'; +import 'package:dart_console/src/ffi/unix/termlib_unix.dart'; +import 'package:dart_console/src/ffi/unix/termios.dart'; + +Never _fail(String message) => throw StateError(message); + +void _check(bool condition, String message) { + if (!condition) _fail(message); +} + +void _expectStateError(String label, void Function() action) { + try { + action(); + } on StateError { + return; + } + _fail('$label did not throw StateError'); +} + +typedef _SizeNative = Size Function(); +typedef _SizeDart = int Function(); +typedef _FillNative = Void Function(Pointer); +typedef _FillDart = void Function(Pointer); +typedef _ValidateNative = Int32 Function(Pointer); +typedef _ValidateDart = int Function(Pointer); +typedef _OpenPtyNative = Int32 Function(Pointer, Pointer); +typedef _OpenPtyDart = int Function(Pointer, Pointer); +typedef _FdNative = Int32 Function(Int32); +typedef _FdDart = int Function(int); +typedef _Dup2Native = Int32 Function(Int32, Int32); +typedef _Dup2Dart = int Function(int, int); +typedef _ConfigureNative = Int32 Function(Int32); +typedef _ConfigureDart = int Function(int); +typedef _SnapshotNative = Int32 Function(Int32, Pointer); +typedef _SnapshotDart = int Function(int, Pointer); +typedef _CompareNative = Int32 Function(Int32, Pointer); +typedef _CompareDart = int Function(int, Pointer); + +class _Native { + _Native(String path) : library = DynamicLibrary.open(path); + final DynamicLibrary library; + + int size(String name) => + library.lookupFunction<_SizeNative, _SizeDart>(name)(); + _FillDart get fill => + library.lookupFunction<_FillNative, _FillDart>('helper_fill_sentinels'); + _ValidateDart get validate => + library.lookupFunction<_ValidateNative, _ValidateDart>( + 'helper_validate_sentinels', + ); + _OpenPtyDart get openPty => + library.lookupFunction<_OpenPtyNative, _OpenPtyDart>('helper_open_pty'); + _FdDart get closeFd => + library.lookupFunction<_FdNative, _FdDart>('helper_close_fd'); + _FdDart get dupFd => + library.lookupFunction<_FdNative, _FdDart>('helper_dup_fd'); + _Dup2Dart get dup2Fd => + library.lookupFunction<_Dup2Native, _Dup2Dart>('helper_dup2_fd'); + _ConfigureDart get configure => + library.lookupFunction<_ConfigureNative, _ConfigureDart>( + 'helper_configure_terminal', + ); + _SnapshotDart get snapshot => + library.lookupFunction<_SnapshotNative, _SnapshotDart>('helper_snapshot'); + _CompareDart get rawMatches => library + .lookupFunction<_CompareNative, _CompareDart>('helper_raw_matches'); + _CompareDart get snapshotMatches => library + .lookupFunction<_CompareNative, _CompareDart>('helper_matches_snapshot'); +} + +void _checkLayout(_Native native) { + final size = native.size('helper_termios_size'); + final buffer = calloc(size + 16); + try { + if (Platform.isMacOS) { + _check( + sizeOf() == size, + 'MacOSTermIOS size differs from native sizeof(termios)', + ); + final value = buffer.cast(); + value.ref + ..c_iflag = 0x11223344 + ..c_oflag = 0x22334455 + ..c_cflag = 0x33445566 + ..c_lflag = 0x44556677 + ..c_ispeed = 0x55667788 + ..c_ospeed = 0x66778899; + for (var i = 0; i < 20; i++) { + value.ref.c_cc[i] = (i * 11 + 3) & 0xff; + } + _check( + native.validate(buffer.cast()) == 1, + 'C did not read the Dart macOS termios sentinels at native field offsets', + ); + buffer.asTypedList(size).fillRange(0, size, 0); + native.fill(buffer.cast()); + _check( + value.ref.c_iflag == 0x11223344 && + value.ref.c_oflag == 0x22334455 && + value.ref.c_cflag == 0x33445566 && + value.ref.c_lflag == 0x44556677, + 'Dart could not read native macOS termios flags', + ); + _check( + value.ref.c_ispeed == 0x55667788 && value.ref.c_ospeed == 0x66778899, + 'Dart could not read native macOS termios speeds', + ); + for (var i = 0; i < 20; i++) { + _check( + value.ref.c_cc[i] == ((i * 11 + 3) & 0xff), + 'Dart macOS control character $i has the wrong native offset', + ); + } + } else { + _check( + sizeOf() == size, + 'LinuxTermIOS size differs from native sizeof(termios)', + ); + final value = buffer.cast(); + value.ref + ..c_iflag = 0x11223344 + ..c_oflag = 0x22334455 + ..c_cflag = 0x33445566 + ..c_lflag = 0x44556677 + ..c_line = 0x5a + ..c_ispeed = 0x55667788 + ..c_ospeed = 0x66778899; + for (var i = 0; i < 32; i++) { + value.ref.c_cc[i] = (i * 11 + 3) & 0xff; + } + _check( + native.validate(buffer.cast()) == 1, + 'C did not read the Dart Linux termios sentinels at native field offsets', + ); + buffer.asTypedList(size).fillRange(0, size, 0); + native.fill(buffer.cast()); + _check( + value.ref.c_iflag == 0x11223344 && + value.ref.c_oflag == 0x22334455 && + value.ref.c_cflag == 0x33445566 && + value.ref.c_lflag == 0x44556677 && + value.ref.c_line == 0x5a, + 'Dart could not read native Linux termios flags or c_line', + ); + _check( + value.ref.c_ispeed == 0x55667788 && value.ref.c_ospeed == 0x66778899, + 'Dart could not read native Linux termios speeds', + ); + for (var i = 0; i < 32; i++) { + _check( + value.ref.c_cc[i] == ((i * 11 + 3) & 0xff), + 'Dart Linux control character $i has the wrong native offset', + ); + } + } + } finally { + calloc.free(buffer); + } +} + +void _checkCanaryAndRawMode(_Native native) { + final originalStdin = native.dupFd(0); + _check(originalStdin >= 0, 'dup(stdin) failed'); + final masterPointer = calloc(); + final slavePointer = calloc(); + final structSize = Platform.isMacOS + ? sizeOf() + : sizeOf(); + final nativeSize = native.size('helper_termios_size'); + _check( + structSize == nativeSize, + 'Dart termios size differs from native sizeof(termios)', + ); + final original = calloc(structSize); + int master = -1; + int slave = -1; + try { + _check(native.openPty(masterPointer, slavePointer) == 0, 'openpty failed'); + master = masterPointer.value; + slave = slavePointer.value; + _check( + native.configure(slave) == 1, + 'could not configure PTY terminal modes and speeds', + ); + + final guardSize = structSize + 16; + final guarded = calloc(guardSize); + try { + for (var i = guardSize - 16; i < guardSize; i++) { + guarded[i] = 0xa5; + } + final terminal = TermLibUnix(); + _check( + terminal.tcgetattr(slave, guarded.cast()) == 0, + 'tcgetattr on PTY slave failed', + ); + for (var i = guardSize - 16; i < guardSize; i++) { + _check( + guarded[i] == 0xa5, + 'tcgetattr overwrote the trailing allocation canary at byte $i', + ); + } + } finally { + calloc.free(guarded); + } + + _check( + native.snapshot(slave, original.cast()) == 1, + 'could not snapshot original PTY termios', + ); + final terminalWithoutSnapshot = TermLibUnix(); + _check(native.dup2Fd(slave, 0) == 0, 'dup2(PTY slave, stdin) failed'); + terminalWithoutSnapshot.enableRawMode(); + _check( + native.snapshotMatches(0, original.cast()) == 1, + 'enableRawMode changed the PTY after its initial stdin snapshot failed', + ); + terminalWithoutSnapshot.disableRawMode(); + _check( + native.snapshotMatches(0, original.cast()) == 1, + 'disableRawMode changed the PTY after its initial stdin snapshot failed', + ); + final terminal = TermLibUnix(); + for (var cycle = 0; cycle < 2; cycle++) { + terminal.enableRawMode(); + _check( + native.rawMatches(0, original.cast()) == 1, + 'raw mode cycle $cycle does not match native termios.h flag and VMIN/VTIME expectations', + ); + terminal.disableRawMode(); + _check( + native.snapshotMatches(0, original.cast()) == 1, + 'disableRawMode cycle $cycle did not restore every original native terminal field', + ); + } + _check( + native.closeFd(0) == 0, + 'could not close stdin for the tcsetattr error-path check', + ); + _expectStateError( + 'enableRawMode after stdin closes', + terminal.enableRawMode, + ); + _expectStateError( + 'disableRawMode after stdin closes', + terminal.disableRawMode, + ); + } finally { + native.dup2Fd(originalStdin, 0); + native.closeFd(originalStdin); + if (slave >= 0) native.closeFd(slave); + if (master >= 0) native.closeFd(master); + calloc.free(masterPointer); + calloc.free(slavePointer); + calloc.free(original); + } +} + +void main(List arguments) { + if (!Platform.isLinux && !Platform.isMacOS) return; + if (arguments.length != 2) { + _fail('usage: termios_process.dart '); + } + final native = _Native(arguments[1]); + switch (arguments[0]) { + case 'layout': + _checkLayout(native); + break; + case 'pty': + _checkCanaryAndRawMode(native); + break; + case 'no-tty': + final terminal = TermLibUnix(); + terminal.enableRawMode(); + terminal.disableRawMode(); + break; + default: + _fail('unknown termios regression case: ${arguments[0]}'); + } +} diff --git a/test/termios_test.dart b/test/termios_test.dart new file mode 100644 index 0000000..1e8e0a3 --- /dev/null +++ b/test/termios_test.dart @@ -0,0 +1,84 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:test/test.dart'; + +Future _runChild(String executable, List arguments) async { + final process = await Process.start(executable, arguments); + final stdout = process.stdout.transform(utf8.decoder).join(); + final stderr = process.stderr.transform(utf8.decoder).join(); + try { + await process.stdin.close(); + final result = await Future.wait([ + process.exitCode, + stdout, + stderr, + ]).timeout(const Duration(seconds: 60)); + expect(result[0], 0, reason: '${result[1]}${result[2]}'); + return '${result[1]}${result[2]}'.trim(); + } finally { + process.kill(ProcessSignal.sigkill); + await process.exitCode; + } +} + +Future _compileNative(String output) async { + final args = [ + if (Platform.isMacOS) '-dynamiclib' else ...['-shared', '-fPIC'], + 'test/termios_native.c', + '-o', + output, + if (Platform.isLinux) '-lutil', + ]; + await _runChild('cc', args); +} + +void main() { + group( + 'native Unix termios ABI and raw mode', + () { + late Directory temporary; + late String nativeLibrary; + late String aotExecutable; + + setUpAll(() async { + temporary = await Directory.systemTemp.createTemp( + 'dart-console-termios-', + ); + addTearDown(() => temporary.delete(recursive: true)); + nativeLibrary = + '${temporary.path}/termios.${Platform.isMacOS ? 'dylib' : 'so'}'; + await _compileNative(nativeLibrary); + aotExecutable = '${temporary.path}/termios-process'; + await _runChild(Platform.resolvedExecutable, [ + 'compile', + 'exe', + 'test/termios_process.dart', + '-o', + aotExecutable, + ]); + }); + + for (final aot in [false, true]) { + for (final testCase in ['layout', 'pty', 'no-tty']) { + test( + '${aot ? 'AOT' : 'JIT'} $testCase matches the native Unix ABI', + () async { + await _runChild( + aot ? aotExecutable : Platform.resolvedExecutable, + [ + if (!aot) 'test/termios_process.dart', + testCase, + nativeLibrary, + ], + ); + }, + ); + } + } + }, + skip: !Platform.isLinux && !Platform.isMacOS, + timeout: const Timeout(Duration(minutes: 2)), + ); +} From bc9caf9aee724056bf63c18850c3d1287279a92b Mon Sep 17 00:00:00 2001 From: Brett Sutton Date: Fri, 2 Oct 2026 14:14:11 +1000 Subject: [PATCH 5/5] Account for Darwin terminal state and bound subprocess tests --- test/read_keys_test.dart | 87 ++++++++++++++++++++++------------------ test/termios_native.c | 51 ++++++++++++++++++++--- 2 files changed, 94 insertions(+), 44 deletions(-) diff --git a/test/read_keys_test.dart b/test/read_keys_test.dart index d115901..2911368 100644 --- a/test/read_keys_test.dart +++ b/test/read_keys_test.dart @@ -5,34 +5,36 @@ import 'dart:io'; import 'package:test/test.dart'; void main() { - test('readKeys streams printable and control keys', () async { - final keys = await _readKeys([0x61, 0x03, 0x7f], expectedKeys: 3); - - expect(keys, ['printable:a', 'control:ctrlC', 'control:backspace']); - }); - - test('readKeys streams escape sequences', () async { - final keys = await _readKeys([ - 0x1b, - 0x5b, - 0x41, - 0x1b, - 0x5b, - 0x33, - 0x7e, - 0x1b, - 0x4f, - 0x50, - ], expectedKeys: 3); - - expect(keys, ['control:arrowUp', 'control:delete', 'control:F1']); - }); - - test('readKeys streams escape key when no sequence follows', () async { - final keys = await _readKeys([0x1b], expectedKeys: 1); - - expect(keys, ['control:escape']); - }); + group('readKeys subprocess', () { + test('readKeys streams printable and control keys', () async { + final keys = await _readKeys([0x61, 0x03, 0x7f], expectedKeys: 3); + + expect(keys, ['printable:a', 'control:ctrlC', 'control:backspace']); + }); + + test('readKeys streams escape sequences', () async { + final keys = await _readKeys([ + 0x1b, + 0x5b, + 0x41, + 0x1b, + 0x5b, + 0x33, + 0x7e, + 0x1b, + 0x4f, + 0x50, + ], expectedKeys: 3); + + expect(keys, ['control:arrowUp', 'control:delete', 'control:F1']); + }); + + test('readKeys streams escape key when no sequence follows', () async { + final keys = await _readKeys([0x1b], expectedKeys: 1); + + expect(keys, ['control:escape']); + }); + }, timeout: const Timeout(Duration(minutes: 2))); } Future> _readKeys( @@ -40,22 +42,31 @@ Future> _readKeys( required int expectedKeys, }) async { final process = await Process.start(Platform.resolvedExecutable, [ - 'run', 'test/read_keys_process.dart', '$expectedKeys', ], workingDirectory: Directory.current.path); - process.stdin.add(bytes); - await process.stdin.close(); - final stdout = process.stdout .transform(utf8.decoder) .transform(const LineSplitter()) .toList(); - final stderr = await process.stderr.transform(utf8.decoder).join(); - final output = await stdout.timeout(const Duration(seconds: 10)); - final exitCode = await process.exitCode.timeout(const Duration(seconds: 10)); - - expect(exitCode, 0, reason: stderr); - return output; + final stderr = process.stderr.transform(utf8.decoder).join(); + try { + process.stdin.add(bytes); + await process.stdin.close(); + // Include cold JIT startup in the bounded wait, and drain both pipes + // concurrently. The outer test timeout must leave time for cleanup. + final result = await Future.wait([ + process.exitCode, + stdout, + stderr, + ]).timeout(const Duration(seconds: 60)); + expect(result[0], 0, reason: result[2] as String); + return result[1] as List; + } finally { + process.kill( + Platform.isWindows ? ProcessSignal.sigterm : ProcessSignal.sigkill, + ); + await process.exitCode; + } } diff --git a/test/termios_native.c b/test/termios_native.c index d4c5dda..7c7c458 100644 --- a/test/termios_native.c +++ b/test/termios_native.c @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -94,12 +95,50 @@ int helper_matches_snapshot(int fd, const void *raw_original) { struct termios current; const struct termios *original = (const struct termios *)raw_original; if (tcgetattr(fd, ¤t) != 0) return 0; - if (current.c_iflag != original->c_iflag || current.c_oflag != original->c_oflag || - current.c_cflag != original->c_cflag || current.c_lflag != original->c_lflag || - current.c_ispeed != original->c_ispeed || current.c_ospeed != original->c_ospeed) - return 0; + int matches = 1; +#define CHECK_TERM_FIELD(field) \ + do { \ + if (current.field != original->field) { \ + fprintf(stderr, "termios restore mismatch: %s actual=0x%llx expected=0x%llx\n", \ + #field, (unsigned long long)current.field, \ + (unsigned long long)original->field); \ + matches = 0; \ + } \ + } while (0) + CHECK_TERM_FIELD(c_iflag); + CHECK_TERM_FIELD(c_oflag); + CHECK_TERM_FIELD(c_cflag); +#if defined(__APPLE__) + // Darwin's tty driver sets PENDIN when restoring canonical mode; it is kernel + // state rather than a termios setting. See xnu bsd/kern/tty.c, lines 1313-1345: + // https://raw.githubusercontent.com/apple-oss-distributions/xnu/main/bsd/kern/tty.c + if ((current.c_lflag & ~PENDIN) != (original->c_lflag & ~PENDIN)) { + fprintf(stderr, "termios restore mismatch: c_lflag actual=0x%llx expected=0x%llx (ignoring Darwin PENDIN only)\n", + (unsigned long long)current.c_lflag, + (unsigned long long)original->c_lflag); + matches = 0; + } +#else + CHECK_TERM_FIELD(c_lflag); +#endif + CHECK_TERM_FIELD(c_ispeed); + CHECK_TERM_FIELD(c_ospeed); +#undef CHECK_TERM_FIELD #if !defined(__APPLE__) - if (current.c_line != original->c_line) return 0; + if (current.c_line != original->c_line) { + fprintf(stderr, "termios restore mismatch: c_line actual=0x%llx expected=0x%llx\n", + (unsigned long long)current.c_line, + (unsigned long long)original->c_line); + matches = 0; + } #endif - return memcmp(current.c_cc, original->c_cc, NCCS) == 0; + for (size_t i = 0; i < NCCS; ++i) { + if (current.c_cc[i] != original->c_cc[i]) { + fprintf(stderr, "termios restore mismatch: c_cc[%zu] actual=0x%llx expected=0x%llx\n", + i, (unsigned long long)current.c_cc[i], + (unsigned long long)original->c_cc[i]); + matches = 0; + } + } + return matches; }