Skip to content

fix: stop dual output recording two canvases to one file - #1759

Merged
summeroff merged 7 commits into
stagingfrom
fix/dual-output-filename-collision
Aug 9, 2026
Merged

fix: stop dual output recording two canvases to one file#1759
summeroff merged 7 commits into
stagingfrom
fix/dual-output-filename-collision

Conversation

@summeroff

@summeroff summeroff commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Dual output starts a recording per canvas. Both derive their filename from the same global Output/FilenameFormatting default — "%CCYY-%MM-%DD %hh-%mm-%ss", one-second granularity, no per-canvas component — so two recordings started together resolve to the same absolute path and two ffmpeg_muxer instances open one file.

libobs tolerated that silently until the Windows nofollow open tightened its share mask (obs-studio#758), when the second open began failing with EACCES and took down 27 tests. obs-studio#760 relaxed the mask again, so CI is green — but green only means the collision is silent again. The two muxers are still interleaving into one file.

Why the existing guard doesn't catch it

FindBestFilename was purely os_file_exists()-based. ffmpeg_muxer creates the file asynchronously after the start signal, so when the second Start() resolves its path the first file does not exist yet and the name is handed out twice.

The per-canvas WIDTHxHEIGHT-NN suffix that used to make this impossible was removed in b3175ad (#1675) as a "rand hack". That PR did thread the canvas into filename generation, but the canvas is only read for the opt-in %FPS/%CRES/%ORES/%VF tokens — the default pattern contains none of them, so it has no effect. (And it would not have been sufficient anyway: two canvases at the same resolution produce identical %CRES.)

Changes

1. Correctness — an in-memory claim. Each output records the path it claimed; FindBestFilename skips names another live output holds, found via IFileOutput::Manager::for_each. The claim lives on the output object rather than in a process-wide registry so it cannot outlive its owner: once the object leaves the manager it is invisible, making a missed release harmless instead of a permanent phantom that would bump every later recording to (2), (3), … for the rest of the session. Released from the libobs stop signal and from the obs_output_start() failure path (which synthesises its own stop), under a mutex — Start() runs on an IPC worker thread and the release on libobs' capture thread.

overwrite now suppresses only the on-disk check. It means "clobber the stale file from last session", which cannot coherently extend to a file another output is writing right now. With a single output there is no peer claim, so behaviour is byte-for-byte unchanged.

2. UX — per-canvas naming. (2) is a safety net, not an answer: it says "something was already there", not "this is your vertical recording", and which canvas wins the base name depends on start order. Recordings had no naming knob at all — only the replay buffer did. osn::Recording now has the same prefix/suffix, wrapped around fileFormat before the extension, exposed over IPC and on IRecording:

2026-08-09 14-30-12.mp4
2026-08-09 14-30-12 Vertical.mp4

Reserved path characters are stripped from prefix/suffix as the replay buffer does; fileFormat passes through untouched so no existing pattern changes meaning.

3. Diagnosability. A LOG_WARNING when the claim actually renames a path. Stepping over a file left on disk is ordinary and stays quiet; stepping over a path a running output holds means a client pointed two outputs at one file.

Test plan

  • New Catch2 tests (obs-studio-server/tests/test-osn-file-output.cpp) — peer claim, noSpace _2 form, the overwrite decision, separator/case folding, release-and-reuse, and DecoratedFileFormat spacing/sanitisation. Verified failing before the fix by stubbing the peer check: 16 | 12 passed | 4 failed → all passing. Full unit suite 109 assertions in 4 cases.
  • Dual canvas recordings can be told apart by suffix (new integration test) — passes locally; asserts both outputs get their requested names with no (2) anywhere, proving the naming path end to end.
  • Dual canvas recording avoids name collisionwaitForFile() removed from between the two start() calls. That helper existed to force the first file onto disk so the os_file_exists check had something to see; without it this is a genuine regression test for the claim.
  • Start Dual Output with advanced recording using the same user audio track — added lastFile() inequality assertion, covering the default-filename case a real user hits.
  • obs-studio-server-lib and obs_studio_client.node build clean; clang-format 18.1.3 clean on all 13 changed C++ files; yarn build:javascript regeneration is stable.

All three dual-output tests confirmed green on CI (182 passing overall).

Follow-ups

  • Desktop must set suffix = 'Vertical' on the vertical recording in createRecording, and uncomment the per-display addRecordingEntry labelling. Until then dual output still collides — but now logs a warning instead of being silently wrong. Needs an osn bump containing this PR first.
  • Split-file chunks 2..N are out of scope. Chunk 1 is covered (it uses settings["path"], which survives the obs_data_apply merge), but ConfigureRecFileSplitting hands directory+format to libobs, which names later chunks from its own os_file_exists check on the muxer thread with no path back into osn. The correct fix is upstream: resolve names by exclusively creating the file rather than by os_file_exists, which closes the same race for every consumer.
  • waitForFile in tests/osn-tests/util/general.ts is now unused; left in place as a reasonable general helper.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Prevents simultaneous canvas recordings from writing to the same file after the filename behavior introduced in #1675.

Changes:

  • Adds synchronized in-memory filename claims.
  • Adds recording prefix/suffix naming.
  • Adds unit and dual-output integration coverage.

Reviewed changes

Copilot reviewed 16 out of 17 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/osn-tests/src/test_osn_dual_output.ts Tests collision avoidance and suffix naming.
obs-studio-server/tests/test-osn-file-output.cpp Tests claims and decorated formats.
obs-studio-server/source/osn-simple-recording.cpp Applies naming and claims to simple recordings.
obs-studio-server/source/osn-recording.hpp Defines prefix/suffix API.
obs-studio-server/source/osn-recording.cpp Implements decorated filename formatting.
obs-studio-server/source/osn-output.hpp Adds a stop lifecycle hook.
obs-studio-server/source/osn-output.cpp Invokes cleanup after stops and failed starts.
obs-studio-server/source/osn-file-output.hpp Stores and exposes filename claims.
obs-studio-server/source/osn-file-output.cpp Implements synchronized claim resolution.
obs-studio-server/source/osn-advanced-recording.cpp Applies naming and claims to advanced recordings.
obs-studio-server/CMakeLists.txt Registers the new unit tests.
obs-studio-client/source/simple-recording.cpp Exposes prefix/suffix accessors.
obs-studio-client/source/recording.hpp Declares client accessors.
obs-studio-client/source/recording.cpp Implements prefix/suffix IPC calls.
obs-studio-client/source/advanced-recording.cpp Exposes advanced recording accessors.
js/module.ts Documents the TypeScript API.
js/module.d.ts Updates generated declarations.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +37 to +40
std::replace(key.begin(), key.end(), '\\', '/');
#ifdef WIN32
std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { return (char)tolower(c); });
#endif

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and fixed in 01e3c2a. claim_key now applies Windows path-equivalence only under WIN32 and compares byte-exact elsewhere, for exactly the reason you give: on POSIX a backslash is an ordinary filename character.

The macOS runners caught the same thing independently — my local verification was Windows-only, so it could not have.

Comment on lines +61 to +65
SECTION("separator and case differences do not evade the check")
{
REQUIRE(resolve(wanted, first.get()) == wanted);
REQUIRE(resolve("C:\\OSN-Test\\2026-08-08 14-30-12.mp4", second.get()) == "C:\\OSN-Test\\2026-08-08 14-30-12 (2).mp4");
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 01e3c2a, alongside the claim_key change. The section is now #ifdef WIN32, with a POSIX counterpart asserting the opposite: that two paths differing only by separator stay distinct.

Comment thread obs-studio-server/source/osn-output.cpp Outdated
}

// libobs emits no "stop" here, so OnStopped() never runs for a failed start.
OnOutputStopped();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — real, and fixed in 8e392f2.

Confirmed the mechanism: obs_output_can_begin_data_capture() returns false while active(output), so a second start() on a running recording does land here with the first muxer still writing. Now guarded:

if (!obs_output_active(m_output))
    OnOutputStopped();

Worth noting the on-disk check masks this in the common case (the file exists by then), but not with overwrite = true — which suppresses exactly that check — so your parenthetical was the important part.

const std::string wanted = claim_key(candidate);
bool claimed = false;

osn::IFileOutput::Manager::GetInstance().for_each([&](osn::FileOutput *output) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 8e392f2. Both Destroy handlers now call DeleteOutput() before Manager::free(), so the object stays visible for the whole stop wait. DeleteOutput() is idempotent (if (!m_output) return;), so the destructor call becomes a no-op and blocking behaviour is unchanged — only the ordering.

I preferred this to moving claims into longer-lived storage: keeping the claim on the object is what makes a missed release self-limiting rather than a permanent phantom.

@summeroff
summeroff force-pushed the fix/dual-output-filename-collision branch from 14dadda to 8e392f2 Compare August 9, 2026 22:02
@summeroff
summeroff requested a balanced review from Copilot August 9, 2026 22:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 17 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

obs-studio-server/source/osn-file-output.cpp:69

  • Calling start() again on an active recording re-enters this resolver. The existing file then makes candidate become ... (2), replacing the owner's original claim even though StartOutput() fails and the original muxer remains active. A subsequent output with overwrite=true can therefore claim the original path and recreate the corruption this guard is intended to prevent. Make resolution idempotent whenever the owner already has a live/pending claim (or reject an active second start before resolving).
	std::lock_guard<std::mutex> lock(s_filenameClaimMutex);

Comment on lines +71 to +87
const char *ext = strrchr(strPath.c_str(), '.');
const int extStart = ext ? int(ext - strPath.c_str()) : -1;

std::string candidate = strPath;
int num = 2;
bool blockedByLiveOutput = false;

for (;;) {
const bool onDisk = !allowOverwrite && os_file_exists(candidate.c_str());
const bool heldByPeer = path_claimed_by_other(candidate, owner);

if (!onDisk && !heldByPeer)
break;
if (heldByPeer)
blockedByLiveOutput = true;
if (extStart < 0)
break;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real, and reachable exactly as you describe — fixed in 5fe4151.

Confirmed the truncation: osn_generate_formatted_filename() appends the extension and then clamps, so the extension is what gets cut:

dstr_cat_ch(&sf, '.');
dstr_cat(&sf, extension);
...
if (sf.len > 255)
    dstr_mid(&sf, &sf, 0, 255);

and the new prefix/suffix API makes 255 bytes easier to reach. The insertion point is now the end of the name when there is no extension, so the live-output guarantee holds either way.

Verifying this also turned up an adjacent bug the same fix covers: strrchr searched the whole path, so a dot in a directory name was treated as the extension — C:/my.dir/rec became C:/my (2).dir/rec. The search is now scoped to the final path component. That one predates this PR.

Both are covered by new unit cases (an extensionless name is still made unique, a dot in a directory is not treated as the extension); each fails against the previous logic.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 17 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

obs-studio-server/source/osn-file-output.cpp:78

  • This changes collision handling when fileFormat is empty. The formatter then produces .mp4, but dot == nameStart is treated as extensionless, yielding .mp4 (2) instead of the previous (2).mp4. Preserve the leading appended extension while still excluding dots in directory components.
	const size_t insertAt = (dot != std::string::npos && dot > nameStart) ? dot : strPath.size();


if (!recording->overwrite)
FindBestFilename(path, recording->noSpace);
FindBestFilename(path, recording->noSpace, recording, recording->overwrite);

@summeroff summeroff Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right again, and it is the other half of the one before it — fixed in 9795070.

I guarded the release path in 8e392f2 but left the resolve path free to move the claim. The sequence you describe:

  1. start() resolves A, claims A, muxer writes A
  2. start() again — the whole Start() handler reruns, the timestamp has advanced, so FindBestFilename resolves B and reassigns claimedFilePath = B
  3. obs_output_start() refuses because the output is active; the muxer is still on A, and nothing claims A

With overwrite = true suppressing the on-disk check, another output can then take A. The claim is now only taken when the output is not already active:

if (owner && !obs_output_active(owner->GetOutput()))
    owner->claimedFilePath = strPath;

obs_output_active() is null-safe (return (output != NULL) ? ... : false), so an output that has not been created yet still claims normally.

On your alternative — rejecting the duplicate start before resolving a filename — I agree it is the better fix, and it would also stop the stale obs_output_update(path = B) that currently lands on a running output. I did not take it here because it changes what the Start IPC call returns for a case Desktop reaches routinely through validateOrCreateOutputInstance, which calls start() on an already-validated instance. That deserves deciding on its own terms rather than as a side effect of this PR.

One caveat on coverage: the unit tests exercise the not-active branch only, since GetOutput() is null there. The active branch has no unit coverage.

summeroff and others added 7 commits August 10, 2026 00:46
Both canvases derive their filename from the default "%CCYY-%MM-%DD %hh-%mm-%ss",
which has one-second granularity and no per-canvas component, so two recordings
started together resolve to the same path. FindBestFilename only consulted the
filesystem, and ffmpeg_muxer creates the file asynchronously after the start
signal, so the second Start() saw no file and reused the name. Both muxers then
opened the same file.

libobs tolerated that silently until the Windows nofollow open tightened its
share mask, at which point the second open failed with EACCES. The share mask
has since been relaxed again, so this is back to being silent -- the recordings
are still interleaving into one file.

Resolve the collision in memory instead of on disk: each output records the path
it claimed, and FindBestFilename skips any name another live output is holding.
The claim is stored on the output object rather than in a process-wide registry
so it cannot outlive its owner -- once the object leaves the manager it is
invisible, and a missed release is harmless rather than a permanent phantom.

overwrite now suppresses only the on-disk check. It says "clobber the stale file
from last session", which cannot coherently extend to a file another output is
writing right now; two live muxers on one handle corrupt it either way. With a
single output there is no peer claim, so behaviour is unchanged.

Also drop the waitForFile() in the existing collision test. It was there to let
the first file reach disk before the second start -- exactly the race being
fixed -- so removing it turns that test into a real regression test.

Known gap: split-file chunks 2..N are named inside libobs from its own
os_file_exists check, with no path back into osn. Chunk 1 is covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The claim added in the previous commit stops dual output corrupting a file, but
it does so by renaming the second recording to "... (2)". That is a safety net,
not a user-facing answer: " (2)" means "something was already there", says
nothing about which file is horizontal and which is vertical, and which canvas
wins the base name depends on start order.

Desktop already knows the answer -- it creates the two outputs with
display: 'horizontal' / 'vertical' and labels them that way in Recording History
-- it just had no way to say so in the filename. Recordings had no naming knob at
all; only the replay buffer did.

So give Recording the same prefix/suffix the replay buffer has, wrapped around
fileFormat before the extension is added, exposed over IPC and on IRecording.
Desktop can then produce "2026-08-09 14-30-12 Vertical.mp4" and the collision
never happens.

Reserved path characters are stripped from prefix and suffix, matching the replay
buffer. fileFormat itself is passed through untouched, so no existing pattern
changes meaning.

Also narrow the new warning to the case worth reporting. Stepping over a file
left on disk is ordinary and always has been, so it stays quiet; stepping over a
path another running output holds means a client pointed two outputs at one file,
and that is now logged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
js/module.d.ts is emitted from js/module.ts by `yarn build:javascript`
(declaration: true), so hand-editing it would have been reverted by the next
regeneration and flagged by the check-js-generated CI job. Declare the two
properties in module.ts and regenerate; tsc strips the doc comment from the
.d.ts because removeComments is on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claim_key folded backslashes into forward slashes on every platform. On POSIX a
backslash is an ordinary filename character, so "a\b.mp4" and "a/b.mp4" are
different files that would have compared equal -- a false collision, and a
rename the caller never asked for.

Case folding was already Windows-only; make the separator handling match and
compare byte-exact elsewhere.

The unit test asserted the Windows rules unconditionally and so failed on the
macOS runners. Split it: Windows keeps the equivalence assertion, POSIX asserts
the two paths stay distinct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two windows where the claim was dropped early, both found in review.

A failed obs_output_start() does not mean the output is idle:
obs_output_can_begin_data_capture() refuses while active(output), so calling
start() twice lands in the failure path with the first muxer still writing.
Only release when the output is genuinely not active.

Both recording Destroy handlers deregistered the object before deleting it, and
only the destructor called DeleteOutput(), which can wait up to 20s for the
muxer to drain. For that whole window the file was still being written while its
claim was invisible to a concurrent Start. Stop first, then deregister;
DeleteOutput() is idempotent so the destructor's call becomes a no-op.

The on-disk check covers most of both windows -- the file exists by then -- but
not when overwrite is set, which suppresses exactly that check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FindBestFilename gave up when the path had no extension, returning it unchanged.
For the on-disk case that was merely the old behaviour, but for a live claim it
breaks the guarantee this whole change exists to provide: two outputs walked away
with the same path.

It is reachable. osn_generate_formatted_filename() appends the extension and then
truncates the result to 255 bytes, so a long enough name loses the extension
entirely -- and the new prefix/suffix API makes long names easier to produce.

Insert at the end of the name when there is no extension, and look for the
extension only in the final path component: strrchr over the whole path treated a
dot in a directory name as an extension, turning "C:/my.dir/rec" into
"C:/my (2).dir/rec".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit stopped the failure path from releasing a claim while the
muxer was still writing, but the resolve path could still move it. A duplicate
start() runs the whole Start() handler first: the timestamp has advanced, so
FindBestFilename resolves a different name and reassigned claimedFilePath to it.
obs_output_start() then refuses because the output is active, leaving the muxer
on its original file with nothing claiming it -- and an output with overwrite set
skips the on-disk check that would otherwise have covered it.

Only take the claim when the output is not already active. obs_output_active()
is null-safe, so an output that has not been created yet claims normally.

Rejecting the duplicate start outright would be the more thorough fix, but that
changes what the Start IPC call returns for a case Desktop can reach through
validateOrCreateOutputInstance, so it wants deciding on its own terms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@summeroff
summeroff force-pushed the fix/dual-output-filename-collision branch from 9795070 to feb6f17 Compare August 9, 2026 22:48
@summeroff
summeroff requested a balanced review from Copilot August 9, 2026 23:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 17 changed files in this pull request and generated no new comments.

Suppressed comments (2)

obs-studio-server/source/osn-file-output.cpp:42

  • This only lowercases individual UTF-8 bytes, so non-ASCII case variants are left unchanged even though the default Windows filesystem treats them as the same path (for example, directories differing only by Ä/ä). Two recordings can therefore still claim the same Windows file. Build the key with a Windows Unicode case-insensitive comparison/canonicalization routine rather than byte-wise tolower.
	std::string key = path;
	std::replace(key.begin(), key.end(), '\\', '/');
	std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { return (char)tolower(c); });
	return key;

obs-studio-server/source/osn-file-output.cpp:99

  • When the generated filename has already reached the formatter's 255-byte limit, inserting the counter makes the final path component exceed the filesystem limit. This is especially concrete for the extensionless truncation case described above: a 255-byte basename becomes 259 bytes after " (2)", so the second recording fails with ENAMETOOLONG instead of receiving a usable unique name. Reserve room for the counter by truncating the basename (without splitting a UTF-8 sequence) before inserting it, and cover the actual 255-byte case rather than the short extensionless string in the test.
		candidate = strPath;
		candidate.insert(insertAt, numStr);

@summeroff
summeroff merged commit 035b6a6 into staging Aug 9, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants