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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions .github/workflows/linux-display-smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
name: Linux Display Smoke

on:
workflow_dispatch:
inputs:
ref:
description: Git ref to test
required: false
type: string
x11-display:
description: X11 display exposed to the runner
required: false
default: ':0'
type: string

jobs:
quickstart:
name: Quickstart viewer (X11 + Wayland)
runs-on: [self-hosted, linux, x64]
timeout-minutes: 30
env:
# Fall back to :0 (the runner's desktop session display) when the input
# is not provided.
DISPLAY: ${{ inputs.x11-display || ':0' }}
defaults:
run:
working-directory: examples/flutter/quickstart
shell: bash
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}

- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.44.8'
channel: stable
architecture: X64
cache: true
pub-cache: true

- name: Install Linux dependencies
working-directory: .
run: |
if command -v apt-get >/dev/null; then
sudo apt-get update -y
sudo apt-get install -y \
clang cmake ninja-build pkg-config \
libgtk-3-dev liblzma-dev libdrm-dev \
libegl1 libegl1-mesa-dev libc++-dev libc++abi-dev \
weston x11-utils
elif command -v dnf >/dev/null; then
# Fedora self-hosted runner. Dependencies are preinstalled on the
# image; only fetch missing ones, and only with passwordless sudo.
if ! sudo -n true 2>/dev/null; then
echo '::notice::No passwordless sudo; assuming dependencies are preinstalled'
exit 0
fi
sudo dnf install -y \
clang cmake ninja-build pkgconf-pkg-config \
gtk3-devel libdrm-devel mesa-libEGL-devel xz-devel \
libcxx-devel libcxxabi-devel \
weston xdpyinfo
else
echo '::warning::No apt-get or dnf found; assuming dependencies are preinstalled'
fi

# Prime generated headers before the example's CMake configure step.
- name: Run thermion_flutter unit tests
working-directory: thermion_flutter/thermion_flutter
run: |
flutter pub get
flutter test

- name: Build quickstart
run: |
flutter pub get
flutter build linux

- name: Verify GPU and X11 session
run: |
if [[ ! -r /dev/dri/renderD128 || ! -w /dev/dri/renderD128 ]]; then
echo '::error::The runner user needs read/write access to /dev/dri/renderD128'
exit 1
fi
if ! xdpyinfo >/dev/null; then
echo "::error::Start the runner from the logged-in X11 session or provide a usable x11-display input (currently ${DISPLAY})"
exit 1
fi

- name: Run viewer smoke test (X11)
run: |
set -o pipefail
GDK_BACKEND=x11 timeout 8m flutter test \
integration_test/display_server_smoke_test.dart \
-d linux 2>&1 | tee "${RUNNER_TEMP}/display-smoke-x11.log"

- name: Run viewer smoke test (Wayland)
run: |
set -o pipefail
export XDG_RUNTIME_DIR="${RUNNER_TEMP}/wayland-runtime"
export WAYLAND_DISPLAY=wayland-ci
mkdir -p "${XDG_RUNTIME_DIR}"
chmod 700 "${XDG_RUNTIME_DIR}"

weston \
--backend=headless-backend.so \
--use-gl \
--no-config \
--socket="${WAYLAND_DISPLAY}" \
--width=1280 \
--height=720 \
--idle-time=0 \
--log="${RUNNER_TEMP}/weston.log" &
weston_pid=$!
trap 'kill "${weston_pid}" 2>/dev/null || true' EXIT

for _ in {1..50}; do
if [[ -S "${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY}" ]]; then
break
fi
if ! kill -0 "${weston_pid}" 2>/dev/null; then
cat "${RUNNER_TEMP}/weston.log"
exit 1
fi
sleep 0.1
done
test -S "${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY}"

unset DISPLAY
GDK_BACKEND=wayland timeout 8m flutter test \
integration_test/display_server_smoke_test.dart \
-d linux 2>&1 | tee "${RUNNER_TEMP}/display-smoke-wayland.log"

- name: Upload display-server logs
if: failure()
uses: actions/upload-artifact@v4
with:
name: linux-display-smoke-logs
path: |
${{ runner.temp }}/display-smoke-*.log
${{ runner.temp }}/weston.log
retention-days: 5
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import 'dart:async';

import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:quickstart/main.dart';
import 'package:thermion_flutter/thermion_flutter.dart';

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

testWidgets(
'adds, initializes, renders, and removes a viewer',
(tester) async {
await tester.pumpWidget(const MyApp());
expect(find.text('No viewers mounted'), findsOneWidget);

// Exercise the real quickstart interaction instead of mounting a
// ViewerWidget directly in the test.
await tester.tap(find.text('Add'));
await tester.pump();
expect(find.byType(ViewerWidget), findsOneWidget);

await _pumpUntil(tester, find.byKey(const ValueKey('viewer-ready-1')));

// Keep the native render loop alive for several frames after the viewer
// callback. Startup-only success is not enough: the EGL transport must
// remain usable once Flutter begins consuming frames.
for (var frame = 0; frame < 30; frame++) {
await tester.pump(const Duration(milliseconds: 16));
}
expect(tester.takeException(), isNull);

await tester.tap(find.text('Remove'));
await _pumpUntil(tester, find.byType(ViewerWidget), present: false);
expect(tester.takeException(), isNull);
},
timeout: const Timeout(Duration(minutes: 3)),
);
}

Future<void> _pumpUntil(
WidgetTester tester,
Finder finder, {
bool present = true,
Duration timeout = const Duration(seconds: 90),
}) async {
final stopwatch = Stopwatch()..start();
while ((finder.evaluate().isNotEmpty != present) &&
stopwatch.elapsed < timeout) {
await tester.pump(const Duration(milliseconds: 16));
}
if (finder.evaluate().isNotEmpty != present) {
throw TimeoutException(
'Timed out waiting for ${finder.describeMatch(Plurality.one)} to be '
'${present ? 'present' : 'absent'}',
timeout,
);
}
}
21 changes: 19 additions & 2 deletions examples/flutter/quickstart/integration_test/lifecycle_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@ import 'package:thermion_flutter/src/platform/src/frame_scheduler.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

Future<void> pumpUntilCompleted(
WidgetTester tester,
Completer<void> completion, {
Duration timeout = const Duration(seconds: 30),
}) async {
final stopwatch = Stopwatch()..start();
while (!completion.isCompleted && stopwatch.elapsed < timeout) {
// Linux OpenGL initialization needs a frame containing the deferred
// bootstrap Texture before Filament can import Flutter's EGL context.
await tester.pump(const Duration(milliseconds: 16));
}
if (!completion.isCompleted) {
throw TimeoutException('Future not completed', timeout);
}
await completion.future;
}

Future<void> pumpViewer(WidgetTester tester) async {
final sun = DirectLight.sun(direction: Vector3(0.7, -1, -0.8).normalized());
await tester.pumpWidget(
Expand Down Expand Up @@ -247,11 +264,11 @@ void main() {
),
);

await available.future.timeout(const Duration(seconds: 30));
await pumpUntilCompleted(tester, available);
await tester.pump();

await tester.pumpWidget(const SizedBox.shrink());
await disposalStarted.future.timeout(const Duration(seconds: 30));
await pumpUntilCompleted(tester, disposalStarted);

// Viewer disposal continues with scene/view/camera destruction after
// onDispose callbacks. Give that render-thread work time to drain before
Expand Down
7 changes: 7 additions & 0 deletions examples/flutter/quickstart/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,13 @@ class _ViewerTileState extends State<_ViewerTile> {
),
),
),
// Exposes completion of the real ViewerWidget initialization to
// native integration tests without changing the visible UI.
if (_viewer != null)
KeyedSubtree(
key: ValueKey('viewer-ready-${widget.index}'),
child: const SizedBox.shrink(),
),
// Top scrim so overlay controls stay legible on bright skyboxes.
const Positioned(
left: 0,
Expand Down
14 changes: 11 additions & 3 deletions thermion_dart/native/include/opengl/linux/LinuxOpenGLContext.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,29 @@ namespace thermion::opengl::linux_platform {
* PlatformEGL can create its own context in the same share group — GL texture
* IDs are then valid in both contexts.
*
* GetPlatform() returns nullptr so Filament auto-creates a default PlatformEGL.
* GetPlatform() returns Thermion's EGLHeadless platform bound to the same
* EGLDisplay as the producer context.
*/
class LinuxOpenGLContext {
public:
LinuxOpenGLContext();
// If eglDisplay is non-null, it is borrowed and must already be
// initialized. This is the preferred Flutter path: Filament gets a
// desktop-GL context on Flutter's existing EGLDisplay without starting a
// second NVIDIA EGL display alongside the raster thread.
explicit LinuxOpenGLContext(void* eglDisplay = nullptr);
~LinuxOpenGLContext();

bool IsValid() const;
const char* GetLastError() const;

int64_t CreateRenderingSurface(uint32_t width, uint32_t height);
void DestroyRenderingSurface(int64_t surfaceId);

uint32_t GetGLTextureId(int64_t surfaceId);
SurfaceExportInfo GetSurfaceExportInfo(int64_t surfaceId);

void* GetSharedContext(); // Returns our EGLContext for Filament sharing
void* GetPlatform(); // Returns nullptr (Filament creates default PlatformEGL)
void* GetPlatform(); // Returns ThermionPlatformEGLHeadless

private:
class Impl;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class LinuxOpenGLTexture {
~LinuxOpenGLTexture();

static std::unique_ptr<LinuxOpenGLTexture> create(
EGLDisplay display, EGLContext context,
EGLDisplay display, EGLContext context, EGLSurface surface,
struct gbm_device* gbm, uint32_t width, uint32_t height);

GLuint GetGLTextureId() const { return _glTextureId; }
Expand Down Expand Up @@ -58,6 +58,8 @@ class LinuxOpenGLTexture {

// Store display for cleanup
EGLDisplay _display = EGL_NO_DISPLAY;
EGLContext _context = EGL_NO_CONTEXT;
EGLSurface _surface = EGL_NO_SURFACE;
};

} // namespace thermion::opengl::linux_platform
Loading
Loading