feat(reboot): split scsynth vs sclang reboot

Context: previous commit's REBOOT button only restarted scsynth (the
audio engine), keeping the sclang interpreter alive with all its
loaded modules. The user clarified they want a real 'reboot sclang' :
kill the whole interpreter and reload everything from boot.scd. That
needs cooperation between the web bridge (which can only reach sclang)
and the launcher (which controls the sclang process).

Approach: a sentinel-file pattern lets the bridge signal the launcher
without sharing a port. The bridge handler /control/rebootSclang
writes ~/.av-live-restart-sclang then pkills sclang. The launcher's
new sentinel watcher fires every 1s ; on detection it removes the
sentinel and calls restartSclang(), which sets a wantsRestart flag
and terminates the current process — the existing terminationHandler
sees the flag and respawns sclang 0.6s later (so scsynth can be
reaped). Two distinct buttons in the topbar : ⟳ scsynth (small,
existing /control/rebootServer) and ⟳ sclang (big, new path).

Changes:
- web_bridge.scd : new /control/rebootSclang OSCdef writes the
  sentinel file then unixCmd's pkill -TERM -f 'sclang.*boot.scd'
  after a 0.5s delay
- launcher/ProcessManager.swift :
  * sclangWantsRestart flag
  * restartSclang() : sets the flag, calls stopSclang, re-sets the
    flag (since stopSclang clears it for normal stops)
  * startSentinelWatcher() : 1s Timer that checks for the sentinel
    file in /Users/electron, removes it and calls restartSclang() on hit
  * maybeRestartSclang() : called from terminationHandler, respawns
    if wantsRestart is set, with 0.6s delay so scsynth pkill finishes
- launcher/AVLiveLauncherApp.swift : startSentinelWatcher() invoked
  in applicationDidFinishLaunching
- web/public/control/index.html :
  * #btnReboot split into #btnRebootScsynth (small ⟳ scsynth) and
    #btnRebootSclang (full ⟳ sclang)
- web/public/control/app.js : two makeArmedButton bindings, one per
  reboot path
- web/public/control/style.css : .btn.warn.small variant for the
  scsynth button

Impact: ⟳ sclang now does a full engine restart (kills sclang,
launcher respawns it, ~albums and all wrappers reload). ⟳ scsynth
keeps the lighter path that just bounces the audio server. Both
follow the same two-click armed pattern as TEARDOWN.
This commit is contained in:
L'électron rare
2026-05-07 17:56:36 +02:00
parent 16752b17a0
commit b608a50c38
6 changed files with 81 additions and 8 deletions
@@ -55,6 +55,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self?.processManager.startAll()
}
}
// Watch the sentinel file written by the web bridge's
// /control/rebootSclang handler — restarts sclang when touched.
processManager.startSentinelWatcher()
}
func applicationWillTerminate(_ notification: Notification) {
@@ -30,6 +30,7 @@ final class ProcessManager: ObservableObject {
private var sclangProc: Process?
private var oscopeProc: Process?
private var webProc: Process?
private var sclangWantsRestart = false
let osc = OSCSender(host: "127.0.0.1", port: 57121)
private let logQueue = DispatchQueue(label: "cc.saillant.avlive.log")
private let maxLogLines = 2000
@@ -264,6 +265,7 @@ final class ProcessManager: ObservableObject {
DispatchQueue.main.async {
self?.sclangProc = nil
self?.sclangRunning = false
self?.maybeRestartSclang()
}
}
append(source: "launcher", text: "started sclang \(sclangPath) \(soundAlgoLoadFile)")
@@ -273,6 +275,7 @@ final class ProcessManager: ObservableObject {
}
func stopSclang() {
sclangWantsRestart = false
sclangProc?.terminate()
// sclang's terminate doesn't always tear scsynth down cleanly —
// run a belt-and-braces pkill so the next boot can grab :57110.
@@ -282,6 +285,43 @@ final class ProcessManager: ObservableObject {
try? kill.run()
}
/// Restart sclang : fire and forget. Sets the auto-restart flag,
/// terminates the current process. terminationHandler observes the
/// flag and re-spawns after scsynth is reaped.
func restartSclang() {
guard sclangProc != nil else { startSclang(); return }
append(source: "launcher", text: "restarting sclang…")
sclangWantsRestart = true
stopSclang()
// stopSclang resets the flag — re-set it because we want restart
sclangWantsRestart = true
}
/// Watches the .av-live-restart-sclang sentinel file. Triggered by
/// the bridge's /control/rebootSclang OSC handler.
private var sentinelTimer: Timer?
func startSentinelWatcher() {
let path = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".av-live-restart-sclang").path
sentinelTimer?.invalidate()
sentinelTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
if FileManager.default.fileExists(atPath: path) {
try? FileManager.default.removeItem(atPath: path)
self?.append(source: "launcher", text: "sentinel detected — restarting sclang")
self?.restartSclang()
}
}
}
private func maybeRestartSclang() {
guard sclangWantsRestart else { return }
sclangWantsRestart = false
// Small delay so scsynth pkill finishes and the audio device is free
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
self?.startSclang()
}
}
// MARK: - oscope-of
func startOscope() {
+12 -4
View File
@@ -647,12 +647,20 @@ function makeArmedButton(btn, options) {
});
}
makeArmedButton(document.getElementById("btnReboot"), {
armedLabel: "CONFIRM ⟳ ?",
makeArmedButton(document.getElementById("btnRebootScsynth"), {
armedLabel: "CONFIRM ?",
armedClass: "warn",
sendOnConfirm: () => send("/control/rebootServer"),
toastArmed: "REBOOT armed — click again within 2s",
toastFired: "Reboot scsynth sent"
toastArmed: "scsynth reboot armed — click again",
toastFired: "scsynth reboot sent"
});
makeArmedButton(document.getElementById("btnRebootSclang"), {
armedLabel: "CONFIRM sclang ?",
armedClass: "warn",
sendOnConfirm: () => send("/control/rebootSclang"),
toastArmed: "sclang reboot armed — full engine restart",
toastFired: "sclang reboot sent — engine restarting"
});
makeArmedButton(document.getElementById("btnTeardown"), {
+2 -1
View File
@@ -35,7 +35,8 @@
<span class="ws-dot offline" id="scDot"></span>
<span class="ws-label" id="scLabel">sclang</span>
</div>
<button class="btn warn" id="btnReboot" title="Reboot scsynth (click twice within 2s)">⟳ REBOOT</button>
<button class="btn warn small" id="btnRebootScsynth" title="Reboot scsynth seul (click twice within 2s)">⟳ scsynth</button>
<button class="btn warn" id="btnRebootSclang" title="Reboot sclang complet — recharge le moteur (click twice within 2s)">⟳ sclang</button>
<button class="btn danger teardown" id="btnTeardown" title="Click twice within 2s to confirm">TEARDOWN</button>
</div>
</header>
+6
View File
@@ -1380,3 +1380,9 @@ kbd {
0 0 24px rgba(255, 194, 74, 0.55) !important;
animation: armed-pulse 0.5s ease-in-out infinite alternate;
}
/* Smaller variant of warn button (scsynth reboot) */
.btn.warn.small {
padding: 4px 8px;
font-size: 0.78rem;
}
+17 -3
View File
@@ -190,9 +190,9 @@ thisProcess.openUDPPort(~webBridgeIn);
~webBridgeAdd.("/control/teardown", { ~teardown.() });
// Reboot scsynth + replay setupAll (recharge engine + wrappers + tracks
// + web_bridge). Conserve l'etat sclang (catalogues, dicts, OSCdef).
// Le client web doit re-emettre listAlbums/Sections apres ~5s.
// Reboot juste scsynth (audio engine). Conserve l'etat sclang
// (catalogues, dicts, OSCdef). Le client web doit re-emettre
// listAlbums/Sections apres ~5s via le retry persistant.
~webBridgeAdd.("/control/rebootServer", {
"[web_bridge] /control/rebootServer reçu — quit puis ~setupAll".postln;
Routine({
@@ -203,6 +203,20 @@ thisProcess.openUDPPort(~webBridgeIn);
}).play(AppClock);
});
// Reboot complet sclang : suicide via pkill, le launcher detecte
// l'exit et relance sclang via terminationHandler + wantsAutoRestart.
~webBridgeAdd.("/control/rebootSclang", {
"[web_bridge] /control/rebootSclang reçu — sclang suicide en 0.5s...".postln;
AppClock.sched(0.5, {
// Touche un fichier sentinel que le launcher surveille
var sentinel = Platform.userHomeDir ++ "/.av-live-restart-sclang";
File.use(sentinel, "w") { |f| f.write("1") };
// Puis suicide
"pkill -TERM -f 'sclang.*boot.scd' 2>/dev/null".unixCmd;
nil;
});
});
// ---------- Mixer ----------------------------------------------------
~webBridgeAdd.("/control/setVol", { |msg|
~setVol.(msg[1].asString.asSymbol, msg[2]);