Bump to v1.3.0: harden updater helper for Dropbox-installed copies

v1.2's self-updater silently failed when the install folder lived
inside a Dropbox sync path. Dropbox held write locks on the existing
RemSound.exe / DLLs during the brief window between the parent exiting
and the helper script copying the new files in. The helper's robocopy
(/R:5 /W:1) gave up after 5 seconds, and the helper then
unconditionally relaunched the OLD binary — so the user saw the same
version they started with after pressing "Yes" on the install prompt,
with no visible error.

Helper script (BuildInstallScript) changes:

* Robocopy retries bumped to /R:60 /W:1 — up to 60 seconds per file.
  Dropbox lock release happens reliably within that window in
  practice.

* Robocopy exit code is captured and checked. Codes >= 8 are real
  failures. On a failure the helper writes update-failed.txt to the
  install folder with the cause + recovery steps, leaves the staging
  folder intact, and does NOT relaunch the old binary. Earlier
  versions silently relaunched the unmodified old binary, hiding the
  failure.

* Helper appends a step-by-step trace to _update-helper.log (in the
  install folder), with robocopy's own output included via /LOG+:.

* update-failed.txt, _update-helper.log, and _apply-update.cmd are
  added to the /XF exclusion list so the helper's own state files
  don't get copied to themselves on a repeat update run.

DownloadAndStageInstallAsync also clears any stale update-failed.txt
at the start of every new attempt, so a successful run leaves the
install folder clean.

readme.html "If install fails" section expanded with the new
update-failed.txt marker file behaviour and the _update-helper.log
location.

Wire format, audio pipeline, and recording feature unchanged from
v1.2 — this is updater-machinery-only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-05-15 13:07:36 +01:00
co-authored by Claude Opus 4.7
parent c59e413c1f
commit 9a5c46eecf
5 changed files with 111 additions and 33 deletions
+66 -10
View File
@@ -123,9 +123,12 @@ internal sealed class RemSoundUpdater : IDisposable
var zipPath = Path.Combine(Path.GetTempPath(), $"RemSound-update-{info.Tag}.zip");
var batchPath = Path.Combine(baseDir, "_apply-update.cmd");
// Tidy any leftover from a previous failed attempt before we start.
// Tidy any leftover from a previous failed attempt before we start. Also clear
// the failure marker — the new attempt starts clean and only re-creates the
// marker if THIS run fails.
TryDelete(zipPath);
TryDeleteDirectory(stagingDir);
TryDelete(Path.Combine(baseDir, "update-failed.txt"));
Log?.Invoke($"updater: downloading {info.DownloadUrl}");
await using (var src = await http.GetStreamAsync(info.DownloadUrl, token).ConfigureAwait(false))
@@ -168,26 +171,79 @@ internal sealed class RemSoundUpdater : IDisposable
/// <summary>One-shot installer batch. Waits for the supplied PID to exit (so file locks
/// release), robocopies the staged folder over the install folder, removes the staging
/// area, restarts RemSound.exe, and self-deletes. Robocopy's <c>/R:5 /W:1</c> flags give
/// the audio threads a few extra seconds to wind down if the exe is slow to release. The
/// helper is detached from RemSound at start time, so it survives the parent's exit.</summary>
private static string BuildInstallScript(string stagingRoot, string installDir) =>
$"""
/// area, restarts RemSound.exe, and self-deletes.
///
/// History: v1.0 of this helper used <c>/R:5 /W:1</c> on robocopy and unconditionally
/// restarted RemSound regardless of whether the copy actually succeeded. On
/// Dropbox-installed copies this failed silently — Dropbox held write locks on the
/// existing install files for ~1030 seconds after extraction kicked the sync off, robocopy
/// gave up after 5 seconds, and the helper relaunched the OLD binary. The user saw the same
/// version after "update".
///
/// v1.3 hardening (2026-05-15):
/// * Robocopy retries bumped to <c>/R:60 /W:1</c> — up to 60 seconds per file. Dropbox
/// locks reliably release inside that window.
/// * Robocopy exit code is captured and checked. Anything ≥ 8 is a true failure; the
/// helper writes <c>update-failed.txt</c> to the install dir with diagnostic detail,
/// does NOT relaunch the old binary, and leaves the staging folder intact so the
/// user (or a re-run of the updater) can recover. Codes 07 are robocopy's
/// "success-ish" range (0 = nothing changed, 1 = copied, 2 = extras, 3 = both, etc).
/// * Helper writes a step-by-step log to <c>_update-helper.log</c> in the install dir
/// for post-mortem when the copy goes wrong. Robocopy's own output is appended via
/// <c>/LOG+:</c>.
///
/// The helper is detached from RemSound at start time, so it survives the parent's exit.</summary>
private static string BuildInstallScript(string stagingRoot, string installDir)
{
var helperLog = Path.Combine(installDir, "_update-helper.log");
var failureMarker = Path.Combine(installDir, "update-failed.txt");
var stagingDir = Path.Combine(installDir, "_update");
var remsoundExe = Path.Combine(installDir, "RemSound.exe");
return $"""
@echo off
setlocal
rem RemSound auto-installer helper. Generated by RemSoundUpdater. Self-deleting.
rem RemSound auto-installer helper. Generated by RemSoundUpdater. Self-deleting on success.
set "PID=%~1"
set "LOG={helperLog}"
set "MARKER={failureMarker}"
echo. >> "%LOG%"
echo === %DATE% %TIME% update helper started, parent PID=%PID% === >> "%LOG%"
:wait_loop
tasklist /FI "PID eq %PID%" 2>nul | find "%PID%" >nul
if not errorlevel 1 (
timeout /t 1 /nobreak >nul
goto wait_loop
)
robocopy "{stagingRoot}" "{installDir}" /E /IS /IT /NFL /NDL /NJH /NJS /R:5 /W:1 /XF _apply-update.cmd >nul
rmdir /S /Q "{Path.Combine(installDir, "_update")}" 2>nul
start "" "{Path.Combine(installDir, "RemSound.exe")}"
echo %DATE% %TIME% parent exited, starting robocopy (R:60 W:1) >> "%LOG%"
robocopy "{stagingRoot}" "{installDir}" /E /IS /IT /NFL /NDL /NJH /NJS /R:60 /W:1 /XF _apply-update.cmd /XF _update-helper.log /XF update-failed.txt /LOG+:"%LOG%"
set "ROBO_EXIT=%ERRORLEVEL%"
echo %DATE% %TIME% robocopy exit=%ROBO_EXIT% >> "%LOG%"
if %ROBO_EXIT% GEQ 8 (
echo Update copy FAILED. > "%MARKER%"
echo Robocopy exit code = %ROBO_EXIT% ^(anything ^>= 8 is a real failure^). >> "%MARKER%"
echo Staged files are intact at: {stagingDir} >> "%MARKER%"
echo Helper log: %LOG% >> "%MARKER%"
echo. >> "%MARKER%"
echo Most common cause: Dropbox or another file-sync app was holding write locks >> "%MARKER%"
echo on the existing RemSound binaries during the update window. Close RemSound, >> "%MARKER%"
echo wait 30 seconds for the sync to settle, then either: >> "%MARKER%"
echo * Re-launch RemSound and try Help -^> Check for updates again, OR >> "%MARKER%"
echo * Manually copy everything from the staged folder above into this folder. >> "%MARKER%"
echo %DATE% %TIME% FAILURE: leaving staging intact, NOT restarting RemSound >> "%LOG%"
del "%~f0"
exit /b %ROBO_EXIT%
)
rmdir /S /Q "{stagingDir}" 2>nul
echo %DATE% %TIME% staging removed, restarting RemSound >> "%LOG%"
start "" "{remsoundExe}"
del "%~f0"
""";
}
/// <summary>If the zip extracted to a single subfolder (typical when GitHub zips a tag),
/// return that subfolder so the copy works from the inner level. Otherwise return the