Skip to content

XS✔ ◾ fix: recover from stuck Stop button when a shave's audio never opened#956

Merged
tomek-i merged 8 commits into
mainfrom
950-fix-stuck-stop-recording
Jul 15, 2026
Merged

XS✔ ◾ fix: recover from stuck Stop button when a shave's audio never opened#956
tomek-i merged 8 commits into
mainfrom
950-fix-stuck-stop-recording

Conversation

@tomek-i

@tomek-i tomek-i commented Jul 13, 2026

Copy link
Copy Markdown
Member

Summary

If a shave's microphone/audio setup never fully completed, useScreenRecording's stop() would silently return null without ever resetting isRecording/isProcessing or telling the user anything went wrong. The Stop button would then remain visible/enabled but every click became a permanent no-op — the exact "stuck shave" reported in the issue.

Closes #950

What changed

File Change
src/ui/src/hooks/useScreenRecording.ts stop() no longer silently no-ops when the MediaRecorder is missing/inactive: it now resets recording state to idle, clears stale refs, and surfaces an error toast. The recorder's real stop path was also hardened with a recorder.onerror handler and a shared try/finally so any unexpected failure during stop still guarantees the UI returns to idle instead of hanging mid-promise.
src/ui/src/hooks/useScreenRecording.test.ts New regression tests covering the "audio/recorder never opened" stop path (was previously untested — the hook had zero direct unit tests).

Decisions

  • Decision: Extract a resetToIdle() helper (hide control bar, run cleanup(), flip isRecording/isProcessing false) and call it both from the "nothing to stop" guard and implicitly via the same finally block on the real stop path.
    • Why: Guarantees the app can never end a stop() call still believing it's recording, regardless of which branch was taken — closing off the whole class of bug rather than just the one reported branch.
    • Alternatives considered: Only special-casing the exact reported branch (!mediaRecorderRef.current). Rejected because the sibling recorder.state === "inactive" guard had the identical silent-no-op bug and would have been left stuck too.
  • Decision: Show toast.error("Nothing to stop — this recording never started properly. You can try again.") when Stop is clicked with no usable recorder.
    • Why: Matches the repo's existing error convention (sonner toast.error, same phrasing style as the neighboring Failed to start recording: ... / Failed to save recording: ... calls) and gives the user both a clear explanation and the recovery action required by AC2 — the button becomes "Start Recording" again immediately after, so they can just try again.

Acceptance criteria

  • A shave started without opening/including audio can still be stopped via the Stop button — stop() now always resolves and returns the UI to idle, even when no MediaRecorder was ever created.
  • If stopping fails, the UI shows a clear error message and offers a recovery action — a toast.error is shown and the Stop button reverts to "Start Recording", so the user can immediately retry.
  • The app does not remain stuck in an active/recording state after clicking Stop — isRecording/isProcessing are unconditionally reset in every code path (guard clause and the real stop path's finally).

Testing

  • Unit tests added/updated (src/ui/src/hooks/useScreenRecording.test.ts, 3 new tests)
  • Integration tests added/updated
  • Manual testing performed
  • Build passes (npm run build)
  • Tests pass (npm rebuild better-sqlite3 --build-from-source && npx vitest run --exclude 'src/ui/**' — 658 passed; npm --prefix src/ui test — 198 passed)
  • Lint / format clean (npm run lint — biome check clean)

Bug repro evidence

  • Symptom (pinned): Clicking Stop when a shave's MediaRecorder was never created/started does nothing — no state change, no user feedback, isRecording stays true.
  • Repro method: A regression test (useScreenRecording.test.ts) that calls stop() on a freshly-mounted hook where start() was never invoked — i.e. mediaRecorderRef.current is null, the exact state you land in when audio was never opened for a shave — and asserts an error toast is shown.
  • Before (unpatched): Ran the new test against the pre-fix stop(): it resolved (so it didn't hang), but the "surfaces a clear, actionable error toast" assertion failedtoast.error was called 0 times, confirming the silent-no-op / no-recovery-action defect called out in AC2.
  • After (patched): All 3 new tests pass — stop() resolves null, isRecording/isProcessing are false, and toast.error is called with an actionable "Nothing to stop... You can try again." message. A second consecutive Stop click also resolves cleanly (no re-stuck state).

Follow-up items

None — the fix is self-contained to the stop() guard clauses and the surrounding error handling in the same hook.


🤖 Generated with Claude Code

…#950)

stop() in useScreenRecording silently no-op'd when mediaRecorderRef was
null/inactive (e.g. audio/mic setup for a shave never completed), leaving
isRecording stuck true with no error shown. It now resets to idle state and
surfaces a toast whenever there's nothing to stop, and any unexpected error
during the real stop path now also guarantees the same reset via a shared
try/finally instead of silently resolving null.

Closes #950
Copilot AI review requested due to automatic review settings July 13, 2026 01:12
@tomek-i tomek-i added the armada Eligible for the ARMADA fleet to pick up label Jul 13, 2026
@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

PR Metrics

Thanks for keeping your pull request small.
Thanks for adding tests.

Lines
Product Code 171
Test Code 426
Subtotal 597
Ignored Code -
Total 597

Metrics computed by PR Metrics. Add it to your Azure DevOps and GitHub PRs!

@github-actions github-actions Bot changed the title fix: recover from stuck Stop button when a shave's audio never opened XS✔ ◾ fix: recover from stuck Stop button when a shave's audio never opened Jul 13, 2026

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

Fixes the “stuck Stop button” screen-recording/shave failure mode (#950) by ensuring useScreenRecording.stop() always returns the UI to an idle state and provides user feedback when there’s nothing valid to stop.

Changes:

  • Added a resetToIdle() helper and used it to recover when no MediaRecorder exists / is inactive, including an actionable error toast.
  • Hardened the normal stop flow with explicit error handling (recorder.onerror) and centralized cleanup/state reset.
  • Added new unit tests covering the “stop with no recorder” regression scenarios.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/ui/src/hooks/useScreenRecording.ts Adds stuck-state recovery in stop() and improves stop-path error handling/cleanup.
src/ui/src/hooks/useScreenRecording.test.ts Adds regression unit tests for stopping when audio/recorder never started.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +363 to +367
} finally {
cleanup();
setIsRecording(false);
setIsProcessing(false);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, fixed in f9018c5. finally now awaits both hideControlBar() and cleanup() (previously cleanup() was fire-and-forget), and hideControlBar() is called unconditionally in finally instead of only inside the onstop happy path — so an onerror/throw path (or a synchronous throw from recorder.stop() itself) can no longer leave the control bar stuck visible while isRecording/isProcessing have already reset. Removed the now-redundant hideControlBar() call that used to live inside onstop.

Comment on lines +354 to +356
recorder.onerror = (event) => {
reject(new Error(`MediaRecorder error: ${String((event as ErrorEvent).error ?? event)}`));
};

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed — same issue tomek-i flagged separately below; fixed together in f9018c5 with a local MediaRecorderErrorEventLike interface (extends Event, adds error?: DOMException) instead of casting to the unrelated DOM ErrorEvent type.

@tomek-i tomek-i added armada:reviewing Claimed by crows-nest; review->merge pipeline running and removed armada Eligible for the ARMADA fleet to pick up labels Jul 13, 2026
@tomek-i

tomek-i commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

🔭 crows-nest: picked up by ARMADA — dispatching to the review→merge pipeline.

try {
await window.electronAPI.screenRecording.hideControlBar().catch(() => {});
try {
return await new Promise((resolve, reject) => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[major] Concurrent stop() calls clobber each other's onstop/onerror handlers, hanging the first caller forever

stop() has no re-entrancy guard beyond the disabled state of the main window's Stop button. The recording control bar is a separate Electron BrowserWindow whose own Stop button fires the stop-recording-request IPC event independently of the main renderer's isProcessing state. If the control-bar Stop and the main-window Stop fire close together, both calls read the same mediaRecorderRef.current and each assigns recorder.onstop/onerror on the same MediaRecorder instance — the second call's assignment silently overwrites the first's handlers, so the first call's Promise never resolves or rejects and its finally never runs, leaving that invocation permanently dangling. This is the same class of stuck-forever bug this PR sets out to fix, just relocated. Suggested fix: guard stop() with an in-flight ref (e.g. isStoppingRef) so a second concurrent invocation is a no-op/early return instead of re-registering handlers on the live recorder.

_flagged by:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, fixed in f9018c5. Verified independently first: there's actually only one useScreenRecording() instance (in the main window — ScreenRecorder.tsx); the control bar doesn't run its own hook. But the bug is real for a related reason — the control bar's Stop button forwards over IPC (stopFromControlBarstop-recording-request) to that same single stop() closure that the in-window Stop button also calls, and nothing prevented a second concurrent call before the first's onstop/onerror fired. Added an `isStoppingRef" in-flight guard so a second concurrent call is now a no-op/early-return instead of re-registering handlers on the live recorder.

// MediaRecorder was ever created because audio/mic setup for this shave
// never completed, so there is nothing to call .stop() on, but the UI must
// not be left believing a recording is still active.
const resetToIdle = useCallback(async () => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[minor] resetToIdle() never stops the backend recording timer if recorder goes inactive mid-session

resetToIdle() calls hideControlBar() and the local cleanup() (stops local MediaStream tracks / closes the AudioContext) but never calls window.electronAPI.screenRecording.stop(...). If a MediaRecorder was created/started (so the backend timer is running) but then transitions to state === 'inactive' on its own (e.g. underlying track/stream ends unexpectedly) before the user clicks Stop, stop() takes the recovery branch and calls resetToIdle(), which never tells the backend to stop its timer — it keeps firing recording-time-update events until the next recording start or app quit. Consider having resetToIdle() also invoke the backend stop/cleanup IPC so backend-side timer/session state is torn down symmetrically.

_flagged by:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, fixed in f9018c5. resetToIdle() now also calls window.electronAPI.screenRecording.stop(new Uint8Array()) — handleStopRecording() stops the backend timer as its first action and safely no-ops before writing anything when videoData is empty, so this reuses existing plumbing without a new IPC channel.

toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
}));

describe("useScreenRecording – stop() when audio/recorder was never opened (#950)", () => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[minor] New tests don't cover the 'recorder exists but inactive' branch or the rewritten onstop/onerror path

The stop() guard is if (!recorder || recorder.state === 'inactive'), but all three new tests only exercise the !recorder (never-created) half — none construct a MediaRecorder-like ref with state === 'inactive' to hit the other half, which is the literal sibling guard the PR description calls out as related. The rewritten in-progress-recording path (the new onerror handler alongside onstop, and the try/catch/reject restructuring) has zero test coverage. The 'second Stop click' test also awaits sequentially rather than firing overlapping calls, so it provides no coverage of the concurrent-invocation race noted separately.

_flagged by:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, fixed in f9018c5. Added coverage for the recorder-exists-but-inactive branch (via a fake MediaRecorder driven through start()), the rewritten onstop/onerror path (both the happy-path resolve and the onerror-rejects-without-hanging case), and a genuine concurrent-invocation test (two overlapping stop() calls where the second is a no-op and doesn't clobber the first's handlers).

filePath: result.filePath,
fileName: result.fileName || result.filePath,
});
} catch (error) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[nit] onerror event cast uses the wrong DOM type name

recorder.onerror casts the event to ErrorEvent, but MediaRecorder's error event is actually a MediaRecorderErrorEvent (with an .error: DOMException property), not a generic ErrorEvent. Works at runtime since .error exists on the real event, but the annotation is misleading. Consider a local { error?: DOMException } shape instead of ErrorEvent.

_flagged by:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, fixed in f9018c5. Added a local MediaRecorderErrorEventLike interface (extends Event, adds error?: DOMException) since lib.dom doesn't ship a MediaRecorderErrorEvent type, and cast to that instead of the unrelated ErrorEvent.

const resetToIdle = useCallback(async () => {
await window.electronAPI.screenRecording.hideControlBar().catch(() => {});
await cleanup();
setIsRecording(false);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[nit] stop() now calls recorder.stop() unconditionally, dropping the old recording-state guard

The previous code only called recorder.stop() when recorder.state === 'recording'; the new code calls it whenever the recorder is neither undefined nor 'inactive', which includes 'paused'. No pause functionality exists today so this is currently a no-op risk, but worth a comment or restoring the explicit state check so a future pause feature doesn't hit an unguarded .stop() call.

_flagged by:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, fixed in f9018c5. Restored the explicit if (recorder.state === "recording") guard before calling recorder.stop(). Since onstop/onerror won't fire without an actual stop() call, the else branch now rejects explicitly (Cannot stop recorder in unexpected state) instead of leaving the promise dangling — a plain restore of the old guard would have hung on 'paused' once a pause feature lands, so I closed that gap too.

@tomek-i

tomek-i commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

🔭 muster review — PR #956

Two independent lenses reviewed this diff in parallel (code-review + codex-rescue), neither saw the other's notes.

Bottom line: 0 blocking, 1 major, 2 minor, 1 nit — not a hard blocker, but the major finding is a real relocation of the same bug class this PR fixes.

Severity Count
blocking 0
major 1
minor 2
nit 2

Major

  • Concurrent stop() calls clobber each other's onstop/onerror handlers, hanging the first caller forever (useScreenRecording.ts:329, codex-rescue) — the control-bar window's Stop button and the main window's Stop button can both invoke stop() on the same live MediaRecorder; the second call's handler assignment overwrites the first's, so the first caller's promise never settles. Same "stuck shave" bug class this PR sets out to fix, just relocated to the concurrent-call case.

Minor

  • resetToIdle() never stops the backend recording timer if the recorder goes inactive mid-session on its own (useScreenRecording.ts:302, codex-rescue) — a timer/resource leak, not user-visible but real.
  • New tests don't cover the recorder.state === 'inactive' branch or the rewritten onstop/onerror path — only the !recorder half is exercised (useScreenRecording.test.ts:15, codex-rescue).

Nits

  • onerror event cast to ErrorEvent should be MediaRecorderErrorEvent-shaped (useScreenRecording.ts:349, code-review).
  • stop() now calls recorder.stop() unconditionally instead of only when state === 'recording', silently including 'paused' (currently unreachable, latent) (useScreenRecording.ts:305, code-review).

Lenses agreed there's nothing blocking; codex-rescue's root-cause pass surfaced the concurrency edge case the conventions lens didn't probe for. Handing to shipwright's address-review mode to triage.

@github-actions

Copy link
Copy Markdown
Contributor

🚀 Pre-release build is available for this PR:
https://github.com/SSWConsulting/SSW.YakShaver.Desktop/releases/tag/0.6.0-beta.956.1783905138

…ery, fix onerror path (#956)

- Guard stop() with an isStoppingRef so a second concurrent invocation
  (control-bar Stop button vs. in-window Stop button both routing to the
  same handler) is a no-op instead of clobbering the first call's
  onstop/onerror handlers and hanging it forever.
- resetToIdle() now also calls the backend stop/cleanup IPC so a
  never-started or self-inactivated recording doesn't leave the backend
  timer running until the next recording or app quit.
- Restore the explicit state === 'recording' guard before calling
  recorder.stop(), rejecting instead of leaving the promise dangling for
  any other reachable state.
- Model the MediaRecorder error event's actual shape (a DOMException on
  .error) instead of casting to the unrelated DOM ErrorEvent type.
- Always hide the control bar in stop()'s finally block (not just on the
  onstop happy path) so an onerror/throw path can't leave it stuck visible.
- Add test coverage for the recorder-exists-but-inactive branch, the
  onstop/onerror in-progress path, and the concurrent-stop no-op guard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@tomek-i

tomek-i commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

shipwright — address-review summary

All 7 review threads (5 from muster/tomek-i's consolidated review + 2 from Copilot's separate automated pass) triaged and addressed. New head: f9018c5fa4ae06d0e01e8eaca7d838a166c52dce.

Agreed and fixed (7/7):

  1. [major] Concurrent stop() calls clobber handlers (tomek-i, line 329) — verified the premise independently first: there's actually only one useScreenRecording() instance (main window), not two racing hook instances. But the underlying bug is real for a related reason — the control bar's Stop button forwards over IPC to that same single stop() closure the in-window button also calls, with no re-entrancy guard. Added an isStoppingRef in-flight guard so a second concurrent call is a no-op.
  2. [minor] resetToIdle() doesn't stop the backend timer (tomek-i, line 302) — now calls window.electronAPI.screenRecording.stop(new Uint8Array()), reusing existing IPC plumbing (handleStopRecording stops the timer first and safely no-ops on empty data).
  3. [minor] Missing test coverage (tomek-i, test.ts:15) — added tests for the recorder-exists-but-inactive branch, the onstop/onerror in-progress path, and a genuine concurrent-stop race.
  4. [nit] onerror cast to ErrorEvent (tomek-i line 349 + Copilot line 356, same issue) — added a local MediaRecorderErrorEventLike type instead.
  5. [nit] recorder.stop() called unconditionally (tomek-i, line 305) — restored the explicit state === "recording" guard, and made the non-recording branch reject explicitly instead of risking a dangling promise on a future paused state.
  6. [Copilot] finally doesn't await cleanup() / doesn't hide control bar on the error path (line 367) — finally now awaits hideControlBar() + cleanup() unconditionally, so an onerror/throw path can't leave the control bar visibly stuck.

Declined: none.

Validation on the new head:

  • npm run build — clean
  • Backend/root vitest run --exclude 'src/ui/**' — 658 passed (62 files)
  • npm --prefix src/ui test — 202 passed (26 files), including 7 in useScreenRecording.test.ts
  • npm run lint (biome) on src/ — clean, 0 warnings

Note: the repo-root biome check . invocation is currently blocked in this sandbox by an unrelated stray/locked git worktree nested under .claude/worktrees/ (pre-existing environmental artifact, not part of this PR's diff); scoping the check to src/ (379 files) confirms the actual change is lint-clean.

@github-actions

Copy link
Copy Markdown
Contributor

🚀 Pre-release build is available for this PR:
https://github.com/SSWConsulting/SSW.YakShaver.Desktop/releases/tag/0.6.0-beta.956.1783906586

@tomek-i tomek-i removed the armada:reviewing Claimed by crows-nest; review->merge pipeline running label Jul 13, 2026
@tomek-i

tomek-i commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

✅ reviewed, addressed, green — awaiting human merge (auto-merge off).

Round 1 review: 0 blocking, 1 major, 2 minor, 2 nit — posted inline + summary.
Address pass (shipwright): all 7 threads triaged and agreed (including 2 extra findings from a separate Copilot pass), fixed, pushed as f9018c5f. Build/test/lint green.
Round 2 re-review: fixes verified sound overall, but surfaced one new non-blocking major worth a human look before merging:

  • useScreenRecording.ts — the new isStoppingRef guard is set inside the finally block after await cleanup(). If cleanup() throws (e.g. a MediaStreamTrack.stop()/AudioNode.disconnect() edge case), isStoppingRef.current = false never runs and every subsequent stop() call permanently short-circuits on the re-entrancy guard — a rare but real relocation of the original "stuck Stop button" bug, one layer deeper. Also flagged: the !recorder/inactive early-return path calls resetToIdle() without the same guard, so a throw there isn't covered by isStoppingRef/finally protection at all.

This didn't reach blocking severity (needs cleanup() to actually throw, which none of today's code paths do) and we've reached the maxReviewRounds (2) cap for this pipeline pass, so it's surfaced here rather than looped again automatically. Worth a try/catch around cleanup() in both call sites before/at merge if a maintainer wants to close the gap, but not required to land this fix.

Gate: 0 blocking · review not degraded · CI green (all checks SUCCESS/SKIPPED) · not draft · mergeable · no branch protections configured · autoMerge: false → stops here for a human to merge.

@tomek-i

tomek-i commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

🔭 crows-nest: address-review complete (head f9018c5, all 5 muster points + 2 Copilot points fixed and re-validated — build clean, 202/202 UI tests pass). ✅ reviewed, addressed, green — awaiting human merge (auto-merge off).

@tomek-i tomek-i added armada Eligible for the ARMADA fleet to pick up armada:reviewing Claimed by crows-nest; review->merge pipeline running labels Jul 13, 2026
@tomek-i

tomek-i commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

🔭 crows-nest: ready-PR pipeline started — review → address → re-validate → gated merge.

@tomek-i tomek-i added the armada:record Request an on-demand logbook walkthrough for this PR; crows-nest records it and removes the label label Jul 13, 2026
// this shave) or it's already inactive — there's nothing to stop, but we
// must still bring the app back to an idle state instead of silently
// no-oping and leaving the Stop button stuck on a dead recording.
if (!recorder || recorder.state === "inactive") {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[major] isStoppingRef re-entrancy guard is not applied to the null/inactive recovery branch

Both lenses independently flagged this: isStoppingRef.current is only set true inside the live recorder stop path — the new !recorder || recorder.state === "inactive" guard clause (the exact branch this PR is fixing) never touches it. Two Stop clicks landing close together while the recorder is null/inactive (the #950 stuck-shave scenario itself, and the dual-trigger race the guard's own comment describes) both pass the check and both await resetToIdle() concurrently — duplicate IPC calls (hideControlBar, screenRecording.stop), duplicate cleanup(), and duplicate error toasts.

Suggested fix: set/reset isStoppingRef.current around the guard-clause branch too (e.g. wrap both branches in the same try/finally that manages the flag), not just the live-recorder path.

flagged by: code-review + codex-rescue

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in f397632 — the recovery branch now sets isStoppingRef.current = true before resetToIdle() and clears it in a finally, mirroring the live-recorder path's guard.

try {
await window.electronAPI.screenRecording.hideControlBar().catch(() => {});
try {
return await new Promise((resolve, reject) => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[major] New re-entrancy guard can itself wedge forever if onstop/onerror never fire

isStoppingRef.current is set true before awaiting the stop promise and only cleared in the finally block once that promise settles — which requires recorder.stop() to reliably fire either onstop or the new onerror handler. If neither ever fires (device revoked mid-stop, stream/track already dead, or a stop-event quirk), the promise never resolves/rejects, finally never runs, and isStoppingRef.current stays true permanently — reproducing the exact 'Stop becomes a permanent no-op' symptom this PR fixes, just relocated and now silent (stop() early-returns null, no toast). No test exercises 'neither event ever fires.'

Suggested fix: bound the wait with a timeout (e.g. Promise.race against a short timeout that force-resets state) so a missing event can't wedge the guard indefinitely.

flagged by: codex-rescue

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in a5864d5 — added a STOP_EVENT_TIMEOUT_MS (15s) that rejects the stop Promise if neither onstop nor onerror fires, with clearTimeout on every other settlement path so it can't fire spuriously.

@tomek-i tomek-i removed the armada:record Request an on-demand logbook walkthrough for this PR; crows-nest records it and removes the label label Jul 13, 2026
@tomek-i

tomek-i commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

🎬 crows-nest: on-demand walkthrough failed — no .armada/logbook/staging.json recipe exists yet for this repo, and establishing one is an interactive step (auth/seed/reach steps need a human's input, not a guess). Run /logbook --setup once with a human to derive the repo's staging recipe, then re-request by removing armada:record-failed and re-adding armada:record.

@github-actions

Copy link
Copy Markdown
Contributor

🚀 Pre-release build is available for this PR:
https://github.com/SSWConsulting/SSW.YakShaver.Desktop/releases/tag/0.6.0-beta.956.1783908083

@tomek-i tomek-i removed the armada:reviewing Claimed by crows-nest; review->merge pipeline running label Jul 13, 2026
@tomek-i

tomek-i commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

✅ crows-nest / muster pipeline complete — reviewed, addressed, re-validated green — awaiting human merge (auto-merge off).

  • Review round 1: 0 blocking, 3 major, 1 minor, 1 nit (dual-lens: code-review + codex-rescue, no degrade).
  • Address pass: all 3 major findings fixed (re-entrancy guard extended to the recovery branch, bounded timeout added so the stop promise can never hang forever, cleanup() made fully defensive) + 1 regression test added. 1 nit declined (pre-existing local convention, out of scope). New head: 0d6bc9f.
  • Review round 2 (re-review of the address diff): 0 new findings — converged.
  • CI: all checks green on 0d6bc9f. Mergeable: yes.

Gate: ready_awaiting_human — every gate is green, but autoMerge is off in .armada/config.json, so this stops here for a human to merge.

@tomek-i tomek-i added the armada:reviewing Claimed by crows-nest; review->merge pipeline running label Jul 13, 2026
@tomek-i

tomek-i commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

🔭 crows-nest: ready-PR pipeline started — review → address → re-validate → gated merge.

// Bounds this Promise so it can never hang forever if onstop/onerror
// never fire (e.g. the recording device was revoked, or the browser
// drops the event) — cleared on every settlement path below.
const timeoutId: ReturnType<typeof setTimeout> = setTimeout(() => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[major] Timed-out stop() never detaches recorder.onstop/onerror, so a late event still fires stale side effects

When STOP_EVENT_TIMEOUT_MS elapses, the promise rejects and the outer finally runs cleanup(), which nulls mediaRecorderRef.current and resets chunksRef.current. But the local recorder variable inside the Promise executor still holds a live reference, and recorder.onstop/recorder.onerror are never cleared on any settlement path. If the browser/device fires the event late — after the timeout has already rejected and the UI has shown a failure toast and reset to idle — the stale onstop handler still runs: it builds a Blob from the now-cleared chunksRef.current (empty), calls the stop IPC a second time, and calls toast.success(...), directly contradicting the failure toast already shown. Suggest nulling recorder.onstop/recorder.onerror in the timeout callback (and other settlement branches) so a late-firing event is a no-op.

flagged by: codex-rescue

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 52974a3 — recorder.onstop/onerror are now nulled out on every settlement path (timeout, onstop success/error, onerror, and the unexpected-state branch), so a late-firing event after settlement is a no-op. Added regression coverage for this exact scenario in f2ad0ce.

// this shave) or it's already inactive — there's nothing to stop, but we
// must still bring the app back to an idle state instead of silently
// no-oping and leaving the Stop button stuck on a dead recording.
if (!recorder || recorder.state === "inactive") {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[nit] isProcessing not set true during recovery-branch resetToIdle() await

In the live-recorder stop path, setIsProcessing(true) is called before the async work begins, so the record button is disabled while stopping is in flight. The !recorder || recorder.state === "inactive" recovery branch never calls setIsProcessing(true) before awaiting resetToIdle() (two IPC round-trips + cleanup()) — only the internal isStoppingRef guard (invisible to the UI) prevents a second click from re-entering. Not a functional bug, just an inconsistency between the two branches' UI feedback during their async windows.

flagged by: code-review

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 06e25d3 — the recovery branch now calls setIsProcessing(true) before awaiting resetToIdle(), matching the live-recorder path so the button reflects in-flight state on both branches.

@tomek-i

tomek-i commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

[minor] Missing regression test for the STOP_EVENT_TIMEOUT_MS timeout path (src/ui/src/hooks/useScreenRecording.test.ts)

The PR's headline safety mechanism — the 15s timeout wrapped around the onstop/onerror Promise so stop() 'can never hang forever' — has no test that actually triggers it. Every other stop() branch (never-created, inactive, onstop happy path, onerror, concurrent call) has a dedicated test, but no test uses vi.useFakeTimers()/vi.advanceTimersByTime() to advance past STOP_EVENT_TIMEOUT_MS and assert the promise settles, state resets, and a toast fires — arguably the most safety-critical addition since it's what prevents the exact 'stuck forever' failure mode this PR fixes.

flagged by: code-review + codex-rescue (independently)

@tomek-i

tomek-i commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

🔭 muster review — PR #956

Lenses: code-review + codex-rescue (both ran — no degrade)

Summary: 0 blocking, 1 major, 1 minor, 1 nit — no blocking findings, but the major finding is worth addressing before merge (a stale-listener bug in the new timeout path that can fire a false success toast after the failure toast already shown).

Severity Finding File
major Timed-out stop() never detaches recorder.onstop/onerror, so a late event still fires stale side effects useScreenRecording.ts:416
minor Missing regression test for the STOP_EVENT_TIMEOUT_MS timeout path (flagged independently by both lenses) useScreenRecording.test.ts
nit isProcessing not set true during recovery-branch resetToIdle() await useScreenRecording.ts:397

Bottom line: no blocking findings — review advisory, but the major finding should be fixed (dangling event handlers on timeout) before this ships, since it can produce a contradictory 'success' toast after the UI has already shown failure.


🔭 crows-nest / muster — automated review, not a merge decision.

armada-lookout added 3 commits July 13, 2026 03:07
…th (#956 thread 3567819413)

Timed-out stop() left recorder.onstop/onerror attached to the live
MediaRecorder, so a late-firing event after the timeout rejected could
still build a Blob from chunks cleanup() already cleared, call the stop
IPC a second time, and show a contradictory success/error toast. Null
both handlers out on every settlement path (timeout, onstop
success/error, onerror, and the unexpected-state branch) so a late
event is a no-op.
…path (#956 thread 3567819890)

The live-recorder stop() path sets isProcessing(true) before its async
work so the button reflects in-flight state; the !recorder ||
state === "inactive" recovery branch never did, leaving the UI looking
idle while resetToIdle() is still tearing down. Not a functional bug
(isStoppingRef still guards re-entry) — just UI-feedback consistency
between the two branches.
…eout path (#956 top-level comment)

Adds fake-timer coverage for the 15s stop() timeout: one test drives
vi.advanceTimersByTimeAsync(15000) to confirm the promise settles to
null and isRecording/isProcessing reset when neither onstop nor onerror
ever fires, and a second confirms a late-firing onstop after the
timeout has already settled is a safe no-op (no duplicate stop IPC
call, no contradictory success toast) now that recorder.onstop/onerror
are detached on the timeout path.
@tomek-i

tomek-i commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

Addressed: missing regression test for the STOP_EVENT_TIMEOUT_MS timeout path

Agreed — added in f2ad0ce (src/ui/src/hooks/useScreenRecording.test.ts), a new describe block with two tests using vi.useFakeTimers() / vi.advanceTimersByTimeAsync(15000):

  1. settles to null and resets state after 15s when neither onstop nor onerror ever fires — confirms the timeout guard actually rejects the promise and isRecording/isProcessing reset.
  2. ignores a late onstop firing after the timeout has already settled the promise — confirms a late onstop after timeout is a no-op (no duplicate stop IPC call, no contradictory success toast). This depends on the detachHandlers() fix from the major finding (52974a3) — verified it fails on pre-fix code before adding it to the suite.

Both pass; full UI suite is green at 205/205.

@tomek-i

tomek-i commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

shipwright — address-review summary

All 3 findings from the latest muster review triaged and implemented (3/3 agreed, 0 discussed, 0 declined). None were blocking.

  1. [major] Timed-out stop() never detached recorder.onstop/onerror (thread 3567819413) — agreed, fixed in 52974a3: both handlers are now nulled out on every settlement path (timeout, onstop success/error, onerror, unexpected-state) so a late-firing event after settlement is a no-op.
  2. [minor] Missing regression test for the STOP_EVENT_TIMEOUT_MS timeout path (top-level comment) — agreed, fixed in f2ad0ce: added fake-timer coverage that drives the 15s timeout and confirms both the settlement and the late-event no-op behavior.
  3. [nit] isProcessing not set during the recovery-branch resetToIdle() await (thread 3567819890) — agreed, fixed in 06e25d3: the recovery branch now sets isProcessing(true) before awaiting, matching the live-recorder path.

Validation: npm run build (root deps + UI) clean, full UI suite 205/205 passing (npx vitest run in src/ui), npm run lint (biome) clean, no stray diff after lint/format.

New head sha: f2ad0ce141b9c70b6f319919633d949c17536ce6

Threads left unresolved for human review.

@github-actions

Copy link
Copy Markdown
Contributor

🚀 Pre-release build is available for this PR:
https://github.com/SSWConsulting/SSW.YakShaver.Desktop/releases/tag/0.6.0-beta.956.1783912317

@tomek-i tomek-i added armada:blocked ARMADA could not finish; needs a human and removed armada:reviewing Claimed by crows-nest; review->merge pipeline running labels Jul 13, 2026
@tomek-i

tomek-i commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

🔭 crows-nest: pipeline complete — review (0 blocking) → address (3/3 findings fixed, pushed as f2ad0ce) → re-validate (local build/test/lint green, CI all green) → gate: blocked.

Reason: branch protections / required reviews not satisfied — this repo's ruleset reports reviewDecision: REVIEW_REQUIRED (an approving human GitHub review, distinct from the muster bot review above). Nothing else is outstanding: 0 blocking findings, CI green, mergeable, not draft.

Action needed: a human reviewer approve this PR on GitHub, then merge (autoMerge is off in .armada/config.json, so ARMADA won't merge even once approved — merging is a manual step).

@tomek-i

tomek-i commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

🎬 Walkthrough video: https://github.com/SSWConsulting/SSW.YakShaver.Desktop/releases/download/logbook-pr-956/walkthrough-pr-956.mp4

Chapters (1:35, narrated):

  1. Recording a shave — the everyday flow
  2. The floating recorder bar — where the stuck Stop lived
  3. Stopping now always answers — the fix, demonstrated live
  4. Ready to try again ins
walkthrough-pr-956.mp4

@tomek-i tomek-i self-assigned this Jul 13, 2026
calumjs pushed a commit that referenced this pull request Jul 14, 2026
Consolidating toward 2-4 R&D Core Activities per year: the cross-Space
overlay bug and the already-fixed stuck-Stop-button bug (#956) are two
facets of the same recording-lifecycle-reliability question, so #921 is
broadened to cover both rather than staying scoped to one symptom.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@steven0x51 steven0x51 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

looks good

@tomek-i
tomek-i merged commit 4ea6414 into main Jul 15, 2026
10 checks passed
@tomek-i
tomek-i deleted the 950-fix-stuck-stop-recording branch July 15, 2026 01:42
@github-actions

Copy link
Copy Markdown
Contributor

Automated Release Created Successfully

Release Details:

You can monitor the build progress in the Actions tab.

tomek-i pushed a commit that referenced this pull request Jul 20, 2026
* Scaffold FY2027 R&D Core Activity docs for #921

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Broaden #921's activity to cover the related, shipped #956 fix

Consolidating toward 2-4 R&D Core Activities per year: the cross-Space
overlay bug and the already-fixed stuck-Stop-button bug (#956) are two
facets of the same recording-lifecycle-reliability question, so #921 is
broadened to cover both rather than staying scoped to one symptom.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Calum Simpson <calum@ssw.com.au>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

armada:blocked ARMADA could not finish; needs a human armada:record-failed On-demand walkthrough request failed or degraded; needs a human armada Eligible for the ARMADA fleet to pick up

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🐛 Bug - Cannot stop previous shave when audio wasn’t opened (stuck state)

3 participants