Skip to content

fix(speech): honor task cancellation in recorder poll loop#204

Open
SebTardif wants to merge 3 commits into
openclaw:mainfrom
SebTardif:fix/speech-poll-cancellation
Open

fix(speech): honor task cancellation in recorder poll loop#204
SebTardif wants to merge 3 commits into
openclaw:mainfrom
SebTardif:fix/speech-poll-cancellation

Conversation

@SebTardif

@SebTardif SebTardif commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Summary

The observeRecorderState() poll loop in Speech.swift uses try? await Task.sleep(nanoseconds:) to throttle polling. The try? silently discards CancellationError, causing the loop to run indefinitely when the parent task is cancelled.

Additionally, the observer task handle was discarded (fire-and-forget Task { }), so stopListening() had no way to cancel the poll loop. The Task.isCancelled guard was defensive-only with no lifecycle path that triggered it.

Problem

When the speech recognizer's parent task is cancelled (e.g., view dismissed or app entering background), the observeRecorderState loop continues polling the recorder's isRecording and transcript state every 100ms. The loop only exits when self.isListening becomes false or recorder.isRecording becomes false, but neither is set by task cancellation.

The try? await Task.sleep pattern swallows CancellationError along with other errors. Without an explicit cancellation check, the cooperative cancellation contract of Swift Structured Concurrency is broken.

Fix

Two changes:

  1. Store the observer task handle in recorderObserverTask (was fire-and-forget Task { }). Cancel it in stopListening() before stopping the recorder. This connects the cancellation guard to a real lifecycle path.

  2. Add guard !Task.isCancelled else { break } after the try? await Task.sleep call. This is the standard Swift pattern for honoring cooperative cancellation in poll loops that use try?.

Origin

The poll loop was introduced in commit babc87d58 (2025-07-30, Peter Steinberger) as part of the original speech recognizer implementation, approximately 11 months ago.

Real behavior proof

  • Behavior addressed: The observeRecorderState() poll loop in Speech.swift ignores task cancellation because try? await Task.sleep silently discards CancellationError. The observer task handle was also discarded, so stopListening() could not cancel the loop. Dismissing the speech view or the app entering background leaves the loop running indefinitely.

  • Real environment tested: macOS 26, Swift 6.3.3 (swiftlang-6.3.3.1.3), built from patched source on fix/speech-poll-cancellation branch.

  • Exact steps or command run after this patch:

    cd /Users/sebastientardif/Peekaboo
    git checkout fix/speech-poll-cancellation
    
    # Step 1. Compile standalone proof script
    swiftc -parse-as-library -o /tmp/prove-speech \
      scripts/prove-speech-poll-cancellation.swift
    
    # Step 2. Run proof
    /tmp/prove-speech
    
    # Step 3. Verify guard placement and task handle storage
    grep -n 'Task.isCancelled\|recorderObserverTask' Apps/Mac/Peekaboo/Core/Speech.swift
  • Evidence after fix: terminal output from the standalone cancellation proof:

    $ swiftc -parse-as-library -o /tmp/prove-speech scripts/prove-speech-poll-cancellation.swift && /tmp/prove-speech
    === Speech poll-loop cancellation proof ===
    
    Test 1: Unfixed pattern (try? swallows CancellationError)
      Loops executed: 20 (expected: all 20, cancellation ignored)
      Elapsed: 160ms
    
    Test 2: Fixed pattern (Task.isCancelled guard after sleep)
      Loops executed: 2 (expected: ~2, exits on cancellation)
      Elapsed: 158ms
    
    ✅ PASS: Unfixed loop ran 20 iterations (ignores cancel)
    ✅ PASS: Fixed loop ran 2 iterations (respects cancel)
    ✅ The Task.isCancelled guard stops the poll loop promptly on cancellation.

    Task handle storage and cancellation path verified:

    $ grep -n 'Task.isCancelled\|recorderObserverTask' Apps/Mac/Peekaboo/Core/Speech.swift
    107:    private var recorderObserverTask: Task<Void, Never>?
    199:            self.recorderObserverTask?.cancel()
    200:            self.recorderObserverTask = nil
    283:        self.recorderObserverTask = Task {
    304:            guard !Task.isCancelled else { break }
  • Observed result after fix: The unfixed pattern runs all 20 iterations (ignoring cancellation) because try? causes Task.sleep to return instantly on a cancelled task without throwing. The fixed pattern exits after 2 iterations because Task.isCancelled catches the cancellation state. The stored task handle ensures stopListening() can cancel the observer through a real lifecycle path.

  • What was not tested: Live cancellation with active speech recognition (requires microphone access and SFSpeechRecognizer authorization). The standalone proof reproduces the exact poll-loop pattern from observeRecorderState().

Related

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
@clawsweeper

clawsweeper Bot commented Jun 28, 2026

Copy link
Copy Markdown

Codex review: needs real behavior proof before merge. Reviewed June 29, 2026, 10:21 AM ET / 14:21 UTC.

Summary
The PR stores the Whisper recorder observer task, cancels it from stopListening(), adds a Task.isCancelled guard after the poll-loop sleep, and adds a standalone Swift cancellation proof script.

Reproducibility: yes. for a source-level cancellation concern: current main has an unowned Whisper recorder poll task and a try? sleep that would swallow cancellation. I did not establish a live microphone or UI-dismissal reproduction against current main.

Review metrics: 2 noteworthy metrics.

  • Diff shape: 2 files changed; 88 additions, 2 deletions. Most of the diff is a standalone proof script rather than production code or integrated regression coverage.
  • Proof script: 80 added lines. Maintainers should decide whether this belongs in the repository or should be replaced by durable test coverage.

Merge readiness
Overall: 🦪 silver shellfish
Proof: 🦪 silver shellfish
Patch quality: 🦐 gold shrimp
Result: blocked until stronger real behavior proof is added.

Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch.

Rank-up moves:

  • Wire cancellation into dismissal/deinit paths, such as stopping recording on view disappearance or using a weak/deinit observer lifecycle like the core audio service.
  • Post redacted terminal/log/screenshot proof from the real SpeechRecognizer lifecycle; redact API keys, endpoints, IPs, phone numbers, and other private details before updating the PR body.

Proof guidance:

  • [P1] Needs stronger real behavior proof before merge: The terminal proof is useful but synthetic; add redacted live or production-matched SpeechRecognizer/Whisper lifecycle proof and update the PR body for a fresh review.

Risk before merge

  • [P1] The new task handle is only cancelled from stopListening(), while at least one dismissal path can bypass that method and leave the strongly captured observer alive.
  • [P1] The posted terminal proof demonstrates the Swift sleep/cancellation pattern, but not the patched app lifecycle with Whisper recording, dismissal, or backgrounding.

Maintainer options:

  1. Decide the mitigation before merge
    Keep the handle-and-guard direction, but wire recorder teardown into dismissal/deinit paths and add production-matched regression coverage or redacted live lifecycle proof before merge.
  2. Pause or close
    Do not merge this PR until maintainers decide whether the risk is worth taking.

Next step before merge

  • [P1] The contributor needs to update lifecycle coverage and real behavior proof; automation cannot supply proof from their microphone/macOS setup.

Security
Cleared: The diff only changes local Swift control flow and adds a standalone Swift proof script; I found no dependency, secret, network, publishing, or automation-hook security concern.

Review findings

  • [P2] Cancel the observer on dismissal paths — Apps/Mac/Peekaboo/Core/Speech.swift:283-285
Review details

Best possible solution:

Keep the handle-and-guard direction, but wire recorder teardown into dismissal/deinit paths and add production-matched regression coverage or redacted live lifecycle proof before merge.

Do we have a high-confidence way to reproduce the issue?

Yes for a source-level cancellation concern: current main has an unowned Whisper recorder poll task and a try? sleep that would swallow cancellation. I did not establish a live microphone or UI-dismissal reproduction against current main.

Is this the best way to solve the issue?

No, not yet: storing the task handle and checking cancellation is the right core direction, but the patch must also cover dismissal/teardown paths and prove the actual recognizer lifecycle.

Full review comments:

  • [P2] Cancel the observer on dismissal paths — Apps/Mac/Peekaboo/Core/Speech.swift:283-285
    This stores the observer handle, but the only new cancel call is in stopListening(). The PR's stated dismissal path can still bypass that: SpeechInputView.sendToAgent() calls dismiss() without stopRecording(), and the task here still captures self strongly, so the recorder poll loop can stay alive after the view is dismissed.
    Confidence: 0.86

Overall correctness: patch is incorrect
Overall confidence: 0.84

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against dda07c245fea.

Label changes

Label changes:

  • add rating: 🦪 silver shellfish: Overall readiness is 🦪 silver shellfish; proof is 🦪 silver shellfish and patch quality is 🦐 gold shrimp.
  • remove rating: 🧂 unranked krab: Current PR rating is rating: 🦪 silver shellfish, so this older rating label is no longer current.
  • remove merge-risk: 🚨 availability: Current PR review selected no merge-risk labels.

Label justifications:

  • P2: This is a normal-priority Mac app speech availability/lifecycle bug with limited blast radius.
  • rating: 🦪 silver shellfish: Overall readiness is 🦪 silver shellfish; proof is 🦪 silver shellfish and patch quality is 🦐 gold shrimp.
  • status: 📣 needs proof: The PR needs real behavior proof before ClawSweeper can clear the contributor ask. Needs stronger real behavior proof before merge: The terminal proof is useful but synthetic; add redacted live or production-matched SpeechRecognizer/Whisper lifecycle proof and update the PR body for a fresh review.
Evidence reviewed

What I checked:

Likely related people:

  • steipete: Git blame and log history tie the current speech observer loop and the existing core audio observer lifecycle pattern to Peter Steinberger's commits. (role: feature-history owner and recent area contributor; confidence: high; commits: 1fa8eead7eea, babc87d587b2, ef0b695957c9; files: Apps/Mac/Peekaboo/Core/Speech.swift, Apps/Mac/Peekaboo/Features/AI/SpeechInputView.swift, Core/PeekabooCore/Sources/PeekabooAutomation/Services/Audio/AudioInputService.swift)
  • SebTardif: SebTardif authored the merged watch-capture cancellation fix for the same Swift cancellation pattern in a different code path, and authored this proposed speech fix. (role: adjacent cancellation-fix contributor; confidence: medium; commits: db5192bb370b, d9d092c8db2a; files: Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Capture/WatchCaptureSession+Loop.swift, Apps/Mac/Peekaboo/Core/Speech.swift)
What the crustacean ranks mean
  • 🦀 challenger crab: rare, exceptional readiness with strong proof, clean implementation, and convincing validation.
  • 🦞 diamond lobster: very strong readiness with only minor maintainer review expected.
  • 🐚 platinum hermit: good normal PR, likely mergeable with ordinary maintainer review.
  • 🦐 gold shrimp: useful signal, but proof or patch confidence is still limited.
  • 🦪 silver shellfish: thin signal; proof, validation, or implementation needs work.
  • 🧂 unranked krab: not merge-ready because proof is missing/unusable or there are serious correctness or safety concerns.
  • 🌊 off-meta tidepool: rating does not apply to this item.

Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

How this review workflow works
  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P2 Normal priority bug or improvement with limited blast radius. labels Jun 28, 2026
@SebTardif

Copy link
Copy Markdown
Contributor Author

@clawsweeper[bot]

Canonical path: Close this PR as superseded by #193.

This PR is not superseded by #193. They fix the same bug class (try? await Task.sleep swallowing CancellationError) but in completely different files:

There is zero code overlap between the two PRs.

the proposed guard is not connected to a cancellable observation task

Fair point. The Task { } at line 279 discards the handle, so stopListening() currently exits the loop via the while self.isListening condition rather than task cancellation. The guard is defensive coding that costs nothing (one boolean check) and matches the pattern established by #193. It protects against future refactoring that stores the task handle, and is correct behavior if the enclosing structured concurrency scope is ever cancelled.

The Whisper recorder observer Task was fire-and-forget (handle
discarded at line 279), so stopListening() could not cancel the
poll loop. The Task.isCancelled guard was defensive-only with no
lifecycle path that triggered it.

Store the task handle in recorderObserverTask and cancel it in
stopListening() before stopping the recorder, making the
cancellation guard exercised through a real code path.

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
Reproduces the try? Task.sleep poll-loop pattern from
observeRecorderState() and demonstrates that the unfixed
version ignores cancellation (runs all iterations) while
the fixed version with Task.isCancelled guard exits after
~2 iterations.

Usage: swiftc -parse-as-library -o /tmp/prove-speech   scripts/prove-speech-poll-cancellation.swift && /tmp/prove-speech
Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
@SebTardif

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Jun 28, 2026

Copy link
Copy Markdown

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

@clawsweeper clawsweeper Bot added the merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. label Jun 28, 2026
@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. and removed rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. labels Jul 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P2 Normal priority bug or improvement with limited blast radius. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant