diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 2f78dd4..80bd485 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -27,8 +27,54 @@ set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) set(BUILD_GAMES OFF CACHE BOOL "" FORCE) set(CUSTOMIZE_BUILD ON CACHE BOOL "" FORCE) +# raylib 6.0 reads every SUPPORT_ flag in its config.h into an option that defaults to ON, the ones +# config.h defines as 0 included (raysan5/raylib#5844, fixed after 6.0), and CUSTOMIZE_BUILD skips +# config.h's own values. So each of those is on unless it is switched off here. +# SUPPORT_CUSTOM_FRAME_CONTROL was: EndDrawing then neither puts the frame on the screen, reads +# input nor waits for the next frame, and bm_present leaves all three to it for every frame it +# draws. So the Linux window stayed black, and from the first input bm_present drew one frame +# after another with no wait between them and read nothing more. A capture draws into a texture +# and never gets that far, which is why the pixel snapshots passed throughout; +# PixelTests.PresentWaitsForTheNextFrame is what notices. The busy wait loop was on the same way, +# and WaitTime is how bm_present waits out a frame it does not draw, so a tray with its window +# hidden spun through every one of them: a seventh of a core, for as long as it ran. The rest are +# formats and features nothing here uses. +foreach(flag IN ITEMS + SUPPORT_CUSTOM_FRAME_CONTROL + SUPPORT_BUSY_WAIT_LOOP + SUPPORT_GPU_SKINNING + SUPPORT_FILEFORMAT_TGA + SUPPORT_FILEFORMAT_JPG + SUPPORT_FILEFORMAT_PSD + SUPPORT_FILEFORMAT_HDR + SUPPORT_FILEFORMAT_PIC + SUPPORT_FILEFORMAT_PNM + SUPPORT_FILEFORMAT_KTX + SUPPORT_FILEFORMAT_ASTC + SUPPORT_FILEFORMAT_PKM + SUPPORT_FILEFORMAT_PVR + SUPPORT_FILEFORMAT_BDF + SUPPORT_FILEFORMAT_FLAC) + set(${flag} OFF CACHE BOOL "" FORCE) +endforeach() +# raylib spins for the last part of every wait by default, for timing precision nothing here +# needs: most of a millisecond of a core per frame, for as long as the tray runs. +set(SUPPORT_PARTIALBUSY_WAIT_LOOP OFF CACHE BOOL "" FORCE) +# F12 saves a screenshot into the working directory, which is wherever the tray was started from. +# raylib looks for the key at the end of a frame it has drawn, so that is F12 pressed while frames +# are being drawn, as they are for as long as the pointer is crossing the window. +set(SUPPORT_SCREEN_CAPTURE OFF CACHE BOOL "" FORCE) set(SUPPORT_MODULE_RAUDIO OFF CACHE BOOL "" FORCE) set(SUPPORT_MODULE_RMODELS OFF CACHE BOOL "" FORCE) +# PNG is the one picture format anything here reads or writes: a row's icons arrive as PNG, and a +# capture is saved as one. The other four that config.h has on, nothing here loads or saves. +foreach(flag IN ITEMS + SUPPORT_FILEFORMAT_BMP + SUPPORT_FILEFORMAT_GIF + SUPPORT_FILEFORMAT_QOI + SUPPORT_FILEFORMAT_DDS) + set(${flag} OFF CACHE BOOL "" FORCE) +endforeach() set(SUPPORT_FILEFORMAT_PNG ON CACHE BOOL "" FORCE) FetchContent_Declare( diff --git a/native/src/bm.cpp b/native/src/bm.cpp index 4e4aaaa..5dfb2bc 100644 --- a/native/src/bm.cpp +++ b/native/src/bm.cpp @@ -1576,7 +1576,12 @@ BM_API int32_t bm_init(int32_t width, int32_t height, const char* title, const u SetTraceLogLevel(LOG_WARNING); // Always made hidden, and shown once it is where it was left: made visible, it would first // appear centred and then jump. - SetConfigFlags(FLAG_WINDOW_RESIZABLE | FLAG_VSYNC_HINT | FLAG_WINDOW_HIDDEN); + // ALWAYS_RUN because PollInputEvents otherwise waits for an event for as long as the window is + // minimised, and every bm_present makes that call. The managed loop stopped there with it: + // the tray's icon and menu stood still, no notification was shown, a click on the tray went + // unread, and a show asked for over the socket, which is what starting BuildMonitor again + // sends, waited until something else brought the window back. + SetConfigFlags(FLAG_WINDOW_RESIZABLE | FLAG_VSYNC_HINT | FLAG_WINDOW_HIDDEN | FLAG_WINDOW_ALWAYS_RUN); InitWindow(width, height, title != nullptr ? title : "BuildMonitor"); if (!IsWindowReady()) { return 0; diff --git a/native/swift/Sources/Bm/Exports.swift b/native/swift/Sources/Bm/Exports.swift index e7dc8fe..48bd5da 100644 --- a/native/swift/Sources/Bm/Exports.swift +++ b/native/swift/Sources/Bm/Exports.swift @@ -8,6 +8,13 @@ import ImageIO /// /// The header is imported for its struct layouts only, with BM_TYPES_ONLY, so these are the /// definitions of those symbols rather than a second declaration of them. +/// +/// Every one that touches AppKit runs inside its own autorelease pool. `NSApplication.run` drains +/// a pool per event, but this app never calls it: the managed loop calls in instead, and with no +/// pool pushed objc4 parks everything autoreleased in one it creates for the thread, which drains +/// only when the thread exits. That is the main thread, so everything a frame autoreleased - the +/// events, the attributed strings, the drawing - stayed until the process did, and a tray is a +/// process that runs for days. @_cdecl("bm_version") public func bmVersion() -> Int32 { @@ -30,6 +37,20 @@ public func bmInit( _ fontSize: Float, _ placement: UnsafePointer?, _ hidden: Int32) -> Int32 { + autoreleasepool { + initialise(width, height, title, fontTtf, fontLength, fontSize, placement, hidden) + } +} + +private func initialise( + _ width: Int32, + _ height: Int32, + _ title: UnsafePointer?, + _ fontTtf: UnsafePointer?, + _ fontLength: Int32, + _ fontSize: Float, + _ placement: UnsafePointer?, + _ hidden: Int32) -> Int32 { var font: Data? if let fontTtf, fontLength > 0 { font = Data(bytes: fontTtf, count: Int(fontLength)) @@ -53,17 +74,19 @@ public func bmPresent(_ screen: UnsafePointer?) -> Int32 { return 0 } - // The managed side presents every frame, and a screen it has not rebuilt comes with the same - // generation. Decoded and drawn anyway, every string was copied and every row drawn sixty times - // a second to put the same pixels on screen. - let generation = screen.pointee.generation - if generation == runtime.presentedGeneration { - runtime.pumpUnchanged() - return 1 + autoreleasepool { + // The managed side presents every frame, and a screen it has not rebuilt comes with the + // same generation. Decoded and drawn anyway, every string was copied and every row drawn + // sixty times a second to put the same pixels on screen. + let generation = screen.pointee.generation + if generation == runtime.presentedGeneration { + runtime.pumpUnchanged() + } else { + runtime.presentedGeneration = generation + runtime.present(Frame.decode(screen)) + } } - runtime.presentedGeneration = generation - runtime.present(Frame.decode(screen)) return 1 } @@ -74,12 +97,14 @@ public func bmPollInput(_ input: UnsafeMutablePointer?) { } let runtime = Runtime.shared - if runtime.initialised { - runtime.measure() - runtime.drainEdit() - runtime.samplePlacement() - runtime.sampleMinimized() - runtime.sampleHover() + autoreleasepool { + if runtime.initialised { + runtime.measure() + runtime.drainEdit() + runtime.samplePlacement() + runtime.sampleMinimized() + runtime.sampleHover() + } } input.pointee = runtime.input @@ -88,16 +113,20 @@ public func bmPollInput(_ input: UnsafeMutablePointer?) { @_cdecl("bm_set_hidden") public func bmSetHidden(_ hidden: Int32) { - if hidden == 0 { - Runtime.shared.show() - } else { - Runtime.shared.hide() + autoreleasepool { + if hidden == 0 { + Runtime.shared.show() + } else { + Runtime.shared.hide() + } } } @_cdecl("bm_focus") public func bmFocus() { - Runtime.shared.show() + autoreleasepool { + Runtime.shared.show() + } } @_cdecl("bm_set_clipboard") @@ -106,9 +135,11 @@ public func bmSetClipboard(_ text: UnsafePointer?) { return } - let board = NSPasteboard.general - board.clearContents() - board.setString(String(cString: text), forType: .string) + autoreleasepool { + let board = NSPasteboard.general + board.clearContents() + board.setString(String(cString: text), forType: .string) + } } @_cdecl("bm_pick_directory") @@ -122,8 +153,10 @@ public func bmPickDirectory( return 0 } - let picked = Runtime.shared.pickDirectory(start: start.map { String(cString: $0) }) - return writePicked(picked, into: buffer, length: bufferLength) + return autoreleasepool { () -> Int32 in + let picked = Runtime.shared.pickDirectory(start: start.map { String(cString: $0) }) + return writePicked(picked, into: buffer, length: bufferLength) + } } /// The answer as the managed side reads it back: the path as UTF-8 with no terminator, and how many @@ -154,6 +187,12 @@ public func bmTrayAvailable() -> Int32 { @_cdecl("bm_tray_init") public func bmTrayInit() -> Int32 { + autoreleasepool { + initialiseTray() + } +} + +private func initialiseTray() -> Int32 { let runtime = Runtime.shared if runtime.tray != nil { return 1 @@ -172,42 +211,61 @@ public func bmTrayInit() -> Int32 { @_cdecl("bm_tray_set_icon") public func bmTraySetIcon(_ kind: Int32, _ png: UnsafePointer?, _ length: Int32) { - guard let png, length > 0 else { + guard let png, length > 0, let tray = Runtime.shared.tray else { return } - Runtime.shared.tray?.setIcon(kind: kind, png: Data(bytes: png, count: Int(length))) + autoreleasepool { + tray.setIcon(kind: kind, png: Data(bytes: png, count: Int(length))) + } } @_cdecl("bm_tray_set_menu_icon") public func bmTraySetMenuIcon(_ name: UnsafePointer?, _ png: UnsafePointer?, _ length: Int32) { - guard let name, let png, length > 0 else { + guard let name, let png, length > 0, let tray = Runtime.shared.tray else { return } - Runtime.shared.tray?.setMenuIcon(name: String(cString: name), png: Data(bytes: png, count: Int(length))) + autoreleasepool { + tray.setMenuIcon(name: String(cString: name), png: Data(bytes: png, count: Int(length))) + } } @_cdecl("bm_set_row_icon") public func bmSetRowIcon(_ name: UnsafePointer?, _ png: UnsafePointer?, _ length: Int32) { - guard let name, let png, length > 0, - let image = NSImage(data: Data(bytes: png, count: Int(length))) else { + guard let name, let png, length > 0 else { return } - image.size = NSSize(width: 16, height: 16) - RowIcons.images[String(cString: name)] = image + autoreleasepool { + if let image = NSImage(data: Data(bytes: png, count: Int(length))) { + image.size = NSSize(width: 16, height: 16) + RowIcons.images[String(cString: name)] = image + } + } } @_cdecl("bm_shutdown") public func bmShutdown() { - Runtime.shared.shutdown() + autoreleasepool { + Runtime.shared.shutdown() + } } /// Renders into a bitmap of this side's own making, with scale, colour space and font smoothing /// pinned, so a committed baseline matches on any display. No window is needed. @_cdecl("bm_capture") public func bmCapture( + _ screen: UnsafePointer?, + _ width: Int32, + _ height: Int32, + _ pngPath: UnsafePointer?) -> Int32 { + autoreleasepool { + capture(screen, width, height, pngPath) + } +} + +private func capture( _ screen: UnsafePointer?, _ width: Int32, _ height: Int32, diff --git a/src/BuildMonitor.Native/Native/NativeResolver.cs b/src/BuildMonitor.Native/Native/NativeResolver.cs index 476873d..982c723 100644 --- a/src/BuildMonitor.Native/Native/NativeResolver.cs +++ b/src/BuildMonitor.Native/Native/NativeResolver.cs @@ -83,11 +83,23 @@ static IEnumerable Candidates() yield return Path.Combine(root, file); } - static IEnumerable Rids() + static IEnumerable Rids() => + Rids(RuntimeInformation.RuntimeIdentifier); + + public static IEnumerable Rids(string runtimeIdentifier) { // The exact RID first. On Alpine that is linux-musl-x64, which we do not ship, so the // probe simply misses rather than loading a glibc binary and hard crashing. - yield return RuntimeInformation.RuntimeIdentifier; + yield return runtimeIdentifier; + + // And nothing else, or the synthesised RID below undoes that: linux-{arch} is the glibc + // build. A load that fails is reported and the head exits, but one that succeeded would + // leave a crash to come later, in whichever call first met a libc that is not the one the + // library was built against. + if (runtimeIdentifier.Contains("-musl-", StringComparison.Ordinal)) + { + yield break; + } var architecture = RuntimeInformation.OSArchitecture switch { diff --git a/src/BuildMonitor.Native/runtimes/linux-arm64/native/libbuildmonitor_ui.so b/src/BuildMonitor.Native/runtimes/linux-arm64/native/libbuildmonitor_ui.so index 2100afe..8d07f8e 100644 Binary files a/src/BuildMonitor.Native/runtimes/linux-arm64/native/libbuildmonitor_ui.so and b/src/BuildMonitor.Native/runtimes/linux-arm64/native/libbuildmonitor_ui.so differ diff --git a/src/BuildMonitor.Native/runtimes/linux-x64/native/libbuildmonitor_ui.so b/src/BuildMonitor.Native/runtimes/linux-x64/native/libbuildmonitor_ui.so index d7e356b..cd8313a 100644 Binary files a/src/BuildMonitor.Native/runtimes/linux-x64/native/libbuildmonitor_ui.so and b/src/BuildMonitor.Native/runtimes/linux-x64/native/libbuildmonitor_ui.so differ diff --git a/src/BuildMonitor.Native/runtimes/osx-arm64/native/libbuildmonitor_ui.dylib b/src/BuildMonitor.Native/runtimes/osx-arm64/native/libbuildmonitor_ui.dylib index 0d7f87a..c3e07d1 100644 Binary files a/src/BuildMonitor.Native/runtimes/osx-arm64/native/libbuildmonitor_ui.dylib and b/src/BuildMonitor.Native/runtimes/osx-arm64/native/libbuildmonitor_ui.dylib differ diff --git a/src/BuildMonitor.Native/runtimes/osx-x64/native/libbuildmonitor_ui.dylib b/src/BuildMonitor.Native/runtimes/osx-x64/native/libbuildmonitor_ui.dylib index 0d7f87a..c3e07d1 100644 Binary files a/src/BuildMonitor.Native/runtimes/osx-x64/native/libbuildmonitor_ui.dylib and b/src/BuildMonitor.Native/runtimes/osx-x64/native/libbuildmonitor_ui.dylib differ diff --git a/src/BuildMonitor.Tests/Native/NativeResolverTests.cs b/src/BuildMonitor.Tests/Native/NativeResolverTests.cs new file mode 100644 index 0000000..3c08206 --- /dev/null +++ b/src/BuildMonitor.Tests/Native/NativeResolverTests.cs @@ -0,0 +1,30 @@ +/// +/// Which runtimes/{rid} directories the resolver probes, which is what decides whose build of the +/// native library a process loads. Nothing here loads one, so these run on every platform. +/// +public class NativeResolverTests +{ + /// + /// The probe for a musl RID is that RID alone. The linux-{arch} candidate after it names the + /// glibc build, which a musl process must not load. + /// + [Test] + [Arguments("linux-musl-x64")] + [Arguments("linux-musl-arm64")] + public async Task MuslProbesItsOwnRidAndNothingElse(string runtimeIdentifier) + { + var rids = NativeResolver.Rids(runtimeIdentifier).ToList(); + await Assert.That(rids).IsEquivalentTo([runtimeIdentifier]); + } + + /// + /// Everywhere else the synthesised RID still follows the framework's own. + /// + [Test] + public async Task OtherRidsStillFallBackToTheSynthesisedRid() + { + var rids = NativeResolver.Rids("some-rid").ToList(); + await Assert.That(rids.Count).IsGreaterThan(1); + await Assert.That(rids[0]).IsEqualTo("some-rid"); + } +} diff --git a/src/BuildMonitor.Tests/Native/PixelTests.cs b/src/BuildMonitor.Tests/Native/PixelTests.cs index 6180df7..e9102c3 100644 --- a/src/BuildMonitor.Tests/Native/PixelTests.cs +++ b/src/BuildMonitor.Tests/Native/PixelTests.cs @@ -97,6 +97,61 @@ public Task Groups() => public Task PrefixGroup() => Capture(MonitorSession.ToggleGroup(Fixtures.WithPrefixGroup(), Fixtures.VerifyPassing)); + /// + /// raylib does three things at the end of a frame it has drawn, behind one flag: puts it on the + /// screen, reads input, and waits for the next frame. raylib 6.0's CMake turned that flag on, and + /// bm_present leaves all three to raylib, so a frame it drew never reached the window. A capture + /// draws into a texture and never gets that far, so every snapshot above kept passing. The wait + /// is the one of the three that can be timed from here, so it stands for all of them. + /// + /// Timed in the window, shown for as long as this takes, with every present a screen that is + /// not the one before it. bm_present draws only into a window that is showing, and only a + /// screen that is new to it, and a frame it does not draw it waits out by itself, whatever the + /// flag. Last in the order, so the frames drawn here come after every capture rather than + /// between two of them. + /// + /// + /// Linux only. The macOS head waits for the next frame in its event pump rather than after + /// drawing one, and its window can only be made on the main thread, which a test host does not + /// run its tests on. + /// + /// + [Test] + [PixelTest] + [RunOn(TUnit.Core.Enums.OS.Linux)] + [NotInParallel(nameof(PixelTests), Order = 10)] + public async Task PresentWaitsForTheNextFrame() + { + // The same rows built twice. The head tells one screen from the next by whether it is the + // instance it was last handed, so taking turns makes every present a screen to draw. + Screen[] screens = + [ + ScreenBuilder.Build(Fixtures.WithBuilds(), Fixtures.Now), + ScreenBuilder.Build(Fixtures.WithBuilds(), Fixtures.Now) + ]; + var watch = new Stopwatch(); + window!.SetHidden(false); + try + { + // So the timing starts on a frame boundary + window.Present(screens[1]); + watch.Start(); + for (var frame = 0; frame < 60; frame++) + { + window.Present(screens[frame % 2]); + } + + watch.Stop(); + } + finally + { + window.SetHidden(true); + } + + // Sixty frames at sixty a second. Unpaced, they went by as fast as each could be drawn. + await Assert.That(watch.Elapsed).IsGreaterThan(TimeSpan.FromMilliseconds(750)); + } + static async Task Capture(SessionState state) { // Pinned rather than System, so a capture does not depend on the theme of whoever ran it.