|
| 1 | +#-- |
| 2 | +# This file is part of Sonic Pi: http://sonic-pi.net |
| 3 | +# Full project source: https://github.com/sonic-pi-net/sonic-pi |
| 4 | +# License: https://github.com/sonic-pi-net/sonic-pi/blob/main/LICENSE.md |
| 5 | +# |
| 6 | +# Copyright 2026 by Sam Aaron (http://sam.aaron.name). |
| 7 | +# All rights reserved. |
| 8 | +# |
| 9 | +# Permission is granted for use, copying, modification, and |
| 10 | +# distribution of modified versions of this work as long as this |
| 11 | +# notice is included. |
| 12 | +#++ |
| 13 | + |
| 14 | +require_relative "./setup_test" |
| 15 | +require_relative "../lib/sonicpi/studio" |
| 16 | +require_relative "./mix/lib/mix_engine" |
| 17 | +require 'socket' |
| 18 | +require 'tmpdir' |
| 19 | +require 'timeout' |
| 20 | + |
| 21 | +module SonicPi |
| 22 | + # Regression guarded against: changing audio device while code ran left |
| 23 | + # Sonic Pi silent until the next device event. The halt stops the jobs, and |
| 24 | + # each job's teardown ends in the idle pause, which froze the root group of |
| 25 | + # the engine's new world while the rebuild was loading synthdefs. The |
| 26 | + # server-info probe that came next was made inside the frozen root, never |
| 27 | + # answered, and was never freed, so the rebuild got no mixer and every retry |
| 28 | + # collided with it ("Duplicate node ID: 1"). |
| 29 | + # |
| 30 | + # A real Studio against a headless engine. Skipped when the engine is not |
| 31 | + # built, as the mix tests are. |
| 32 | + class StudioColdSwapTester < Minitest::Test |
| 33 | + class State |
| 34 | + def sched_ahead_time_at(_t) |
| 35 | + 0.5 |
| 36 | + end |
| 37 | + end |
| 38 | + |
| 39 | + def setup |
| 40 | + skip "engine not built at #{MixEngine::ENGINE}" unless File.executable?(MixEngine::ENGINE) |
| 41 | + port = free_port |
| 42 | + @log = File.join(Dir.tmpdir, "sonic-pi-cold-swap-test-#{port}.log") |
| 43 | + @engine = Process.spawn(MixEngine::ENGINE, "--headless", "-u", port.to_s, "--tcp", port.to_s, |
| 44 | + "-o", "2", "-i", "0", "-a", "1024", "-b", "4096", "-B", "127.0.0.1", |
| 45 | + out: @log, err: [:child, :out]) |
| 46 | + @studio = Studio.new({scsynth_port: port, scsynth_send_port: port}, Queue.new, State.new, |
| 47 | + ->(*) {}, -> { nil }) |
| 48 | + @server = @studio.server |
| 49 | + end |
| 50 | + |
| 51 | + def teardown |
| 52 | + @server&.shutdown rescue nil |
| 53 | + return unless @engine |
| 54 | + Process.kill("TERM", @engine) rescue nil |
| 55 | + begin |
| 56 | + Timeout.timeout(5) { Process.wait(@engine) } |
| 57 | + rescue Timeout::Error |
| 58 | + Process.kill("KILL", @engine) rescue nil |
| 59 | + Process.wait(@engine) rescue nil |
| 60 | + end |
| 61 | + end |
| 62 | + |
| 63 | + def test_a_rebuild_comes_through_a_pause_that_reached_the_new_world_first |
| 64 | + @studio.pause |
| 65 | + @studio.cold_swap_reinit! |
| 66 | + refute_nil @studio.mixer_group, "the rebuild ended with no mixer; see #{@log}" |
| 67 | + refute root_runs?, "nothing is playing, so the rebuilt graph should rest" |
| 68 | + end |
| 69 | + |
| 70 | + def test_a_pause_that_comes_during_a_rebuild_waits_for_it |
| 71 | + pause = nil |
| 72 | + just_before_the_rebuild_probes do |
| 73 | + pause = Thread.new { @studio.pause } |
| 74 | + Timeout.timeout(5) { Thread.pass until !pause.alive? || waiting_on_the_studio_gate?(pause) } |
| 75 | + end |
| 76 | + @studio.cold_swap_reinit! |
| 77 | + refute_nil @studio.mixer_group, "the rebuild ended with no mixer; see #{@log}" |
| 78 | + assert pause.join(5), "the pause never went ahead after the rebuild" |
| 79 | + refute root_runs?, "the pause that waited for the rebuild should still rest the graph" |
| 80 | + end |
| 81 | + |
| 82 | + def test_a_probe_that_got_no_answer_leaves_nothing_behind |
| 83 | + @server.node_pause(0, true) |
| 84 | + assert_raises(StandardError) { @server.fetch_scsynth_info!(1) } |
| 85 | + refute node_exists?(1), "a probe left in the graph collides with the next one" |
| 86 | + end |
| 87 | + |
| 88 | + private |
| 89 | + |
| 90 | + # A UDP and a TCP port, the same number, as the engine takes them. |
| 91 | + def free_port |
| 92 | + tcp = TCPServer.new("127.0.0.1", 0) |
| 93 | + port = tcp.addr[1] |
| 94 | + tcp.close |
| 95 | + udp = UDPSocket.new |
| 96 | + udp.bind("127.0.0.1", port) |
| 97 | + udp.close |
| 98 | + port |
| 99 | + end |
| 100 | + |
| 101 | + # The server-info probe answers only from a running graph. |
| 102 | + def root_runs? |
| 103 | + @server.fetch_scsynth_info!(1) |
| 104 | + true |
| 105 | + rescue StandardError |
| 106 | + false |
| 107 | + end |
| 108 | + |
| 109 | + def node_exists?(id) |
| 110 | + answer = Promise.new |
| 111 | + @server.add_event_oneshot_handler("/n_info") { |payload| answer.deliver!(true) if payload.to_a[0] == id } |
| 112 | + @server.add_event_oneshot_handler("/fail") { |payload| answer.deliver!(false) if payload.to_a[0] == "/n_query" } |
| 113 | + @server.osc("/n_query", id) |
| 114 | + answer.get(5) |
| 115 | + end |
| 116 | + |
| 117 | + # The last moment a pause can do harm: the probe is made, and answers, |
| 118 | + # only in a running graph. |
| 119 | + def just_before_the_rebuild_probes(&during) |
| 120 | + probe = @server.method(:fetch_scsynth_info!) |
| 121 | + server = @server |
| 122 | + @server.define_singleton_method(:fetch_scsynth_info!) do |*args| |
| 123 | + server.singleton_class.send(:remove_method, :fetch_scsynth_info!) |
| 124 | + during.call |
| 125 | + probe.call(*args) |
| 126 | + end |
| 127 | + end |
| 128 | + |
| 129 | + def waiting_on_the_studio_gate?(thread) |
| 130 | + thread.status == "sleep" && (thread.backtrace || []).any? { |frame| frame.include?("studio_ready_gate.rb") } |
| 131 | + end |
| 132 | + end |
| 133 | +end |
0 commit comments