-
Notifications
You must be signed in to change notification settings - Fork 0
TROUBLESHOOTING
Generated from
docs/TROUBLESHOOTING.md. Edit that file and re-runnode scripts/publish-wiki.mjs --push. An edit made here is a fork of the documentation that nothing reconciles, and the next run of this script will overwrite it without asking.
Organised by what you observe, because that is what you have when something is wrong.
Two commands answer a surprising share of problems:
polyemesis -log debug # logs every child's full command line as it spawns
ffmpeg -version | head -1 # 6.0+ requiredAnd one page: Monitoring shows each running process with its own FFmpeg output. When a destination or an ingest misbehaves, the explanation is usually sitting in that process's log rather than in the server log — FFmpeg's own words go to the event bus, not to the log file.
polyemesis refuses to start below 6.0 rather than failing later in a way that
looks like a bug. Ubuntu 22.04 ships 4.4 and Debian 12 ships 5.1, so
apt install ffmpeg is not a universal answer. Your options are a newer distro,
a static FFmpeg build with libsrt, or Docker —
INSTALL.md has all
three with commands.
Not fatal — a warning, and a correct one. The login form and session cookie
cross the network in clear text. Set tls.mode: auto, or bind to 127.0.0.1
and use an SSH tunnel.
Something else has :8080 or the ingest port. On Linux:
ss -lntup | grep -E ':8080|:6000'A previous polyemesis that did not shut down cleanly is a common cause. Note that its FFmpeg children are in their own process groups, so killing the server does not take them with it — check for strays:
pgrep -af ffmpeg-
Is the port published? In Docker, SRT is UDP:
-p 6000:6000/udp. Missing/udpis the single most common cause. - Does the mode match? An SRT listener will not accept an RTMP publisher.
-
Is the address right for that source? Both listeners are shared, so the
token (SRT) or the stream key (RTMP) is what picks the source out — a valid
URL with the wrong key reaches the server and is refused, it does not fall
through to whichever source happens to be there. On RTMP the log line is
rtmp publish refusedand it deliberately names no source: "no such key", "that source is disabled" and "that source has no engine" are three different facts, and telling them apart is what would let someone enumerate which keys exist. Copy the URL and key from Sources rather than retyping them. - Is a firewall in the way? SRT is UDP and is often dropped by default.
-
Does your FFmpeg have SRT?
ffmpeg -protocols | grep -x srt. Homebrew's does not. Without it, the ingest cannot listen and the log saysProtocol not found. -
Are you on macOS with a bare
:port? See directly below — this one looks exactly like a firewall and is not one. -
Is the link lossy? A handshake can fail on loss that an established
stream would shrug off, and it fails with a bare
I/O errorthat names nothing. See It connects on some attempts and not others.
This no longer happens. It is kept here because the symptom was distinctive and someone running an older build will still meet it.
The distinguishing symptom was the silence. Every refusal polyemesis makes is
typed and logged, so a publisher failing with an I/O error while the server log
said nothing at all had not reached the handshake — no refusal was made, because
no connection was ever offered.
On macOS a bare :6000 accepted IPv6 publishers only. Datagrams from an IPv4
caller arrived — a plain listener on the same address received them — but the SRT
handshake never completed:
| Listen address | Caller | Linux | macOS (before the fix) |
|---|---|---|---|
:6000 |
IPv4 | ok | times out |
:6000 |
IPv6 | ok | ok |
0.0.0.0:6000 |
IPv4 | ok | ok |
127.0.0.1:6000 |
IPv4 | ok | ok |
The cause is upstream, in datarhei/gosrt: a reply to a v4-mapped peer goes out
through golang.org/x/net's ipv4.PacketConn carrying an IPv4 control message
on an AF_INET6 socket, Darwin rejects that combination with
sendmsg: invalid argument, and packetConn.writeToFrom has no error return —
so the failure is discarded. Reported as
datarhei/gosrt#148.
polyemesis no longer takes that path. gosrt chooses its network from the
address it is given — an empty host becomes a dual-stack udp, a v4 literal
becomes udp4, a v6 literal becomes udp6 — and only the first is affected. So
a wildcard address now binds 0.0.0.0 and :: as two separate listeners, which
between them accept both families on every platform. Two sockets can share the
port because Go sets IPV6_V6ONLY on the udp6 network.
If one family cannot be bound — a host with IPv6 disabled, say — the other still serves and the reason is logged. Only losing both is fatal.
With one-port ingest the refusal is typed and says which:
| Reason | What it means |
|---|---|
REJ_BADSECRET |
Wrong passphrase, or a token matching no source |
REJ_CLOSE |
The source exists but is disabled |
REJ_RESOURCE |
Something is already publishing to that source, or no pipeline is running for it |
REJ_ROGUE |
The streamid is empty or over the length limit |
REJ_UNSECURE |
The source requires a passphrase and none was offered — or the publisher encrypted and the source has no passphrase set |
A token that does not exist and a token for a source that does not exist give the same answer deliberately, so a caller cannot use the refusal to enumerate sources. Neither is ever logged.
The two REJ_RESOURCE cases are worth telling apart, and the server log does:
already publishing names the incumbent peer, no pipeline for source means
the source is enabled but nothing is running to receive it.
Usually the encoder and the server disagreeing about latency, or genuine packet loss. The source's link telemetry (RTT, loss, retransmits) is on the Sources page. Raise the SRT latency on both ends if loss is real.
Retrying works, and that is the fix. The reason it works is worth knowing, because the numbers say loss is hurting one specific moment rather than the link as a whole.
Once the connection is established, the data path is very robust. Measured against a live server over 40s runs — 1200 kbit/s video plus three audio tracks — with sender-side loss applied 8s in, so every handshake completed on a clean link:
| Sender-side condition | Publisher live | Packets lost | Delivered |
|---|---|---|---|
| clean | 100% of samples | 0 | 6.1 MB |
| 2% loss | 100% | 199 | 6.1 MB |
| 5% loss | 100% | 463 | 6.1 MB |
| 10% loss + 120ms±40ms jitter | 100% | 4766 | 5.9 MB |
| 20% loss | 100% | 1788 | 6.1 MB |
The publisher never left the live state in any condition, and all three audio tracks kept metering throughout every one. At 20% loss the stream still delivered the same 6.1 MB as a clean link. The jittered row is the only one that lost ground — RTT rose to 89.8ms and delivered bytes fell about 3% — and it is also the only row with jitter. Counts here are not monotonic in loss and should not be read as if they were.
The handshake is the weak point. Same conditions, but with loss present from the first packet so that it applies during connection setup — six attempts each:
| Condition | Connected |
|---|---|
| clean | 6/6 |
| 2% | 6/6 |
| 5% | 6/6 |
| 10% | 4/6 |
| 20% | 4/6 |
The asymmetry is structural rather than a defect. A data stream is thousands of packets protected by retransmission that has already been negotiated, so losing one costs a retransmit. A handshake is a handful of packets exchanged before any of that machinery exists, so losing one loses the whole attempt.
A failed attempt surfaces at the publisher as nothing more than:
Error opening output files: I/O error
That is FFmpeg's message, not polyemesis's, and it names neither SRT, nor loss,
nor suggests retrying. The server side is what tells you which failure you have.
A handshake that never completed produces no refusal at all — nothing is
logged, because nothing was refused. If instead you get a typed REJ_ reason,
loss is not your problem and the refusal table is.
What to do: retry, and turn on your encoder's auto-reconnect so it retries for you. At 10% loss a second attempt is very likely to succeed where the first did not, and once it does the connection will carry the stream.
Raising SRT latency does not help here. Latency sizes the receive buffer on an established connection, which is exactly the part that was already surviving 20% loss; it does not protect the handshake. Raise it for the symptom in the section above, not this one.
Open its process log on the Monitoring page first. The platform's own rejection is almost always there.
The Library probes every upload before it is stored under its final name, and
refuses anything ffprobe cannot read as media. Usually the message is ffprobe's
own: Invalid data found when processing input means the file is not what its
name says, and moov atom not found means an MP4 whose end is missing.
Two refusals are polyemesis's own words rather than ffprobe's:
- "this file carries no video or audio stream" — ffprobe read the container and found nothing playable in it. A renamed archive arrives this way.
- "this file is a playlist or script naming other files, not media itself" — the file is an ffconcat script, an HLS playlist or similar. These are refused even though ffprobe reports streams for them, because the streams belong to the files they NAME. A two-line, 44-byte text file would otherwise be stored with another video's codecs, resolution and duration shown as its own.
This is stricter than it used to be. The extension list was never a gate: an
unrecognised extension was stored as .bin and listed as media anyway, so a
PDF or a zip could sit in the Library looking like a video until a playlist
normalise job failed on it — or until it reached air.
A file the server accepts but you cannot play locally is worth checking the other way round: your player may lack a codec this FFmpeg has.
Do not read the check as a completeness guarantee, because it is not one.
moov atom not found only appears for an MP4 whose index sits at the end of
the file, which is the default layout. Measured on the FFmpeg this repository
builds against:
| file, cut to 10% of its length | result |
|---|---|
| MP4, default layout |
refused, moov atom not found
|
MP4 written with -movflags +faststart
|
accepted, and the Library shows the ORIGINAL duration |
Matroska (.mkv) |
accepted, and the Library shows the ORIGINAL duration |
So a partial download of a faststart MP4 or an MKV is accepted and listed as
ten minutes long while holding one. The check answers "is this media", not "is
this all of it". internal/ffmpeg.TestProbeFileAcceptsMostTruncatedMedia pins
each of the three rows above.
The check is skipped rather than failed when it cannot run, and the upload is stored unchecked rather than refused. Refusing every upload in any of these cases would be a worse outage than the one it guards against — and deleting the file, which is what the first version of this did on a disconnect, destroys a transfer that had already completed.
There are five ways it happens:
| what happened | why the file is kept |
|---|---|
the server has no ffprobe
|
nothing to judge with |
| it has no running engine (an install whose video pipeline will not build logs the reason and keeps serving) | nothing to judge with |
| the client disconnected while the probe was running | the transfer completed; the inspection did not |
| the probe took longer than 30 seconds | a slow disk must not delete valid media |
| the probe could not be started, or printed something the server could not read | a fork that failed is a fact about this server, not about your file |
The third row is the one worth understanding, because it is under the caller's control, not the server's. The check runs while the request is still open, so anything that ends the request early — a dropped connection, a proxy timeout, a browser tab closing — ends the check. It is not only an operational condition; a client that sends a complete body and then hangs up gets the file stored with nothing having read it, on purpose if it likes.
So the state is recorded, not merely logged:
-
GET /api/v1/mediacarries"verified": falsefor it, always present, plusunverifiedReasonsaying which row above applies. A file that passed carries"verified": trueand itsmediablock.mediabeing absent is not the signal — that is also how an upload from before this feature looks. -
GET /api/v1/mediaalso carries"outcome", always present, and it is the field to branch on.verifiedis still true only for "inspected and accepted", so false covers three situations with different remedies — andoutcomeis the field that tells them apart. Here are all four of its values:outcomewhat it means what to do verifiedinspected and accepted nothing unverifiedthis server produced no verdict (one of the five rows above) upload it again refusedthe bytes were inspected and are not media this server takes replace the file; re-sending it changes nothing unrecordednothing was ever written about this file — every upload stored before verdicts existed nothing; the normalise worker re-checks it at the moment of use unrecordedis not stored anywhere: it is what the listing says when there is no.probe-record beside the file, and it stays distinct from every recorded state because refusing those uploads would strand media an operator has had for a year. -
The Library shows a Not checked marker on the row, or Refused for the
refusedstate — never both, and never the first for the second. -
A settings save that adds such a file to a playlist is refused, naming the file and telling the operator to upload it again — or, for a
refusedfile, saying it was inspected and refused and that sending it again will not change that. Items already in the stored playlist are not refused — see below. -
The normalise worker re-runs the same format check on whatever it is handed before it transcodes anything, so an item that reaches it by any other route is caught there instead.
The remedy is to upload the file again on a connection that stays up. There is no way to mark a stored file as checked without re-uploading it, deliberately: the server would be recording a pass it did not perform. A job that re-runs the check against a file already on disk is issue #202.
Nothing writes refused yet. The upload handler still answers 400 and
discards the staged bytes, which is right — nothing references a file that was
never published. The state exists because anything that inspects an upload
later cannot do that: the file is published by then, DELETE answers 409
while a playlist item names it, so the refusal has to be recorded — and until
this state existed the only way to record it was as unverified, which every
consumer answers by telling you to upload the same bytes again. The re-verify
job of #202 is the first writer.
One thing this does not cover: an unchecked file's pull URL still works, so pasting it into a pull source bypasses all of the above. That is issue #201.
Every one of them also writes a WARN line naming the upload:
level=WARN msg="no ffprobe available; accepting this upload unchecked" reason="this install reports no ffprobe binary" name=show-629507cb.mkv
level=WARN msg="upload probe was interrupted; accepting the file unchecked" name=show-4aee482d.mkv cause="context deadline exceeded" err="signal: killed"
level=WARN msg="the upload probe could not be run; accepting the file unchecked" name=show-1f0c22a1.mkv err="fork/exec /usr/bin/ffprobe: no such file or directory"
level=WARN msg="an upload was stored without being inspected" name=show-4aee482d.mkv reason="the inspection was cut short before it finished"
name is the stored filename, so it matches what the Library shows.
The startup log will not tell you: it prints ffmpeg's version and path and says
nothing about ffprobe, and there is no "engine came up" line to look for. Grep
the running server's log for unchecked.
The file is real media in a format the upload path does not accept — the check is an allowlist of containers whose streams live in the bytes we were handed, so a legitimate AIFF, y4m, IVF or GIF is refused by it. Re-save it as MP4 or MPEG-TS.
This is a different refusal from "this file is a playlist or script naming other files", which means the opposite thing: ffprobe read the file perfectly and reported some other file's streams as its own. The two used to share one message, so an operator refused a DV file was told to go looking for a script that did not exist.
The routing graph references a track the incoming stream does not have. Either the source stopped sending that track, or the destination selects a track that was never there. Check the source's probed layout on the Sources page against the destination's selection.
For a video-only source, this is expected until you turn on the silence tier (Settings → Synthetic), which synthesises a silent stereo track so destinations have something to select.
Fixed. Older builds passed a fixed output path to FFmpeg, which refuses an existing file — so the first restart died with "already exists" and every one after it did too. Current builds pick a fresh path per spawn and never overwrite existing footage.
- Bitrate above what the platform allows. The platform presets set limits the platform will actually take; a manual configuration can exceed them.
- Keyframe interval. Video is passed through untouched, so this is your encoder's setting, not a polyemesis one. Most platforms want 2 seconds.
- A backwards timestamp. A platform drops the connection on one. If this happens at a failover switch, that is a bug worth reporting — the failover suite measures exactly this and expects zero.
This is what the product is for, so it is worth measuring rather than guessing. The Meters page shows loudness after routing — what the platform actually receives.
Its track selection includes only tracks the source is not sending. Selecting a track that is not there gives you silence, not an error.
If you are on an old build, this was a real bug: FFmpeg's amix divides by the
input count by default, which cost about 9.5 dB on a three-track mix. Current
builds set normalize=0. If you still see it, check for a normalisation or
limiter setting on the destination profile.
Set the per-destination audio delay. A negative delay pulls audio ahead of picture, which is done by shifting the video instead — no audio filter can move sound earlier than it arrived.
The measurement is post-routing and needs a minimum integration time before it means anything; a reading taken in the first few seconds is not yet meaningful. Check the destination's target matches the platform you are sending to.
EXPERIMENTAL — on Twitch this depends on Enhanced Broadcasting. The negotiation is proven against
ingest.twitch.tv; a broadcast published through the key it mints is not. If you get to the end of this list and the track still is not there, that is the unobserved step, and an issue with the destination card's message in it is worth more than anything on this page.
polyemesis says which of these it was on the destination's card, once the destination has gone live at least once. Check there first — it names the reason rather than making you guess between them.
- Enhanced Broadcasting is off for this destination. The ordinary Twitch RTMP ingest carries one audio track, so the engine refuses the pair before the broadcast starts and publishes the live mix alone. Switch it on in the destination's settings. The routing editor also says so, in the second-mix card, before you save.
- No GPU inventory is declared. Twitch refuses the negotiation outright for a client that reports none — "did not send GPU Information" — and it checks the inventory it is sent, not the hardware (see ENCODING.md §2). Fill it in on the Settings page. A declared GPU Twitch does not recognise, or a driver version it considers out of date, is refused by name too.
- The ingest has not been probed yet. The second mix is dropped on every platform while the channel layout is a guess — the live mix is already running provisionally and a second guessed mix is not stacked on top of it. It returns by itself on the first reconcile after a probe succeeds; nothing to do.
- The far end is not Twitch and takes one track. Off Twitch nothing is negotiated: both mixes are published and whether the second is accepted is a property of that ingest. Many RTMP ingests ignore or reject it.
A refusal at any of these never fails the broadcast: the destination falls back to publishing the live mix alone to the ordinary ingest.
EXPERIMENTAL — the command-line flags polyemesis hands NVENC, QSV, VA-API and AMF encoders were read out of FFmpeg's own option tables rather than measured on silicon. VideoToolbox and the software encoders are not in that set. See ENCODING.md § Per-encoder flags.
If the encoder opens and produces picture, the argv is at least valid — an invalid flag value refuses to open and the start gate reports it by name. What is not established is whether the rate control then behaves as the numbers in the editor say, and the capped-VBR path (a bitrate ceiling above the target) is where that is most likely to show. Compare the measured output bitrate against the target you set; if they disagree, the fallback that works today is to set the ceiling equal to the target, which asks for CBR. Please report it — this is the exact evidence gap the label exists to mark.
Check your FFmpeg major version. This changed between the versions polyemesis supports, and the difference is a whole GOP rather than rounding — measured on a one-second-GOP source, asking for a 4.4s clip:
| FFmpeg | Result |
|---|---|
| 6.1.2 | 5.402s — keeps the packets through the end of the GOP containing the out point |
| 8.1.2 | 4.423s — stops at the out point |
Neither is wrong: a stream copy can only cut on packet boundaries, and which side of the boundary to land on is a choice. But on FFmpeg 6.x a copied clip can run up to one GOP long, and that applies to precise mode too — precise re-encodes only the head and copies the tail.
It only shows up when the out point falls mid-GOP. An out point that lands on a keyframe is exact on both.
If you need exact out-points, use FFmpeg 8.x.
The free-space guard halts recording rather than filling the disk. The Recordings page reports the state and the reason. It resumes when space is available.
Known defect, and the loss is bounded rather than total.
The graceful stop is a CTRL_BREAK console event. A Windows service has no
console for it to travel through, so the supervisor terminates the recorder
instead of asking it to finish, and FFmpeg never writes the container index for
the segment it was filling. The service logs this warning at every start, under
event ID 3 in the Event Viewer.
Only the in-progress segment is affected. The recorder writes segmented MKV,
so every segment that had already rolled over is finalised and plays normally. A
shorter recording.segmentSeconds puts less at risk each time.
To avoid it: stop recording from the UI and wait for it to appear in the
Recordings list before stopping the service. Running polyemesis from a
console rather than as a service is unaffected — there the CTRL_BREAK is
delivered and the recording is finalised.
The governor defers work under load — this is deliberate, so post-production does not compete with a live broadcast. Jobs → overview shows what is queued and why. Check the queue is not paused.
The file was swept by retention between the page listing it and you clicking it. Expected, and a 404 rather than an error for that reason.
Publish the ingest port with the right protocol. SRT is UDP, RTMP is TCP:
-p 8080:8080 -p 6000:6000/udp -p 1935:1935/data is not on a volume. One mount covers everything — the database, the key,
recordings and TLS material all live under it.
Up to about 30 seconds, and that is on purpose: recordings are finalised on the
way down. stop_grace_period: 30s is set in the compose file. Forcing a shorter
timeout truncates the recording you were making.
Open an issue with the bug template. The most useful report has
polyemesis -version, ffmpeg -version, the ingest mode, the relevant process
log from the Monitoring page — and for anything about audio or timing, what you
measured rather than what it sounded like.
Check for stream keys and passphrases before pasting logs.
Getting it running
- Quickstart: from nothing to a live restream
- Install polyemesis — an SRT server on your own box
- OBS SRT setup: multitrack audio to one ingest
- TLS certificates for a self-hosted SRT server
The routing
- Audio routing: a different mix per destination
- Renditions: one shared video encode
- Encoding: what is copied and what is encoded
- Hardware encoding: NVENC, QSV, VA-API, AMF
Operating it
- Configuration: config.yaml and the web UI
- Streaming platforms: what can be automated
- Broadcasting from a file, on a schedule
- What a settings change restarts, and what it does not
- Upgrading polyemesis and its database
- Troubleshooting: SRT, RTMP and audio problems
Automating it
- Monitoring: Prometheus metrics and alerts
- Lifecycle webhooks: one signed POST per event
- MQTT telemetry and Home Assistant
- HTTP API reference — polyemesis /api/v1
Understanding it