fix: stop dual output recording two canvases to one file - #1759
Conversation
There was a problem hiding this comment.
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.
| std::replace(key.begin(), key.end(), '\\', '/'); | ||
| #ifdef WIN32 | ||
| std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { return (char)tolower(c); }); | ||
| #endif |
There was a problem hiding this comment.
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.
| 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"); | ||
| } |
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| // libobs emits no "stop" here, so OnStopped() never runs for a failed start. | ||
| OnOutputStopped(); |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
14dadda to
8e392f2
Compare
There was a problem hiding this comment.
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 makescandidatebecome... (2), replacing the owner's original claim even thoughStartOutput()fails and the original muxer remains active. A subsequent output withoverwrite=truecan 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);
| 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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
fileFormatis empty. The formatter then produces.mp4, butdot == nameStartis 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); |
There was a problem hiding this comment.
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:
start()resolves A, claims A, muxer writes Astart()again — the whole Start() handler reruns, the timestamp has advanced, soFindBestFilenameresolves B and reassignsclaimedFilePath = Bobs_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.
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>
9795070 to
feb6f17
Compare
There was a problem hiding this comment.
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-wisetolower.
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 withENAMETOOLONGinstead 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);
Summary
Dual output starts a recording per canvas. Both derive their filename from the same global
Output/FilenameFormattingdefault —"%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 twoffmpeg_muxerinstances 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
EACCESand 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
FindBestFilenamewas purelyos_file_exists()-based.ffmpeg_muxercreates the file asynchronously after thestartsignal, so when the secondStart()resolves its path the first file does not exist yet and the name is handed out twice.The per-canvas
WIDTHxHEIGHT-NNsuffix 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/%VFtokens — 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;
FindBestFilenameskips names another live output holds, found viaIFileOutput::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 libobsstopsignal and from theobs_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.overwritenow 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::Recordingnow has the sameprefix/suffix, wrapped aroundfileFormatbefore the extension, exposed over IPC and onIRecording:Reserved path characters are stripped from prefix/suffix as the replay buffer does;
fileFormatpasses through untouched so no existing pattern changes meaning.3. Diagnosability. A
LOG_WARNINGwhen 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
obs-studio-server/tests/test-osn-file-output.cpp) — peer claim,noSpace_2form, theoverwritedecision, separator/case folding, release-and-reuse, andDecoratedFileFormatspacing/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 collision—waitForFile()removed from between the twostart()calls. That helper existed to force the first file onto disk so theos_file_existscheck 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— addedlastFile()inequality assertion, covering the default-filename case a real user hits.obs-studio-server-libandobs_studio_client.nodebuild clean; clang-format 18.1.3 clean on all 13 changed C++ files;yarn build:javascriptregeneration is stable.All three dual-output tests confirmed green on CI (182 passing overall).
Follow-ups
suffix = 'Vertical'on the vertical recording increateRecording, and uncomment the per-displayaddRecordingEntrylabelling. Until then dual output still collides — but now logs a warning instead of being silently wrong. Needs an osn bump containing this PR first.settings["path"], which survives theobs_data_applymerge), butConfigureRecFileSplittinghandsdirectory+formatto libobs, which names later chunks from its ownos_file_existscheck on the muxer thread with no path back into osn. The correct fix is upstream: resolve names by exclusively creating the file rather than byos_file_exists, which closes the same race for every consumer.waitForFileintests/osn-tests/util/general.tsis now unused; left in place as a reasonable general helper.