Skip to content

Commit 28f73ec

Browse files
committed
Server - take more care when working with the info synth
1 parent fe75f22 commit 28f73ec

3 files changed

Lines changed: 154 additions & 1 deletion

File tree

‎app/server/ruby/lib/sonicpi/server.rb‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,14 @@ def fetch_scsynth_info!(timeout=nil)
130130
info_prom.deliver! payload
131131
end
132132
osc @osc_path_s_new, "sonic-pi-server-info", 1, 0, 0
133-
server_info = info_prom.get(timeout)
133+
begin
134+
server_info = info_prom.get(timeout)
135+
ensure
136+
# The probe answers twice a second until it is freed, and only from
137+
# a running graph. Free it whether it answered or not: one left
138+
# behind takes node 1, and every probe after it collides.
139+
osc @osc_path_n_free, 1
140+
end
134141
raise "Timed out waiting for server info" unless server_info
135142
@scsynth_info = SonicPi::Core::SPMap.new({
136143
:sample_rate => server_info[2],

‎app/server/ruby/lib/sonicpi/studio.rb‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1098,6 +1098,12 @@ def cold_swap_reinit!
10981098
# device channel count changed (mic feedback, synths bypassing
10991099
# the mixer).
11001100
begin
1101+
# Make sure the graph is running before the probe goes in. The
1102+
# jobs stopped by the device change pause it when they finish,
1103+
# and that pause can reach the new World before we get here. A
1104+
# probe in a paused graph never answers. If the studio should be
1105+
# paused, it's paused again at the end of the rebuild.
1106+
@server.node_run(0, true)
11011107
@server.fetch_scsynth_info!(5)
11021108
reset_and_setup_groups_and_busses
11031109
STDOUT.puts "Studio - Phase 3: Server info + Groups (#{(Time.now - start).round(2)}s)"
@@ -1157,6 +1163,12 @@ def cold_swap_reinit!
11571163
log_phase_err.call("in init_studio", e)
11581164
end
11591165

1166+
# Phase 3 left the graph running. If nothing is playing, pause it
1167+
# again, as Studio#pause would have. A pause or start that arrived
1168+
# during the rebuild has been waiting at the gate, and runs once
1169+
# the rebuild finishes.
1170+
@recording_mutex.synchronize { __pause_graph if @paused }
1171+
11601172
message "Reinitialisation complete (#{(Time.now - start).round(2)}s)"
11611173
end
11621174
ensure
@@ -1412,6 +1424,7 @@ def internal_load_synthdef(path, server=@server)
14121424
new_group new_synth_group new_fx_group new_fx_bus
14131425
recording_start recording_stop
14141426
control_bus
1427+
pause start
14151428
].freeze
14161429

14171430
_gate_module = Module.new
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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

Comments
 (0)