feat(fanout): make encoder supervision a real, settable option

Production-readiness pass on the panel side of daemon encoder supervision.

Until now the feature was gated on a setting that did not exist as a
column, so it could only be enabled by hand-editing the database and the
daemon never learned about it at all.

* Migration 018 adds `fanout_supervise`, off for every existing install.
* Admin -> Settings gains the toggle, alongside the other fanout tuning.
* SettingsService saves it (it is a checkbox, so it has to be in the
  boolean list or it can never be turned back off).
* FanoutConfig writes it into the daemon's config file as `supervise`,
  which is how the node learns it may supervise at all. Both halves must
  be on for anything to change, and either one off is a full rollback.
* StreamProcess reads `fanout_supervise` rather than the placeholder
  name, matching the panel's `fanout_*` convention.

Also: the recorded command file now says WHO ran the stream. `_.fanout`
when the daemon owns the process (the bare command it was handed) and
`_.ffmpeg` when this node ran it itself (with the redirect-and-background
tail). Two names rather than one because the first question in any
incident is which path the stream took, and a single filename cannot
answer it; the stale one is removed so a stream that switched paths does
not leave a lie behind.

FanoutConfigTest covers the new key both ways, including that a settings
array predating it reads as off -- that absence is the upgrade path for
every existing install.

Verified: php -l clean on every changed file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
obscuremind
2026-09-10 21:34:50 +01:00
co-authored by Claude Opus 5
parent 8244ece100
commit 8e24a2427a
7 changed files with 69 additions and 8 deletions
+1
View File
@@ -1778,6 +1778,7 @@ fanout_source_grace = "Fanout Source Grace"
fanout_write_timeout = "Fanout Write Timeout"
fanout_idle_buffer_grace = "Fanout Idle Buffer Grace"
fanout_idle_buffer_ratio = "Fanout Idle Buffer Ratio"
fanout_supervise = "Fanout Encoder Supervision"
fanout_source_chunk_bytes = "Fanout Source Chunk Bytes"
fanout_max_gop_bytes = "Fanout Max GOP Bytes"
fanout_source_insecure_tls = "Fanout Source Insecure TLS"
+1 -1
View File
@@ -34,7 +34,7 @@ class SettingsService {
$rArray = QueryHelper::verifyPostTable('settings', $rData, true);
foreach (array('php_loopback', 'restreamer_bypass_proxy', 'request_prebuffer', 'modal_edit', 'group_buttons', 'enable_search', 'on_demand_checker', 'ondemand_balance_equal', 'disable_mag_token', 'allow_cdn_access', 'dts_legacy_ffmpeg', 'mag_load_all_channels', 'disable_xmltv_restreamer', 'disable_playlist_restreamer', 'ffmpeg_warnings', 'reseller_ssl_domain', 'extract_subtitles', 'show_category_duplicates', 'vod_sort_newest', 'header_stats', 'mag_keep_extension', 'keep_protocol', 'read_native_hls', 'player_allow_playlist', 'player_allow_bouquet', 'player_hide_incompatible', 'player_allow_hevc', 'force_epg_timezone', 'check_vod', 'ignore_keyframes', 'save_login_logs', 'save_restart_logs', 'mag_legacy_redirect', 'restrict_playlists', 'monitor_connection_status', 'kill_rogue_ffmpeg', 'show_images', 'on_demand_instant_off', 'on_demand_failure_exit', 'playlist_from_mysql', 'ignore_invalid_users', 'legacy_mag_auth', 'ministra_allow_blank', 'block_proxies', 'block_streaming_servers', 'ip_subnet_match', 'auto_unban_ip', 'debug_show_errors', 'enable_debug_stalker', 'restart_php_fpm', 'restream_deny_unauthorised', 'api_probe', 'legacy_panel_api', 'hide_failures', 'verify_host', 'encrypt_playlist', 'encrypt_playlist_restreamer', 'mag_disable_ssl', 'legacy_get', 'legacy_xmltv', 'save_closed_connection', 'show_tickets', 'stream_logs_save', 'client_logs_save', 'streams_grouped', 'cloudflare', 'cleanup', 'dashboard_stats', 'dashboard_status', 'dashboard_map', 'dashboard_display_alt', 'recaptcha_enable', 'ip_logout', 'disable_player_api', 'disable_playlist', 'disable_xmltv', 'disable_enigma2', 'disable_ministra', 'enable_isp_lock', 'block_svp', 'disable_ts', 'disable_ts_allow_restream', 'disable_hls', 'disable_hls_allow_restream', 'disable_rtmp', 'disable_rtmp_allow_restream', 'case_sensitive_line', 'county_override_1st', 'disallow_2nd_ip_con', 'use_mdomain_in_lists', 'encrypt_hls', 'disallow_empty_user_agents', 'detect_restream_block_user', 'download_images', 'api_redirect', 'use_buffer', 'audio_restart_loss', 'show_isps', 'priority_backup', 'rtmp_random', 'show_connected_video', 'show_not_on_air_video', 'show_banned_video', 'show_expired_video', 'show_expiring_video', 'show_all_category_mag', 'always_enabled_subtitles', 'enable_connection_problem_indication', 'show_tv_channel_logo', 'show_channel_logo_in_preview', 'disable_trial', 'restrict_same_ip', 'fanout_source_insecure', 'js_navigate') as $rSetting) {
foreach (array('php_loopback', 'restreamer_bypass_proxy', 'request_prebuffer', 'modal_edit', 'group_buttons', 'enable_search', 'on_demand_checker', 'ondemand_balance_equal', 'disable_mag_token', 'allow_cdn_access', 'dts_legacy_ffmpeg', 'mag_load_all_channels', 'disable_xmltv_restreamer', 'disable_playlist_restreamer', 'ffmpeg_warnings', 'reseller_ssl_domain', 'extract_subtitles', 'show_category_duplicates', 'vod_sort_newest', 'header_stats', 'mag_keep_extension', 'keep_protocol', 'read_native_hls', 'player_allow_playlist', 'player_allow_bouquet', 'player_hide_incompatible', 'player_allow_hevc', 'force_epg_timezone', 'check_vod', 'ignore_keyframes', 'save_login_logs', 'save_restart_logs', 'mag_legacy_redirect', 'restrict_playlists', 'monitor_connection_status', 'kill_rogue_ffmpeg', 'show_images', 'on_demand_instant_off', 'on_demand_failure_exit', 'playlist_from_mysql', 'ignore_invalid_users', 'legacy_mag_auth', 'ministra_allow_blank', 'block_proxies', 'block_streaming_servers', 'ip_subnet_match', 'auto_unban_ip', 'debug_show_errors', 'enable_debug_stalker', 'restart_php_fpm', 'restream_deny_unauthorised', 'api_probe', 'legacy_panel_api', 'hide_failures', 'verify_host', 'encrypt_playlist', 'encrypt_playlist_restreamer', 'mag_disable_ssl', 'legacy_get', 'legacy_xmltv', 'save_closed_connection', 'show_tickets', 'stream_logs_save', 'client_logs_save', 'streams_grouped', 'cloudflare', 'cleanup', 'dashboard_stats', 'dashboard_status', 'dashboard_map', 'dashboard_display_alt', 'recaptcha_enable', 'ip_logout', 'disable_player_api', 'disable_playlist', 'disable_xmltv', 'disable_enigma2', 'disable_ministra', 'enable_isp_lock', 'block_svp', 'disable_ts', 'disable_ts_allow_restream', 'disable_hls', 'disable_hls_allow_restream', 'disable_rtmp', 'disable_rtmp_allow_restream', 'case_sensitive_line', 'county_override_1st', 'disallow_2nd_ip_con', 'use_mdomain_in_lists', 'encrypt_hls', 'disallow_empty_user_agents', 'detect_restream_block_user', 'download_images', 'api_redirect', 'use_buffer', 'audio_restart_loss', 'show_isps', 'priority_backup', 'rtmp_random', 'show_connected_video', 'show_not_on_air_video', 'show_banned_video', 'show_expired_video', 'show_expiring_video', 'show_all_category_mag', 'always_enabled_subtitles', 'enable_connection_problem_indication', 'show_tv_channel_logo', 'show_channel_logo_in_preview', 'disable_trial', 'restrict_same_ip', 'fanout_source_insecure', 'fanout_supervise', 'js_navigate') as $rSetting) {
if (isset($rData[$rSetting])) {
$rArray[$rSetting] = 1;
} else {
+15 -7
View File
@@ -946,14 +946,15 @@ class StreamProcess {
* Whether this node hands live encoders to the fanout daemon to supervise
* instead of running them itself under the PHP watchdog.
*
* Off unless `daemon_supervise` is set, so a panel that has never heard of
* the setting keeps the legacy behaviour exactly — no migration needed, and
* clearing the setting is the rollback.
* Off unless `fanout_supervise` is set. A panel that predates the setting
* reads null and keeps the legacy behaviour exactly, and clearing it is the
* rollback: MonitorCommand resumes, because its stand-down check asks the
* daemon rather than assuming, and an unreachable daemon answers "no".
*
* @return bool
*/
public static function daemonSupervises(): bool {
return (bool) SettingsManager::get('daemon_supervise');
return (bool) SettingsManager::get('fanout_supervise');
}
/**
@@ -1633,9 +1634,16 @@ class StreamProcess {
$rFFMPEG .= self::liveRedirectTail($rStreamID);
shell_exec($rFFMPEG);
}
// Record what actually ran: the bare command when the daemon owns
// the process, the backgrounded one when this node does.
file_put_contents(STREAMS_PATH . $rStreamID . '_.ffmpeg', $rFFMPEG);
// Record what actually ran, under a name that says WHO ran it:
// `_.fanout` when the daemon owns the process (the bare command it
// was handed), `_.ffmpeg` when this node ran it itself (with the
// redirect-and-background tail). Two names rather than one because
// the first question in any incident is which path the stream took,
// and a single file cannot answer it. The stale one is removed so a
// stream that switched paths does not leave a lie behind.
$rCmdFile = $rHandedOver ? '_.fanout' : '_.ffmpeg';
file_put_contents(STREAMS_PATH . $rStreamID . $rCmdFile, $rFFMPEG);
@unlink(STREAMS_PATH . $rStreamID . ($rHandedOver ? '_.ffmpeg' : '_.fanout'));
// Wait briefly for PID file to be written, with retry
$rPID = 0;
+10
View File
@@ -1555,6 +1555,16 @@ use XcVm\Streaming\Codec\FfmpegBinaries; // Code reconstruction by Squallp
</div>
</div>
<div class="form-group row mb-4">
<label class="col-md-4 col-form-label" for="fanout_supervise">
<?= $language::get('fanout_supervise') ?>
<i class="icon-base ti tabler-info-circle text-body-secondary" data-bs-toggle="tooltip" title="xc_fanout: let the daemon run and watch stream encoders instead of one PHP monitor process per stream. The node's daemon must also have supervision enabled. Off = the PHP watchdog keeps running."></i>
</label>
<div class="col-md-2">
<div class="form-check form-switch"><input name="fanout_supervise" id="fanout_supervise" type="checkbox" <?= ($rSettings["fanout_supervise"] ?? 0) == 1 ? ' checked' : '' ?> class="form-check-input"></div>
</div>
</div>
<div class="form-group row mb-4">
<label class="col-md-4 col-form-label" for="split_by">
<?= $language::get('load_balancing') ?>
+4
View File
@@ -121,6 +121,10 @@ class FanoutConfig {
'idle_buffer_grace_sec' => self::clampInt((int) ($rSettings['fanout_idle_buffer_grace_sec'] ?? 30), 0, 3600),
'idle_buffer_ratio' => $rRatio,
'source_backend' => self::backend((string) ($rSettings['fanout_source_backend'] ?? 'auto')),
// Whether this node's daemon may run and watch stream encoders at all.
// The panel handing a stream over is the other half; both must be on
// for anything to change, and either one off is a full rollback.
'supervise' => (bool) ($rSettings['fanout_supervise'] ?? false),
);
}
@@ -0,0 +1,11 @@
-- xc_fanout: let the daemon run and watch this node's stream encoders instead of
-- a per-stream PHP watchdog (console.php monitor). See the daemon's
-- docs/adr/0002-monitor-in-daemon.md.
--
-- Off for every existing install. Turning it on is a two-sided opt-in: the panel
-- hands streams over, and the node's daemon must itself be configured to accept
-- them (the `supervise` key in its config.json, which FanoutConfig writes from
-- this setting). Turning it off is the rollback -- MonitorCommand resumes, since
-- its stand-down check asks the daemon and an unreachable one answers "no".
ALTER TABLE `settings`
ADD COLUMN IF NOT EXISTS `fanout_supervise` tinyint(1) DEFAULT 0 AFTER `fanout_source_backend`;
+27
View File
@@ -67,6 +67,33 @@ final class FanoutConfigTest extends TestCase {
$this->assertSame(0.5, $c['idle_buffer_ratio']);
}
/**
* Encoder supervision is off unless the panel says otherwise, and a panel
* that predates the setting must read as off rather than as anything else --
* that absence is the upgrade path for every existing install.
*/
public function testSuperviseDefaultsOff(): void {
$rSettings = $this->baseSettings();
unset($rSettings['fanout_supervise']);
$this->assertTrue(FanoutConfig::sync($rSettings));
$this->assertFalse($this->read()['supervise']);
$rSettings['fanout_supervise'] = 0;
$this->assertTrue(FanoutConfig::sync($rSettings));
$this->assertFalse($this->read()['supervise']);
}
/**
* And it reaches the daemon's config file when it IS set, since that file is
* the only way the node learns it may supervise at all.
*/
public function testSuperviseReachesTheDaemonConfig(): void {
$rSettings = $this->baseSettings();
$rSettings['fanout_supervise'] = 1;
$this->assertTrue(FanoutConfig::sync($rSettings));
$this->assertTrue($this->read()['supervise']);
}
public function testPrebufferMaxSecIsDerivedAndFloored(): void {
// client 30, restreamer 0, hls_window*seg = 6*6 = 36 → floor 40.
$this->assertTrue(FanoutConfig::sync($this->baseSettings()));