62 days of uptime broke our livestream

Sunday’s livestream went out in front of 62 live viewers. By the end of the service that number was zero. Not because the message was bad — because the stream kept pausing. Every minute or two it would buffer, hang, recover for a few seconds, then hang again. People stuck around for a while, then gave up. The full video did eventually upload as a complete archive after the service ended, so anyone who came back later could watch it. But the live experience, the part where the people on the couch feel like they’re part of the room, that was broken for a couple hundred minutes.

Same week, my remote VNC sessions into the streaming Mac had been dropping every minute or two for no obvious reason. The two symptoms turned out to have the same cause: the streaming Mac hadn’t been rebooted in 62 days, and macOS’s kernel network buffer pool was sitting at 97% utilization. When that pool fills, the OS silently drops or delays packets. Outbound RTMP to YouTube stalls. Inbound VNC stalls and reconnects. Every TCP flow that touches the host gets a little worse, and you don’t notice it building until it tips over on a Sunday morning.

netstat -m told the whole story:

8569/8819 mbufs in use
806/2424 mbuf 2KB clusters in use
15930 KB allocated to network (38.7% in use)

After a reboot:

141/420 mbufs in use
1/1568 mbuf 2KB clusters in use
7713 KB allocated to network (1.4% in use)


That was the fix. But “reboot once a week” is the kind of advice that’s easy to give and hard to follow when the machine is running ProPresenter or OBS — because both of those apps fight automated reboots in their own way.

Why weekly

In a production streaming setup the host typically:

  • Holds open dozens of long-lived TCP connections (RTMP to YouTube, control to the switcher, ProPresenter network sync, NDI streams, web hooks).
  • Reads a continuous stream of audio/video frames into memory.
  • Talks to USB or Thunderbolt capture devices that allocate kernel buffers per stream.
  • Auto-launches several apps on login (OBS, ProPresenter, Companion, browser tabs, monitoring dashboards).

Every one of those is a slow leak waiting to happen. Not because any single component is buggy — because the macOS network stack and the apps on top of it weren’t designed to run for two months without a break. Even Apple’s own laptops are quietly rebooted by macOS updates every few weeks. Production hosts don’t get those automatic restarts because we postpone updates.

The weekly reboot is a guardrail. It costs about a minute of downtime in the middle of the night and prevents an entire category of “why is the stream lagging?” debugging the next Sunday — the kind that costs you all of your viewers.

The macOS scheduler isn’t enough

The first thing you’d try is pmset:

sudo pmset repeat restart T 02:00:00

That works for a plain Mac. On a Mac running OBS or ProPresenter, it doesn’t:

  • ProPresenter shows a modal “Are you sure you want to quit ProPresenter?” dialog the moment macOS asks it to quit. The reboot stalls indefinitely waiting for someone to click OK. Next Sunday morning the volunteers find the same machine they left, still running, still leaking buffers.
  • OBS doesn’t block the reboot, but it doesn’t get the chance to write its state file either. On next launch it pops the “OBS was not cleanly shut down” warning. Worse, scene state and recording paths can be lost.

Both apps expose extensive WebSocket / REST APIs (Bitfocus Companion uses them for show control). Neither API has a “quit the app” endpoint — confirmed against ProPresenter’s OpenAPI spec and obs-websocket v5’s protocol document. That’s a deliberate choice; you don’t want random show control plugins killing your production app mid-service.

The script that actually works

So we replaced pmset with a LaunchDaemon that runs a small bash script with per-app handling. Each app needs a slightly different mechanism:

  • OBS honors a polite AppleScript quit saving no when sent into the user’s Aqua session. It writes its state file cleanly and exits in about five seconds. We verified by checking that the safe_mode flag file does not appear on next boot and the final OBS log ends with a complete profiler summary.
  • ProPresenter ignores the AppleScript saving no parameter and always pops its confirmation dialog. You can programmatically click OK on the dialog via System Events, but that requires granting Accessibility permission to /bin/bash, which is way too broad a grant for a single weekly task. A plain SIGTERM via killall cleanly terminates ProPresenter without triggering the dialog at all — that turns out to be the right tool.

SIGTERM is the standard Unix way to ask a process to terminate. Well-behaved Mac apps respond by saving state through the normal AppKit termination path. The confirmation dialog only appears on the macOS-initiated “polite shutdown” Apple Event, not on a direct signal. ProPresenter happens to handle SIGTERM cleanly; OBS doesn’t (it would lose state), which is why each app gets its own mechanism.

Here’s the script (lives at /Library/Scripts/jesse-lee/weekly-reboot.sh, mode 755, owned by root:wheel):

#!/bin/bash
set -u
exec >> /var/log/weekly-reboot.log 2>&1
echo "[$(date)] starting weekly reboot"

USER_UID=$(id -u worshipmedia 2>/dev/null)

wait_exit() {
    local app="$1"; local secs="$2"; local i
    for i in $(seq 1 "$secs"); do
        pgrep -x "$app" > /dev/null || return 0
        sleep 1
    done
    return 1
}

user_osa() {
    /bin/launchctl asuser "$USER_UID" /usr/bin/osascript "$@" >/dev/null 2>&1
}

quit_obs() {
    pgrep -x OBS > /dev/null || return 0
    echo "[$(date)] OBS: AppleScript quit"
    user_osa -e 'tell application "OBS" to quit saving no' &
    local p=$!
    wait_exit OBS 10
    kill "$p" 2>/dev/null
    if pgrep -x OBS > /dev/null; then
        echo "[$(date)] OBS: SIGTERM"
        /usr/bin/killall OBS 2>/dev/null
        wait_exit OBS 10
    fi
    pgrep -x OBS > /dev/null && /usr/bin/killall -9 OBS 2>/dev/null
}

quit_propresenter() {
    pgrep -x ProPresenter > /dev/null || return 0
    echo "[$(date)] ProPresenter: SIGTERM"
    /usr/bin/killall ProPresenter 2>/dev/null
    wait_exit ProPresenter 10
    pgrep -x ProPresenter > /dev/null && /usr/bin/killall -9 ProPresenter 2>/dev/null
}

quit_obs
quit_propresenter
/usr/bin/killall -9 "ProPresenter Helper (Workspaces)" 2>/dev/null
/usr/bin/killall -9 "ProPresenter Helper (Snapshots)" 2>/dev/null

sleep 3
echo "[$(date)] calling shutdown -r now"
/sbin/shutdown -r now

The LaunchDaemon plist (/Library/LaunchDaemons/com.example.weekly-reboot.plist) wraps it on a weekly schedule:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.example.weekly-reboot</string>
    <key>ProgramArguments</key>
    <array>
        <string>/Library/Scripts/jesse-lee/weekly-reboot.sh</string>
    </array>
    <key>StartCalendarInterval</key>
    <dict>
        <key>Weekday</key>
        <integer>2</integer>
        <key>Hour</key>
        <integer>2</integer>
        <key>Minute</key>
        <integer>0</integer>
    </dict>
    <key>StandardOutPath</key>
    <string>/var/log/weekly-reboot.log</string>
    <key>StandardErrorPath</key>
    <string>/var/log/weekly-reboot.log</string>
</dict>
</plist>

Load it with sudo launchctl bootstrap system /Library/LaunchDaemons/com.example.weekly-reboot.plist. Test it (this will reboot the Mac) with sudo launchctl kickstart -k system/com.example.weekly-reboot. Check /var/log/weekly-reboot.log afterwards to confirm which path each app took.

Stagger the rest of the network

One streaming Mac doesn’t live alone. If you reboot it during a window where the network monitoring system is also being rebooted, or your DNS server cycles right when OBS comes back and tries to resolve YouTube’s RTMP endpoint, you’ve created a different problem.

We stagger every host that needs a weekly reboot inside a single Tuesday 02:00–04:00 maintenance window:

  • 02:00 — streaming Mac (OBS)
  • 02:15 — sanctuary Mac (ProPresenter)
  • 02:30 — primary DNS server
  • 02:45 — secondary DNS server
  • 02:55 — primary application host (apt-upgrade + reboot)

DNS master comes back before the secondaries cycle. The application host (which also runs a DNS secondary, the network monitoring system, and a few other things) goes last so it has working DNS when it restarts. IP cameras and wireless APs have their own per-device weekly reboot timers set to fall inside the same window. The monitoring system has a maintenance schedule configured to suppress alerts during 02:00–04:00 Tuesday so the reboot cycle doesn’t page anyone.

What I’d tell past-me

Uptime is not a virtue. “My streaming Mac has been up for 62 days” is the kind of thing systems people brag about, and on a production host it’s actually a smell. Whatever subtle resource leak any of your apps has, it’s accumulating. The streamer who reboots their box every Tuesday at 2am does not know what an mbuf is, and they don’t have to.

Set up the LaunchDaemon. Stagger your other hosts inside the same window. Tell your monitoring system to keep quiet during the window. Check the log file occasionally. Then forget about it. Your viewers will stay through the whole service.

ProPresenter Media Cleanup Guide

How we cleaned up a ProPresenter media library, removing duplicates, old content, and fixing broken media paths after a username change.

The Problem

Our ProPresenter installation had several issues:

  • Duplicate files wasting disk space (4.6 GB of duplicates)
  • Old content like funeral slideshows and dated events no longer needed
  • Broken media paths after the Mac username changed from mediateam to worshipmedia
  • Media referenced paths like /Users/Shared/Renewed Vision Media/ that no longer existed

Part 1: Finding and Deleting Duplicate Files

We created a bash script to find files with identical content using MD5 hashes, preferring to keep “originals” over files with _copy in the name.

#!/bin/bash
# find_duplicates.sh - Find and delete duplicate files in ProPresenter Media folder

MEDIA_DIR="$HOME/Documents/ProPresenter/Media"
ONEDRIVE_DIR="$HOME/OneDrive - Your Church Name/ProPresenter_Sync/Media"

# Set to 1 to actually delete, 0 for dry run
DRY_RUN=1

# Create temp files
HASH_FILE=$(mktemp)
DUPLICATES_FILE=$(mktemp)
trap "rm -f $HASH_FILE $DUPLICATES_FILE" EXIT

echo "Scanning $MEDIA_DIR..."

# Calculate MD5 hashes for all files
find "$MEDIA_DIR" -type f ! -name ".*" -print0 | while IFS= read -r -d '' file; do
    hash=$(md5 -q "$file" 2>/dev/null)
    if [[ -n "$hash" ]]; then
        echo "$hash|$file"
    fi
done > "$HASH_FILE"

# Find duplicate hashes
cut -d'|' -f1 "$HASH_FILE" | sort | uniq -d > "$DUPLICATES_FILE"

# Process each duplicate set
while IFS= read -r dup_hash; do
    files=()
    while IFS='|' read -r hash filepath; do
        [[ "$hash" == "$dup_hash" ]] && files+=("$filepath")
    done < "$HASH_FILE"

    # Keep original (file without _copy), delete others
    keep=""
    for f in "${files[@]}"; do
        if [[ ! "$f" == *"_copy"* && ! "$f" == *" copy"* ]]; then
            keep="$f"
            break
        fi
    done
    [[ -z "$keep" ]] && keep="${files[0]}"

    echo "KEEP: $keep"
    for f in "${files[@]}"; do
        if [[ "$f" != "$keep" ]]; then
            if [[ $DRY_RUN -eq 0 ]]; then
                rm -f "$f"
                # Also delete from OneDrive sync
                relative_path="${f#$MEDIA_DIR/}"
                rm -f "$ONEDRIVE_DIR/$relative_path"
            fi
            echo "  DELETE: $f"
        fi
    done
done < "$DUPLICATES_FILE"

Results: Found 227 duplicate sets, deleted 305 files, freed 4.6 GB.

Part 2: Finding Old/One-Time Content

We searched for presentations that were unlikely to be needed again:

  • Memorial and funeral services (named after individuals)
  • Dated annual events (Christmas Pageant 2021, Confirmation 2022)
  • One-time events (Town Hall presentations, Scout ceremonies)
  • Duplicate hymns in Special folder that exist in Default library
# Find presentations with dates or person names
find ~/Documents/ProPresenter/Libraries -name "*.pro" -exec basename {} ; | 
  grep -iE "[0-9]{4}|memorial|funeral|recognition|pageant"

We created a review file listing candidates for deletion with comments explaining why each could be removed, then manually reviewed before deleting.

Part 3: Finding Associated Media for Old Presentations

ProPresenter stores imported slides in Media/Imported/{UUID}/ folders. We needed to find which media folders were ONLY used by presentations being deleted (not shared with active presentations).

#!/usr/bin/env python3
# find_unique_media.py - Find media only used by presentations marked for deletion

import os
import re
from pathlib import Path

PROPRESENTER_DIR = Path.home() / "Documents/ProPresenter"
UUID_PATTERN = re.compile(r'[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}', re.IGNORECASE)

def extract_uuids(filepath):
    """Extract all UUIDs referenced in a .pro file."""
    with open(filepath, 'rb') as f:
        content = f.read().decode('utf-8', errors='ignore')
    return set(UUID_PATTERN.findall(content))

# Get UUIDs from presentations to delete vs keep
delete_uuids = set()
keep_uuids = set()

for pro_file in delete_presentations:
    delete_uuids.update(extract_uuids(pro_file))

for pro_file in keep_presentations:
    keep_uuids.update(extract_uuids(pro_file))

# UUIDs only in delete set are safe to remove
unique_uuids = delete_uuids - keep_uuids

# Find corresponding Media/Imported folders
for uuid in unique_uuids:
    folder = PROPRESENTER_DIR / "Media/Imported" / uuid
    if folder.exists():
        print(f"Safe to delete: {folder}")

Results: Found 5 unique media folders (44.5 MB) containing memorial slideshow images that could be safely deleted.

Part 4: Fixing Broken Media Paths

After a username change from mediateam to worshipmedia, all media paths were broken. ProPresenter stores paths in two places:

  1. Playlist files (protobuf format)
  2. Workspace database (LevelDB format)

Fixing Playlist Files with Protobuf

ProPresenter 7 uses Protocol Buffers for playlist files. We used the reverse-engineered schema from greyshirtguy/ProPresenter7-Proto.

# Clone the proto definitions
git clone https://github.com/greyshirtguy/ProPresenter7-Proto.git ~/dev/ProPresenter7-Proto

# Install protobuf tools
pip3 install grpcio-tools

# Compile proto files to Python
cd ~/dev/ProPresenter7-Proto/proto
python3 -m grpc_tools.protoc -I. --python_out=. *.proto
#!/usr/bin/env python3
# fix_media_paths.py - Fix paths in ProPresenter playlist files

import sys
from pathlib import Path
from google.protobuf.message import Message

sys.path.insert(0, str(Path.home() / "dev/ProPresenter7-Proto/proto"))
from proto import propresenter_pb2

PATH_MAPPINGS = [
    ("/Users/Shared/Renewed Vision Media/",
     "/Users/worshipmedia/Documents/ProPresenter/Media/Renewed Vision Media/"),
    ("/Users/mediateam/", "/Users/worshipmedia/"),
    ("/Users/tom/", "/Users/worshipmedia/"),
]

def fix_string(s):
    for old, new in PATH_MAPPINGS:
        s = s.replace(old, new)
    return s

def fix_message(msg, path="root"):
    """Recursively fix all string fields containing paths."""
    for field in msg.DESCRIPTOR.fields:
        if field.label == 3:  # Repeated
            for i, item in enumerate(getattr(msg, field.name)):
                if field.message_type:
                    fix_message(item, f"{path}.{field.name}[{i}]")
                elif field.type == 9 and '/' in item:  # String with path
                    getattr(msg, field.name)[i] = fix_string(item)
        elif field.message_type:
            sub_msg = getattr(msg, field.name)
            if sub_msg.ByteSize() > 0:
                fix_message(sub_msg, f"{path}.{field.name}")
        elif field.type == 9:  # String
            value = getattr(msg, field.name)
            if value and '/' in value:
                setattr(msg, field.name, fix_string(value))

# Parse and fix the Media playlist
media_file = Path.home() / "Documents/ProPresenter/Playlists/Media"
doc = propresenter_pb2.PlaylistDocument()
doc.ParseFromString(media_file.read_bytes())
fix_message(doc)
media_file.write_bytes(doc.SerializeToString())

Results: Fixed 3,472 path references in the Media playlist.

Fixing the Workspace Database

ProPresenter caches media information in a LevelDB database at:

~/Library/Application Support/RenewedVision/ProPresenter/Workspaces/ProPresenter-{ID}/Database/

The simplest fix was to let ProPresenter rebuild this database:

  1. Quit ProPresenter completely
  2. Stop the helper processes:
    pkill -9 -f "ProPresenter"
    launchctl bootout gui/$(id -u)/com.renewedvision.propresenter.workspaces-helper
  3. Delete or rename the Database folder
  4. Restart ProPresenter – it rebuilds the database and rescans media

Temporary Symlinks for Legacy Paths

For presentation files (.pro) that still reference old paths, we created symlinks:

# For /Users/Shared paths
mkdir -p /Users/Shared/Documents
ln -sf ~/Documents/ProPresenter /Users/Shared/Documents/ProPresenter
ln -sf ~/Documents/ProPresenter/Media/Renewed Vision Media /Users/Shared/Renewed Vision Media

# For old username paths (requires sudo)
sudo mkdir -p /Users/mediateam/Documents
sudo ln -sf /Users/worshipmedia/Documents/ProPresenter /Users/mediateam/Documents/ProPresenter

Summary

Task Files Affected Space Freed
Duplicate removal 305 files 4.6 GB
Old presentations 24 files 785 KB
Orphaned media folders 5 folders (187 files) 44.5 MB
Path fixes 3,472 references

Total space recovered: ~4.7 GB

Tools Used

  • md5 – macOS built-in hash tool for duplicate detection
  • protobuf/grpcio-tools – For parsing ProPresenter playlist files
  • ProPresenter7-Proto – Reverse-engineered protobuf schema
  • Python 3 – Scripting for media analysis and path fixing

Tips

  1. Always run duplicate finder in dry-run mode first
  2. Back up the Playlists/Media file before modifying
  3. The ProPresenter workspace database rebuilds automatically – sometimes deleting it is the easiest fix
  4. When deleting media, also delete from your sync folder (OneDrive, Dropbox, etc.)
  5. Check both Media/Assets/ and Media/Renewed Vision Media/ for files – they may be in unexpected locations