Skip to content

fix: video: stop the recording health monitor from reopening its dialog - #2951

Open
rafaellehmkuhl wants to merge 2 commits into
bluerobotics:masterfrom
rafaellehmkuhl:issue-2950-recording-health-monitor-reopens-the
Open

fix: video: stop the recording health monitor from reopening its dialog#2951
rafaellehmkuhl wants to merge 2 commits into
bluerobotics:masterfrom
rafaellehmkuhl:issue-2950-recording-health-monitor-reopens-the

Conversation

@rafaellehmkuhl

@rafaellehmkuhl rafaellehmkuhl commented Aug 19, 2026

Copy link
Copy Markdown
Member

Summary

A recording with a missing output file made the 15 s health monitor call showDialog on every tick, and since showDialog unmounts and mounts a fresh dialog on each call, the warning kept popping back with no way to dismiss it for good. The fix lands in the shared dialog composable rather than in the caller.

  • showDialog no longer remounts a dialog that is already on screen (src/composables/interactionDialog.ts). While a dialog with the same resolved options is open, the pending promise is returned instead of the dialog being rebuilt under the user. useInteractionDialog is a factory, so this dedupes per composable instance, and it covers only the dialog shown last — which is what a caller repeating one request needs, the interval case. Callers that share an instance and alternate between different requests still have to arbitrate among themselves, which the recording monitors now do (below). Keying on the whole resolved state (not just the message) keeps a caller asking for the same text with different buttons from silently inheriting the open dialog's promise. It replaces the hand-rolled "is it already open" flag the sibling not-growing-file check carried in src/stores/video.ts.
  • The missing-file branch now uses the same guarded dialog as the other recording health warnings (src/stores/video.ts). It gets the "Don't show again during this session" opt-out, and its message says the recording may be lost and that the user should stop it and start a new one, rather than talking about a file size that could not be read. The session opt-out is keyed by message, so silencing the mild not-growing nag does not silence the serious missing-file warning.
  • Every recording health warning names its stream, and the monitors take turns on the one dialog surface (src/stores/video.ts). Cockpit records several streams at once, so "stop this recording" pointed at nothing the user could act on. Naming the stream makes each stream's warning a distinct dialog request, which the composable's most-recent-only guard cannot deduplicate, so the store tracks which warning owns the surface: the one on screen holds it until it is settled, and the monitors of the other unhealthy streams retry on later ticks. Nothing but the user settles one of these dialogs, so the wait is as long as the user leaves it up — a warning about a recording that may already be lost therefore takes the surface from a milder one instead of queueing behind it. The Close action's log entry now names the warning it closed, matching the opt-out's.

Test plan

  • On Standalone, start a recording, then delete or rename the output file under the videos folder while it runs. The warning appears once, names the stream, and stays put across the following 15 s ticks.
  • Press "Close" on it. It comes back on the next tick (the problem is still there), still only one dialog at a time.
  • Press "Don't show again during this session". It stays gone for the rest of the session.
  • Force the not-growing warning, silence it with the opt-out, then make the file disappear. The missing-file warning still shows.
  • Record two streams and break both, one with a missing file and one that stops growing. One warning is shown at a time and is not replaced under the user every 15 s; acting on it lets the other through on a later tick.
  • With the not-growing warning left on screen, make the other stream's file disappear. The missing-file warning replaces it on the next tick, and the not-growing one does not take the surface back while it is up.
  • Confirm unrelated dialogs still open normally while one is on screen, and that a second, different message replaces the open one as before.

Checks

  • Unit test src/tests/composables/interactionDialog.test.ts counts dialog mounts: three identical showDialog calls mount once, a different message mounts again, the same message with a different variant mounts its own dialog, and mounting resumes after closeDialog.
  • yarn lint and yarn test:unit clean.

Closes #2950

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Automated PR Review — round 1⚠️ IMPORTANT FIXES REQUIRED

⚠️ IMPORTANT FIXES REQUIRED — 6 open findings: 1 major (1.1), 4 minor and 1 nit.

While a video recording is running, Cockpit checks every 15 seconds that the recording is still growing on disk and pops up a warning when it is not. This PR makes the shared warning-popup helper recognise that it is being asked for a popup that is already on screen and leave it alone instead of tearing it down and rebuilding it, and it routes the "output file is missing" warning through the same warning popup as its sibling checks, with new wording and a "don't show again this session" button. A small unit test covering the no-rebuild behaviour is added.

What still needs attention

# Problem What it means Severity Status
1.1 One opt-out button silences two different warnings A user who dismisses the mild "file is not growing" nag will never be told, for the rest of the session, that the recording file has vanished — so a recording can be lost without any warning. major
1.2 Repeat popups are matched only by their wording If two different parts of the app ask for a popup that happens to use the same words, the second one silently never appears and gets the first one's answer. minor
6.1 The new "don't show again" button is not logged When a user later reports that Cockpit stopped warning them about a broken recording, the support log has no record of them having switched the warnings off. minor
7.1 The warning text is written across two source lines The warning sentence carries a line break and a run of spaces in the middle of it, which will show up literally anywhere the text is not rendered as web page content. minor
8.1 Two separate changes in one commit The reworded warning and its new opt-out button cannot be reverted or backported without also reverting the popup fix. minor
11.1 Debug printouts left in the new test Continuous-integration logs get three lines of leftover debugging noise on every run. nit
Change map — what was established before judging

Claims (from the PR body and commit message — the author's hypothesis, checked against the code)

Claim Verdict
"A recording with a missing output file made the 15 s health monitor call showDialog on every tick" Verified. src/stores/video.ts:823-828 (base) calls showDialog directly and returns without clearing the interval registered at src/stores/video.ts:813, whose period is 15000 ms (src/stores/video.ts:836). Nothing guards the call.
"showDialog unmounts and mounts a fresh dialog on each call" Verified. showDialog (src/composables/interactionDialog.ts:139-151) unconditionally calls mountDialog, which begins with unmountDialog() (src/composables/interactionDialog.ts:119-122).
"the warning kept popping back with no way to dismiss it for good" Verified. The missing-file branch passed no actions, and defaultDialogState() sets persistent: true (src/composables/interactionDialog.ts:97), so the only control was the fallback Close button at src/components/InteractionDialog.vue:74, which the next tick undid.
"The fix lands in the shared dialog composable rather than in the caller" Verified, and it is the right chokepoint: the remount was in the shared helper, not in the caller.
"That covers every caller that notifies from a poll or an interval" Partly contradicted. useInteractionDialog is a factory, not a singleton — every call gets a fresh closure (src/composables/interactionDialog.ts:101-106 plus the two new lets). The guard therefore dedupes only within one instance, and only when title and message are byte-identical. Callers that build a message containing a changing value (a size, a count, a timestamp) still remount on every tick. See finding 1.2.
"it replaces the hand-rolled 'is it already open' flag the sibling not-growing-file check carried" Verified; notGrowingDialogOpen (src/stores/video.ts:779, :781, :800-801) is removed by the diff.

Failure sitesrc/stores/video.ts:823-828 (the unguarded showDialog in the Electron monitor tick) and src/composables/interactionDialog.ts:139-151 (the unconditional remount). Both are in the diff, and the diff fixes the shared one rather than only the caller.

Entry points

Function Reached from Frequency
showDialog (src/composables/interactionDialog.ts:139, head hunk @@ -137,21 +140,35) ~30 call sites; the one this PR targets is the setInterval at src/stores/video.ts:813 (Electron) and :839 (web) per interval tick — every 15 s per active recording stream, plus per user action from every other caller
closeDialog (src/composables/interactionDialog.ts:153) dialog action buttons via handleAction (src/components/InteractionDialog.vue:251-254), and the internalShowDialog watcher at src/components/InteractionDialog.vue:247 per user action
new resolveFn / rejectFn wrappers (added inside showDialog) the onConfirmed / onDismissed props passed in mountDialog (src/composables/interactionDialog.ts:127-132), emitted at src/components/InteractionDialog.vue:253 and :246 per user action
suppressRecordingHealthDialog (src/stores/video.ts:784, renamed) the "Don't show again during this session" button in recordingHealthDialogConfig.actions per user action
showRecordingHealthDialog (src/stores/video.ts:799, renamed) setInterval at src/stores/video.ts:813 and :839 per interval tick — every 15 s per active recording stream
src/tests/composables/interactionDialog.test.ts body vitest only never in production code (test file, not a finding)

Invariants — the change relies on: openDialogPromise is defined exactly while this composable instance's dialog is on screen. Every site that can take the dialog off screen, and whether the PR covers it:

Site Covered?
closeDialog (src/composables/interactionDialog.ts:153) Yes — the diff adds openDialogPromise = undefined.
user confirm → emit('confirmed') (src/components/InteractionDialog.vue:253) → resolveFn Yes — the new resolve wrapper clears it.
dismiss / timer expiry → emit('dismissed') (src/components/InteractionDialog.vue:246, :226) → rejectFn Yes — the new reject wrapper clears it.
a superseding showDialog with a different key Yes — resolveFn?.(…) runs before the reassignment, so the clear cannot clobber the new promise.
InteractionDialog.vue:247 calling closeDialog from its own composable instance, not the store's Yes, indirectly — emit('dismissed') at :246 fires first on the store's instance.
onUnmounted → unmountDialog (src/composables/interactionDialog.ts:158-160) does not clear the flag Moot — the closure dies with its owning component, so that instance can never be asked again.
a different useInteractionDialog() instance mounting its own dialog over this one Not covered, and pre-existing: unmountDialog only knows its own mountPoint, so cross-instance stacking is unchanged by this PR and is not a finding against it.

Complexitycomplexity-report.json reports triggeredCount: 0 over 118 functions measured across all 3 changed files, truncated: false. No complexity findings.

1. Correctness & Implementation Bugs — 2 findings

1.1 — One session flag now silences two warnings of very different severity (major)

src/stores/video.ts:74 and the diff hunk at :775-790.

The diff renames suppressNotGrowingDialogs to suppressRecordingHealthDialogs and routes the missing-file branch through showRecordingHealthDialog, which returns early on that same flag. The flag is:

  • store-level, not per recording (src/stores/video.ts:74), so it spans every stream and every recording started later in the session;
  • never resetgrep finds only the declaration and the single = true write, no path back to false;
  • set from a button whose label ("Don't show again during this session") does not say which warnings it covers.

On Standalone both warnings come from the same monitor tick (src/stores/video.ts:813), so the sequence is reachable in one sitting: the user silences the relatively benign "The video output file is not growing" nag on one stream, and from then on the far more serious "Cockpit cannot find the file this recording is being saved to" — the one the new copy itself says means the recording may be lost — never appears, for any stream, for the rest of the session. In the base revision that warning was not suppressible at all (which was the bug), so this is a behaviour the PR introduces, not one it inherits.

Consequence: a user can lose a recording with no warning at all, because they earlier consented to silence a different, milder message.

Smallest fix: keep the opt-out scoped to what the user actually opted out of. Either give showRecordingHealthDialog a per-message suppression set (suppressedRecordingHealthMessages: Set<string>, keyed on the message it was invoked with) and check membership instead of one boolean, or leave the boolean to the not-growing checks and omit the opt-out action from the missing-file config. If the intent really is one switch for all of them, the button has to say so ("Stop warning me about this recording's health") and the finding becomes a copy fix instead.

1.2 — The dedup key covers only title and message, and the guard discards the caller's other options (minor)

Added in showDialog, diff hunk src/composables/interactionDialog.ts @@ -137,21 +140,35:

const key = JSON.stringify([options.title, options.message])
if (openDialogPromise && openDialogKey === key) return openDialogPromise

Two problems follow from the key:

  1. variant, actions, maxWidth, persistent and timer are outside it, so a second caller with the same wording but different buttons gets an early return: dialogProps is never updated, their actions never render, and they are handed a promise owned by the first caller — so their isConfirmed is decided by whichever button the user pressed on the other request. I traced the shared-instance call sites (src/libs/utils-vue.ts:5, src/stores/widgetManager.ts:49, src/stores/mission.ts:127, src/stores/controller.ts:264, src/stores/snapshot.ts:25, src/libs/sensors-logging.ts:39, src/libs/joystick/protocols/mavlink-manual-control.ts:216) and found no collision today, which is why this is minor rather than major — but nothing stops the next one, and the failure is silent.
  2. title is read raw, while defaultDialogState() normalises it to '' (src/composables/interactionDialog.ts:93). A caller that omits title and one that passes title: '' produce [null,…] and ["",…], i.e. two different keys for the same dialog, so the dedup does not fire between them.

Fix, in the same two lines: key on the whole options object (JSON.stringify(options) will not capture the action callbacks, but title/message/variant/persistent/timer are what distinguish dialogs in practice) or, at minimum, use options.title ?? '' and compare message and variant together. Either keeps the diff the same size.

Note for the PR body: the claim that this "covers every caller that notifies from a poll or an interval" holds only for callers whose message text is constant across ticks. useInteractionDialog is a factory (each call gets its own closure), so the dedup is also per-instance rather than app-wide.

6. UI / UX — 1 finding

6.1 — The new dialog actions are not logged via logUserAction (minor)

Diff hunk src/stores/video.ts @@ -775,31 +775,24. The missing-file dialog gains two buttons it did not have before, and suppressRecordingHealthDialog is a new handler; neither it nor the Close action calls logUserAction. AGENTS.md ("Logging user interactions") requires every discrete user interaction in a new feature to produce a logUserAction(…) entry, and src/stores/video.ts currently contains no logUserAction call at all, so this is house direction the file has not converged on yet rather than a local inconsistency — hence minor.

The session opt-out is the one that matters: once it is pressed, Cockpit deliberately stops warning about a recording that may be failing, and finding 1.1 shows how wide that silence is. Without a log entry, a later "Cockpit didn't tell me the recording was broken" report is unexplainable from the logs.

One line in the handler, in the established past-tense voice:

logUserAction('Silenced the recording health warnings for this session')

Consequence: when a user reports missing warnings, support has no record that the user turned them off.

7. Code Quality & Style — 1 finding

7.1 — The new warning text is a two-line template literal, so a newline and 12 spaces end up inside the string (minor)

Diff hunk src/stores/video.ts @@ -821,14 +814,16:

const msg = `Cockpit cannot find the file this recording is being saved to, which means the recording may
  be lost. We recommend stopping it and starting a new one.`

The literal contains may\n be lost. It renders acceptably today only because src/components/InteractionDialog.vue:31 interpolates it into a plain <div>, where HTML collapses the run of whitespace — the string itself is wrong, and it will show the break literally the moment it reaches a title attribute, a log line, a snackbar with white-space preserved, or a copy-paste.

max-len is code: 180 with .eslintrc.cjs:47, and the single-line form is ~175 characters at that indentation, so this fits without the bare // eslint-disable-next-line the diff correctly removes. Alternatively DialogOptions.message already accepts string[] (src/composables/interactionDialog.ts:17) and the component renders an array as list items (src/components/InteractionDialog.vue:26-28), which is the in-tree way to split a message across lines deliberately. Both sibling messages in the same function stay on one line (src/stores/video.ts:831, :852-854).

Consequence: the warning sentence carries a stray line break and indentation that will surface as literal text wherever it is not rendered as HTML body content.

8. Commit Hygiene — 1 finding

8.1 — The single commit bundles the shared dialog fix with a separate user-facing behaviour change (minor)

pr.json lists one commit, e6b76d4 "video: stop the recording health monitor from reopening its dialog". Read against git log on this checkout, the scope-prefixed subject is the dominant style here and video: fits, so the subject is fine. The contents are two logical changes:

  1. the showDialog dedup in src/composables/interactionDialog.ts plus the removal of the now-redundant notGrowingDialogOpen flag and its test — this is the fix, and it alone stops the reopening;
  2. the missing-file branch being rewritten to use recordingHealthDialogConfig, gaining a two-button footer, the session opt-out and completely new copy — a user-visible behaviour change that is not required for (1) to work.

AGENTS.md "Commit hygiene": "A fix or a modification to existing behavior gets its own commit, never a corner of the feature commit that happens to touch the same code — it has to be reviewable, revertable, and backportable on its own." Finding 1.1 is entirely inside (2); with the split, it could be reverted or reworked without touching the composable fix.

Nothing else here is a problem: no issue or PR reference in the commit message (Closes #2950 correctly sits in the PR body only), no wip/fixup! noise, no over-splitting, and 74/28 lines is well within one sitting.

Consequence: the reworded warning and its new opt-out cannot be reverted or backported without also reverting the popup fix.

11. Nitpicks / Optional — 1 finding

11.1 — Three leftover console.log calls in the new test (nit)

src/tests/composables/interactionDialog.test.ts lines 12, 18 and 23 of the added file print the mounts counter before each expect. no-console is off (.eslintrc.cjs:49) so this is not a lint error, and no existing test under src/tests/ logs progress this way. The expect calls already report the value on failure. Drop them.

Sections with nothing to report (6)

2. Persistence & User Data — ✅ (the only touched declaration near persisted state is src/stores/video.ts:74, a plain ref(false) renamed in place; the useBlueOsStorage keys at :68-69 appear as diff context only, and no cockpit-* key is added, reshaped or removed, so the section collapses per the inventory rule)

3. AGENTS.md Adherence — ✅ (no new dependency and no package.json change; the two renames are justified by the widened meaning of the flag rather than cosmetic, so scope discipline holds; the reworded comment at src/stores/video.ts:790-791 sits above unchanged persistent: true, but it named the deleted notGrowingDialogOpen and was therefore factually wrong, which the comment-immutability rule permits rewriting; the showDialog JSDoc at src/composables/interactionDialog.ts:79-81 was extended alongside the code it documents and keeps its typed @param/@returns; nothing exported is left without a call site)

4. Security — ✅ (checked all sub-checks: no new dependency, no encoded blob, no non-ASCII or bidirectional characters in the three files, no network call, no eval/Function/v-html, no env var or credential, no change under scripts/, .github/ or src/electron/, no licensing impact; pr.json, pr.diff and complexity-report.json were also read for text addressed to the reviewer and contain none)

5. Performance — ✅ (the guard removes an app unmount/createApp/mount cycle per 15 s tick at src/stores/video.ts:813 and :839; no timer, listener, subscription or watch is added, so there is nothing new needing teardown, and none of the hot paths — mavlink:onIncomingMessage, dataLake:setVariable, dataLake:notifyListeners — is touched; the added work per call is one JSON.stringify of two short strings)

9. Tests — ✅ (no existing test weakened or removed; the added test asserts mount counts through a stubbed InteractionDialog.vue and asserts promise identity, which matches the behaviour the diff introduces rather than its implementation, and it restores state via closeDialog() before its last assertion so it is not order-dependent within the file)

10. Documentation — ✅ (no Lite/Standalone parity change to record in README.md — the missing-file check already ran only behind window.electronAPI at src/stores/video.ts:811 and still does; the only public surface touched, the showDialog contract, has its JSDoc updated in the same hunk)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the issue-2950-recording-health-monitor-reopens-the branch from e6b76d4 to b3c5aeb Compare August 19, 2026 15:17
@rafaellehmkuhl

rafaellehmkuhl commented Aug 19, 2026

Copy link
Copy Markdown
Member Author
Review follow-up — round 1

Done

  • src/stores/video.ts (1.1 — one session flag silences two warnings of different severity): the store-level boolean is gone. suppressedRecordingHealthMessages is a Set<string> keyed on the message the user actually opted out of, so silencing the not-growing nag leaves the missing-file warning intact.
  • src/composables/interactionDialog.ts (1.2 — dedup key covers only title and message): the key is now JSON.stringify({ ...defaultDialogState(), ...options }), so variant, actions, maxWidth, persistent and timer are part of it, and an omitted title normalises to '' like a passed one. Action callbacks drop out of the JSON, which is what still lets interval callers rebuilding their arrow functions hit the guard.
  • src/stores/video.ts (6.1 — new dialog actions not logged): both actions log. The opt-out logs which warning was silenced (the message goes into the entry), and Close logs too.
  • src/stores/video.ts (7.1 — two-line template literal): the message is a single-line string passed straight to showRecordingHealthDialog, matching the two sibling calls in the same function. 165 characters at that indentation, so it fits under max-len: 180.
  • (8.1 — one commit bundles two logical changes): split into composables: interaction-dialog: don't remount a dialog that is already open (the composable guard, the removal of the hand-rolled notGrowingDialogOpen flag, and the test) and video: route the missing recording file warning through the health dialog (the copy, the opt-out and the logging). The first alone stops the reopening.
  • src/tests/composables/interactionDialog.test.ts (11.1 — leftover console.log): removed. Added one assertion that the same message with a different variant mounts its own dialog, which is the guard for 1.2.

Done differently

  • src/stores/video.ts (1.1): kept the button label as is rather than rewording it to cover all the warnings. Per-message suppression is what the label already promises to a user reading it, and it is the option that cannot lose a recording silently. The suppression is still session-wide across streams, as it was before this PR — the message text carries no stream name.

Notes

  • PR body updated for the per-instance point in the change map: the dedup is per useInteractionDialog() instance, not app-wide, and the claim about interval callers now says so.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Automated PR Review — round 2 — 📝 MINOR SUGGESTIONS

📝 MINOR SUGGESTIONS — 2 open findings (2 minor), 6 closed since round 1.

While a video recording runs, Cockpit checks every 15 seconds that the file is still growing and pops a warning up when it is not. This round the shared pop-up helper now recognises a request for a pop-up that is already on screen — matching on the whole request, not just its wording — and hands the caller the existing one back instead of tearing it down and rebuilding it. The "output file is missing" warning now goes through the same pop-up as its siblings, with new wording, a "don't show again this session" button that only silences the one warning the user dismissed, and log entries for both buttons. A unit test counts pop-up rebuilds, and the work is now split into two commits.

What still needs attention

# Problem What it means Severity Status
1.3 The no-rebuild guard only protects the most recent pop-up If two recordings fail at the same time, or another pop-up appears in between, the recording warning still vanishes and comes back under the user every 15 seconds. minor
6.2 The warning never says which recording is failing A user recording two streams is told a recording may be lost and to stop it, without being told which one. minor
Since round 1 — 6 closed, 2 new, comparing e6b76d4b3c5aeb

Rangee6b76d40490572812dcb42cf2b92effaff9cdc4bb3c5aeb2313c41ea698f77147c4d0457ba197c82.

incremental.diff is not usable as a delta this round: it contains the PR's whole base...head content (+25/-5, +33/-28, +35/-0 — the same hunks as pr.diff), which is what a rebase produces when PREV_SHA is no longer an ancestor of the head. That is consistent with the commit split the author describes, and pr.json now lists two commits where round 1 saw one. Every status below was therefore judged against pr.diff and the code quoted in round 1, not against incremental.diff.

No /resolve commands have been issued on this PR (resolutions.json is []), so nothing was closed by a maintainer this round.

Status of the round 1 findings

  • 1.1 — One session flag now silences two warnings of very different severity (major) — ✅ Addressed. The finding asked for the opt-out to be scoped to what the user actually opted out of, and named a per-message suppression set as the first of two acceptable fixes. That is what landed: suppressNotGrowingDialogs = ref(false) is gone, replaced by const suppressedRecordingHealthMessages = new Set<string>() (head src/stores/video.ts:75, with its explaining comment at :74), and the head hunk @@ -775,31 +776,33 @@ carries if (suppressedRecordingHealthMessages.has(message)) return and suppressedRecordingHealthMessages.add(message). Silencing "The video output file is not growing…" therefore leaves "Cockpit cannot find the file this recording is being saved to…" reachable.
  • 1.2 — Dedup key covers only title and message (minor) — ✅ Addressed. Both halves are closed by the same two lines: const resolvedOptions = { ...defaultDialogState(), ...options } / const key = JSON.stringify(resolvedOptions) (head hunk @@ -137,21 +140,38 @@). variant, actions, maxWidth, persistent and timer are now inside the key, so a same-worded request with different buttons gets its own dialog rather than inheriting the open one's promise; and merging over defaultDialogState() normalises an omitted title to '' (src/composables/interactionDialog.ts:93), so the two spellings produce one key. The residual — action callbacks are dropped by JSON.stringify, so two requests differing only in what their identically-labelled buttons do still collide — is the trade the finding itself proposed, and the code comment states it.
  • 6.1 — New dialog actions are not logged via logUserAction (minor) — ✅ Addressed. Both handlers log in the past-tense house voice: logUserAction(\Silenced the recording health warning "${message}" for this session`)andlogUserAction('Closed a recording health warning')(head hunk@@ -775,31 +776,33 @@). No import is needed and none was added, which is correct — the helper is global (src/libs/cosmos.ts:66, :675) and declared to ESLint at .eslintrc.cjs:18-21`. The opt-out entry names the warning that was silenced, which is the case that mattered; the Close entry does not, which is folded into 6.2 below rather than left as its own finding.
  • 7.1 — Warning text was a two-line template literal (minor) — ✅ Addressed. The message is now a single-line quoted string passed straight to showRecordingHealthDialog (head hunk @@ -821,14 +824,16 @@), so no newline or run of spaces survives inside it. At 150 characters plus quotes and 12 columns of indentation it is ~164, under max-len code: 180 (.eslintrc.cjs:47), and the bare // eslint-disable-next-line is gone. The sibling not-growing call was re-wrapped onto three lines in the same hunk; that is Prettier's doing, since the rename to showRecordingHealthDialog pushed the one-line form to ~124 columns against printWidth: 120 (package.json:155-160), so it is a required reflow rather than scope creep.
  • 8.1 — One commit bundled the shared fix with a behaviour change (minor) — ✅ Addressed. pr.json now lists two commits: 21e88e6 composables: interaction-dialog: don't remount a dialog that is already open and b3c5aeb video: route the missing recording file warning through the health dialog. The split is the one the finding asked for — the composable guard (which alone stops the reopening) apart from the user-facing copy, opt-out and logging. Both prefixes have precedent in this history, including composables: interaction-dialog: verbatim (b1c3565), and neither message references an issue or PR.
  • 11.1 — Three leftover console.log calls in the new test (nit) — ✅ Addressed. The added src/tests/composables/interactionDialog.test.ts contains no console.*; the assertions carry the mounts counter on failure. An assertion that the same message with a different variant mounts its own dialog was added, which is the regression guard for 1.2.

New this round — 1.3 (minor) and 6.2 (minor), both written out in full below. Neither is a regression introduced this round: both concern what the guard and the new copy do not cover, over the whole of pr.diff.

Discussion since round 1

  • @rafaellehmkuhl posted a follow-up (comment) listing each round 1 finding and what was done. Every "Done" item was checked against the diff above and holds. The one "Done differently" item — keeping the button label as "Don't show again during this session" instead of rewording it, on the grounds that per-message suppression is what that label already promises — is accepted: the label scopes the user's consent to one warning for one session, which is exactly what the Set<string> implements, so 1.1 is closed rather than reworded. The author's own note that the suppression is still session-wide across streams is accurate and is not raised as a finding; the related copy gap is 6.2.
  • The same comment states the PR body was updated so the per-instance claim "now says so". As published, the body still reads "this dedupes per composable instance, which is what the interval callers need since each holds its own" — the interval callers do not each hold their own instance, they share the single useInteractionDialog() at src/stores/video.ts:49. That mismatch between description and code is the basis of 1.3.
  • The only other comment on the PR is the bare /review that triggered this round, and carries nothing to act on.
Change map — what was established before judging

Claims (from the PR body and the two commit messages — the author's hypothesis, checked against the code)

Claim Verdict
"A recording with a missing output file made the 15 s health monitor call showDialog on every tick" Verified. Base src/stores/video.ts:823-827 calls showDialog and returns without touching the interval registered at :813, whose period is 15000 ms (:836). Nothing guarded it.
"showDialog unmounts and mounts a fresh dialog on each call" Verified. Base showDialog (src/composables/interactionDialog.ts:139-151) always calls mountDialog, which opens with unmountDialog() (:119-122).
"the warning kept popping back with no way to dismiss it for good" Verified. The base missing-file branch passed no actions, and defaultDialogState() sets persistent: true (:97), leaving only the fallback Close at src/components/InteractionDialog.vue:74, which the next tick undid.
"showDialog no longer remounts a dialog that is already on screen … the pending promise is returned" Verified. Head hunk @@ -137,21 +140,38 @@: if (openDialogPromise && openDialogKey === key) return openDialogPromise before any mounting, and openDialogPromise is returned instead of a fresh promise.
"Keying on the whole resolved state (not just the message) keeps a caller asking for the same text with different buttons from silently inheriting the open dialog's promise" Verified. The key is JSON.stringify({ ...defaultDialogState(), ...options }), so button text/size/color/class/disabled are in it; only the action functions drop out, which the code comment states.
"useInteractionDialog is a factory, so this dedupes per composable instance, which is what the interval callers need since each holds its own" Contradicted. The video store creates exactly one instance, at src/stores/video.ts:49, and both recording monitors plus ~16 unrelated showDialog calls in the same file (:382, :417, :425, :459, :727, :732, :736, :817, :843, :909, :920, :963, :972, :1081, :1096, :1150) share it. The per-recording monitors do not hold their own instances. See finding 1.3.
"The session opt-out is keyed by message, so silencing the mild not-growing nag does not silence the serious missing-file warning" Verified. suppressedRecordingHealthMessages.has(message) / .add(message) in the head hunk @@ -775,31 +776,33 @@; the two messages differ, so one does not cover the other.
"New unit test counts dialog mounts: three identical calls mount once, a different message mounts again, the same message with a different variant mounts its own dialog, and mounting resumes after closeDialog" Verified against src/tests/composables/interactionDialog.test.ts:1-35; the four assertions are present in that order.
"yarn lint, yarn typecheck and yarn test:unit clean" Not verifiable here (no PR code is executed). Nothing in the diff reads as a likely violation: the added functions are expressions, which @typescript-eslint/explicit-function-return-type allows via allowExpressions; the test's two import chunks are each sorted for simple-import-sort; and logUserAction is both an ESLint global and a declared TS global.

Failure site — base src/stores/video.ts:823-827 (the unguarded showDialog on the Electron monitor tick) and base src/composables/interactionDialog.ts:139-151 (the unconditional remount). Both are in the diff, and the shared one is fixed rather than only the caller.

Entry points

Function Reached from Frequency
showDialog (head hunk @@ -137,21 +140,38 @@) ~30 call sites across the tree; the ones this PR targets are the setIntervals at src/stores/video.ts:813 (Electron) and :839 (web), through the single instance at :49 per interval tick — every 15 s per active recording — plus per user action from every other caller
resolveFn / rejectFn wrappers (added inside showDialog) the onConfirmed / onDismissed props passed in mountDialog (src/composables/interactionDialog.ts:127-132), emitted at src/components/InteractionDialog.vue:253 and :246/:226 per user action
closeDialog (head, openDialogPromise = undefined added) dialog action buttons via handleAction (src/components/InteractionDialog.vue:251-254), and the internalShowDialog watcher at :242-249 per user action
showRecordingHealthDialog (head hunk @@ -775,31 +776,33 @@) setInterval at src/stores/video.ts:813 and :839, one per recording stream per interval tick — every 15 s per active recording
suppressRecordingHealthDialog(message) (same hunk) the "Don't show again during this session" button per user action
closeRecordingHealthDialog (same hunk) the "Close" button per user action
src/tests/composables/interactionDialog.test.ts body vitest only (default **/*.test.ts include; vite.config.ts:70-73) never in production code (test file, not a finding)

Invariants

Invariant A — openDialogPromise is defined exactly while this instance's dialog is on screen.

Site that takes the dialog off screen Covered?
closeDialog Yes — openDialogPromise = undefined added.
confirm → emit('confirmed')resolveFn Yes — the new resolve wrapper clears it.
dismiss / backdrop / timer → emit('dismissed')rejectFn Yes — the new reject wrapper clears it.
a superseding showDialog with a different key Yes — resolveFn?.(…) runs before openDialogPromise is reassigned, so the clear cannot clobber the new promise.
onUnmounted → unmountDialog (src/composables/interactionDialog.ts:158-160) does not clear it Moot — the closure dies with its owning component.
an action that runs without closing the dialog: handleAction emits 'confirmed' for every button (src/components/InteractionDialog.vue:251-254), so the wrapper would clear the flag while the dialog stayed up Not covered, but not reachable from this PR's callers — both health actions call closeDialog. Noted, not raised.

Invariant B — the key is byte-stable across ticks, or the guard does not fire. Held by every message this PR passes (all three are constant literals). It breaks for any message carrying a changing value, and the guard is in any case only a per-instance, most-recent-dialog one: see 1.3, and note the interaction with 6.2's suggestion to name the stream.

Complexitycomplexity-report.json reports, for head b3c5aeb against base 607f462, triggeredCount: 0 over functionsMeasured: 120 across all 3 changed files, truncated: false, thresholds complexity 12 / bump 5 / depth 4. Nothing to raise, and nothing counted by hand.

1. Correctness & Implementation Bugs — 1 finding

1.3 — The no-remount guard only covers the most recently shown dialog, so the 15 s monitor can still rebuild a dialog under the user (minor)

Head hunk src/composables/interactionDialog.ts @@ -137,21 +140,38 @@, with the callers at src/stores/video.ts:813 and :839 sharing the single composable instance at src/stores/video.ts:49.

The guard is if (openDialogPromise && openDialogKey === key) return openDialogPromise. openDialogKey is overwritten by every showDialog that gets through, so exactly one request is deduplicated at a time: the last one shown. Two consequences follow, both hidden by the PR body's reading that the interval callers "each hold their own" instance — the video store's monitors all use the one at :49:

  1. Two recordings failing at once. startRecording registers one 15 s monitor per stream (recordingMonitors[streamName], :809-859). If stream A's file is missing and stream B's file has stopped growing, the two ticks produce different messages, hence different keys, and each tick remounts over the other's dialog — the exact tear-down-and-rebuild this PR set out to remove, now needing two unhealthy recordings instead of one. Both messages are constant literals today, so identical failures on two streams do collapse into one dialog; it is differing failures that churn.
  2. Any other dialog in between. While a health warning is up, any of the ~16 other showDialog calls in the same store (e.g. :727 "No streams available.", :382 "Stream '…' has changed. Stopping recording…") takes over as openDialogKey. The health monitor's next tick then no longer matches and remounts, replacing the dialog the user is reading. That churn is pre-existing rather than introduced here, which is part of why this is minor.

The reported bug itself is fixed: the "Don't show again during this session" action now gives the user a permanent way out regardless of the interleaving, which is why this is not major.

Consequence: with two failing recordings, or with another pop-up appearing in between, the recording warning still disappears and reappears under the user every 15 seconds.

Fix, at the caller rather than in the composable — one dialog surface cannot show two warnings at once, so the composable cannot settle this: let the recording health warnings share one owner in the store. Keep the currently displayed health message in a store-level let beside suppressedRecordingHealthMessages, set it in showRecordingHealthDialog, clear it in both actions, and skip the call while it holds a different message (a poll retries for free, so nothing is lost) — preferring the missing-file message over the not-growing one when both are pending, since only the first means data loss. Please also correct the PR body's per-instance claim, or the next reader will conclude the interval callers are isolated from each other.

6. UI / UX — 1 finding

6.2 — The rewritten warning, and its log entries, never identify which recording is failing (minor)

Head hunks src/stores/video.ts @@ -821,14 +824,16 @@ and @@ -775,31 +776,33 @@.

  • The message. "Cockpit cannot find the file this recording is being saved to, which means the recording may be lost. We recommend stopping it and starting a new one." Cockpit records several streams at once — activeStreams is keyed by stream name and each recording gets its own monitor (src/stores/video.ts:809-859) — so "this recording" and "stop it" name nothing the user can act on. streamName is in scope in the very same closure, and the sibling message two branches up already uses it: Recording for stream '${streamName}' has stopped… (:816). The base copy was equally anonymous, but this line is being rewritten here, which is the moment to fix it.
  • The log entries. logUserAction('Closed a recording health warning') records neither which warning nor which stream, while its sibling one line up does interpolate the message. The handler is invoked from an action that already has message in scope — it is passed to suppressRecordingHealthDialog the same way — so closeRecordingHealthDialog(message) costs one parameter. Round 1's 6.1 is otherwise closed; this is the remainder.

Consequence: a user recording two streams is told a recording may be lost and that they should stop it, without being told which one — and the support log of that session cannot say either.

Fix: interpolate the stream name into both health messages and into the Close log entry, matching :816. Note the interaction with 1.3: per-stream text makes each stream's key distinct, so this should land together with the caller-side single-owner guard described there, otherwise two failing streams alternate their dialogs every 15 s. Per-stream text also makes the "Don't show again during this session" opt-out per stream, since suppressedRecordingHealthMessages is keyed by the message.

Sections with nothing to report (9)

2. Persistence & User Data — ✅ (the only touched declaration near persisted state is the non-persisted flag at base src/stores/video.ts:74, which became the equally non-persisted Set<string> at head :75; the useBlueOsStorage keys at :71-72 appear as diff context only, and no cockpit-* key is added, reshaped or removed, so the section collapses per the inventory rule)

3. AGENTS.md Adherence — ✅ (no dependency or package.json change; logUserAction is used unimported as the "Logging user interactions" section prescribes and is declared at src/libs/cosmos.ts:66 and .eslintrc.cjs:18-21; the two rewritten comments in the head hunk @@ -775,31 +776,33 @@ sit on code the diff changes, and the second had named the deleted notGrowingDialogOpen, so comment immutability holds; the showDialog JSDoc was extended alongside its code and keeps typed @param/@returns; the new 4-line comment in showDialog is above the one-sentence target but is the "why" the guard exists plus the callback caveat, neither of which is readable off the lines themselves; nothing exported lacks a call site, and the diff is +93/-33 with no new abstraction)

4. Security — ✅ (all sub-checks run: no new dependency, no encoded blob, rg for non-ASCII over pr.diff returns nothing so no hidden or bidirectional characters, no network call, no eval/Function/v-html, no env var or credential, nothing under scripts/, .github/ or src/electron/, no licensing impact; pr.json, pr.diff, new-comments.json and complexity-report.json were each read for text addressed to the reviewer and contain none — the author's follow-up comment is a claim about the code, verified against the diff rather than acted on)

5. Performance — ✅ (the guard removes an unmount/createApp/mount cycle per 15 s tick at src/stores/video.ts:813 and :839 in the common case; the work added per call is one JSON.stringify of a small object, on a per-user-action or 15 s path, never on mavlink:onIncomingMessage, dataLake:setVariable or dataLake:notifyListeners; no timer, listener, subscription or watch is added, so there is nothing new to tear down, and the Set grows by at most one entry per opt-out click)

7. Code Quality & Style — ✅ (complexity-report.json reports triggeredCount: 0 over 120 functions measured across all 3 changed files with truncated: false, so no function crossed the 12 / +5 / depth-4 thresholds; the one-line message at head hunk @@ -821,14 +824,16 @@ is ~164 columns against max-len 180, and the neighbouring three-line wrap is Prettier's printWidth: 120 output for the renamed call rather than hand formatting; no new scoped CSS, no stray any, no re-implemented helper — the suppression is a plain Set rather than a bespoke structure — and the store keeps only wiring while the guard lives in the composable)

8. Commit Hygiene — ✅ (pr.json lists two commits, 21e88e6 composables: … for the composable guard, its test and the removal of the hand-rolled flag, and b3c5aeb video: … for the copy, the opt-out and the logging; both prefixes match this history — git log shows composables: interaction-dialog: reset props between dialogs at b1c3565 and many video: subjects — neither message contains #N or a closing keyword, there is no wip/fixup! noise, no commit undoes the other, and 93 added / 33 removed lines split two ways is well within one sitting)

9. Tests — ✅ (no existing test weakened or removed; the added src/tests/composables/interactionDialog.test.ts sits beside the existing src/tests/libs and src/tests/types suites and is picked up by the default vitest include with environment: 'jsdom' from vite.config.ts:70-73; it asserts mount counts through a stubbed InteractionDialog.vue and promise identity — behaviour, not internals — and calls closeDialog() to restore state before its final assertion, so it is not order-dependent)

10. Documentation — ✅ (no Lite/Standalone parity change to record in README.md: the missing-file branch already ran only behind window.electronAPI at src/stores/video.ts:811 and still does, and the web branch at :839 is unchanged in behaviour; the only public contract touched, showDialog's dedup, has its JSDoc updated in the same hunk)

11. Nitpicks / Optional — ✅ (round 1's console.log nit is gone from the added test; the Close-handler log wording is folded into 6.2 rather than repeated here, and nothing else in the diff is worth a line)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the issue-2950-recording-health-monitor-reopens-the branch from b3c5aeb to e7da178 Compare August 19, 2026 18:18
@rafaellehmkuhl

rafaellehmkuhl commented Aug 19, 2026

Copy link
Copy Markdown
Member Author
Review follow-up — round 2

Done

  • src/stores/video.ts (1.3 — no-remount guard only covers the most recently shown dialog): the health warnings now share one owner in the store. isRecordingHealthDialogOpen sits next to suppressedRecordingHealthMessages, showRecordingHealthDialog skips while it is held, and it is released both by the two actions and by showDialog's promise settling, so an unrelated dialog superseding the warning hands the surface back instead of keeping it for the session.
  • src/stores/video.ts (6.2 — the warning never says which recording is failing): all three health messages interpolate streamName, matching the sibling at :834. The web branch's "number of video chunks" message got it too — same anonymity, one line, and leaving it as the only nameless one would have been odd.
  • src/stores/video.ts (6.2 — the Close log entry): closeRecordingHealthDialog(message) now logs Closed the recording health warning "<message>", matching the opt-out entry.
  • PR body (1.3 — the per-instance claim): corrected. It now says the dedup is per composable instance and only covers the dialog shown last, that the interval callers share the store's single instance, and that arbitrating between different requests is the store's job.
  • Commit message (1.3): the same wrong claim was in composables: interaction-dialog: …'s body ("the interval callers that each hold their own instance"). Reworded in the same rebase.

Done differently

  • src/stores/video.ts (1.3 — "preferring the missing-file message over the not-growing one when both are pending"): the owner is a plain boolean, first warning wins, rather than a stored message compared by severity. A ponytail: comment on the guard names the ceiling — a not-growing warning can hold the surface while a missing-file one waits one 15 s tick — and the upgrade path. Ranking needs a severity order the messages do not carry today, and the cost of not having it is bounded by the poll interval.

Won't change (with reasoning)

  • 1.3, second consequence — any other dialog in between: not addressed, and I read the finding as agreeing it is out of scope ("pre-existing rather than introduced here"). While a health warning is up, any of the store's other showDialog calls still takes the surface; the health monitor now reclaims it on the next tick rather than being stuck, which is the part this PR owns. Fixing the general case means dialog stacking or a priority queue in the composable, which is its own PR.

Notes

  • No test for the store-side guard: it lives inside startRecording, which needs MediaRecorder, a live MediaStream and window.electronAPI to reach. The composable test still covers the dedup half; the store half is in the test plan.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Automated PR Review — round 3 — 📝 MINOR SUGGESTIONS

📝 MINOR SUGGESTIONS — 2 open findings (1 minor, 1 nit), 7 closed (1 this round).

While a video recording runs, Cockpit checks every 15 seconds that the file is still growing and pops a warning up when it is not. The shared pop-up helper now recognises a request for a pop-up that is already on screen and hands the caller the existing one back instead of tearing it down and rebuilding it. On top of that, this round the recording checks take turns on the single pop-up surface: the warning on screen stays put, and the checks for other broken recordings come back on a later tick instead of replacing it. Every warning now names the recording it is about, the "output file is missing" warning goes through the same pop-up with a "don't show again this session" button, and both buttons write a log entry naming the warning the user acted on.

What still needs attention

# Problem What it means Severity Status
1.3 No priority between the two recording warnings If two recordings go wrong at once, a mild "the file is not growing" pop-up left on screen stops Cockpit from ever telling the user that the other recording's file has vanished and may be lost. minor :large_yellow_circle:
11.2 Helpers left nested inside the recording routine Nothing breaks, but the next person changing this code has to read a 325-line function to discover that the "one warning at a time" rule is shared by every recording. nit
Since round 2 — 1 closed, 1 new, 1 partially addressed, comparing b3c5aebe7da178

Rangeb3c5aeb2313c41ea698f77147c4d0457ba197c82e7da17811f1c013aa2bd3e2a0f05b4d83d1f88cf.

incremental.diff is again unusable as a delta: it reproduces the whole base...head content, with per-file totals +25/-5, +48/-29, +35/-0 that match pr.json's files entries exactly, so it is pr.diff under another name rather than the changes since b3c5aeb. That is what a rebase produces when PREV_SHA is no longer an ancestor of the head, and it is consistent with pr.json: both commits now carry committedDate 2026-08-19T18:17:57Z, and the first one's oid moved from 21e88e6 (round 2) to 4254132. Every status below was therefore judged against pr.diff and the base checkout, not against incremental.diff.

No /resolve commands have been issued on this PR (resolutions.json is []), so nothing was closed by a maintainer this round, and there are no unknown ids to report back.

Status of the previously open findings

  • 6.2 — The rewritten warning, and its log entries, never identify which recording is failing (minor) — ✅ Addressed. The finding asked for two things and both landed. All three health messages now interpolate streamName: the missing-file one at head src/stores/video.ts:841-843, the not-growing one at :848-850, and the web build's chunk one at :871-873 — matching the sibling at head :833, and matching how the store's other user-facing dialogs already read (:382, :417). streamName is the external stream name (startRecording is called from startRecordingAllStreams with namesAvailableStreams, which maps to externalId/WebRTC names at :108-113), which is the name AGENTS.md says user-facing UI shows, so this is the right one to print. The Close log entry now names the warning too: logUserAction(\Closed the recording health warning "${message}"`)at head:792, reached through closeRecordingHealthDialog(message)`, so it matches the opt-out entry one function up.
  • 1.3 — The no-remount guard only covers the most recently shown dialog (minor) — :large_yellow_circle: Partially addressed, and reprinted in full below. What round 2 asked for was (i) a store-level owner for the health warnings, (ii) preferring the missing-file message over the not-growing one when both are pending, and (iii) correcting the PR body's per-instance claim. (i) landed — let isRecordingHealthDialogOpen at head :77, the skip at :803, the claim at :804, released from both actions (:788, :793) and from the promise settling (.then(releaseRecordingHealthDialog, releaseRecordingHealthDialog) at :819) — so two streams failing at once no longer remount over each other. (iii) landed in both the PR body and the first commit's body. (ii) did not land, and the ponytail: comment standing in for it (head :801-802) names a ceiling the code does not have; that is what keeps the finding open.

New this round — 11.2 (nit), written out below. It concerns where the new store-side helpers were placed, over the whole of pr.diff.

Discussion since round 2

  • @rafaellehmkuhl posted a follow-up (comment) listing what was done per finding. Every "Done" item was checked against the diff above and holds, including the two claims about text outside the code: the PR body now reads "it covers only the dialog shown last … Callers that share an instance and alternate between different requests still have to arbitrate among themselves", and the first commit body carries the same corrected wording instead of round 2's "the interval callers that each hold their own instance".
  • The one "Done differently" item is the reason 1.3 stays open rather than becoming disputed. The argument offered for the plain boolean over a ranked message is that "the cost of not having it is bounded by the poll interval". The code contradicts that premise: isRecordingHealthDialogOpen is not cleared by a tick, only by the holding dialog being settled — its two action buttons (head :788, :793) or another showDialog on the same instance superseding it (head src/composables/interactionDialog.ts:153). Both health dialogs pass persistent: true and no timer, so nothing settles one on its own. The wait therefore lasts as long as the user leaves the milder dialog up, not one interval. Since that is a checkable fact about the code rather than a judgement call, this is not a decision for a maintainer; see 1.3.
  • The "Won't change" item — round 2's second consequence, an unrelated dialog interposing between two health ticks — is accepted as out of scope, on the same ground the finding itself gave ("pre-existing rather than introduced here"). It is not part of what 1.3 still asks for.
  • The "Notes" item, that the store-side guard has no unit test because it lives inside startRecording, is accurate and is not raised: this review does not ask for tests for logic a PR adds.
  • The other comment on the PR since round 2 is the bare /review that triggered this round, and carries nothing to act on.
Change map — what was established before judging

Claims (from the PR body and the two commit messages — the author's hypothesis, checked against the code)

Claim Verdict
"A recording with a missing output file made the 15 s health monitor call showDialog on every tick" Verified. Base src/stores/video.ts:823-827 calls showDialog and returns without touching the interval registered at :813, whose period is 15000 ms (:836). Nothing guarded it.
"showDialog unmounts and mounts a fresh dialog on each call" Verified. Base showDialog (src/composables/interactionDialog.ts:139-151) always calls mountDialog, which opens with unmountDialog() (:119-122).
"the warning kept popping back with no way to dismiss it for good" Verified. The base missing-file branch passed no actions, and defaultDialogState() sets persistent: true (:97), leaving only the fallback Close at src/components/InteractionDialog.vue:74, which the next tick undid.
"showDialog no longer remounts a dialog that is already on screen … the pending promise is returned" Verified. Head src/composables/interactionDialog.ts:147-149: resolvedOptions / key / if (openDialogPromise && openDialogKey === key) return openDialogPromise, before any mounting; the same promise object is returned at :169.
"useInteractionDialog is a factory, so this dedupes per composable instance, and it covers only the dialog shown last … Callers that share an instance and alternate between different requests still have to arbitrate among themselves" Verified, and this is round 2's contradicted claim now corrected. The store holds one instance (src/stores/video.ts:49) shared by both monitors and ~16 unrelated showDialog calls in the same file.
"It replaces the hand-rolled 'is it already open' flag the sibling not-growing file check carried" Verified in part, and the body says which part. The per-stream notGrowingDialogOpen (base :779) is gone, but a flag remains — now store-wide at head :77, which the body's third bullet states outright rather than glossing.
"the monitors take turns on the one dialog surface … the monitor of every other unhealthy stream comes back 15 s later" Verified for the taking-turns half, overstated for the coming-back half. The guard at head :803 does serialise them, but a later tick only gets the surface once the holding dialog is settled; while it is not, every later tick skips too. See 1.3.
"Every recording health warning names its stream" Verified. Head :841-843, :848-850, :871-873, all three through showRecordingHealthDialog.
"The session opt-out is keyed by message, so silencing the mild not-growing nag does not silence the serious missing-file warning" VerifiedsuppressedRecordingHealthMessages.has(message) / .add(message) at head :797 / :787. Note the messages now carry the stream name, so the opt-out is also per stream: silencing stream A's not-growing nag leaves stream B's reachable. That is defensible (a different recording is a different problem) and the label "during this session" does not promise otherwise, so it is not raised.
"The Close action's log entry now names the warning it closed, matching the opt-out's" Verified — head :792 against :786.
"yarn lint and yarn test:unit clean" Not verifiable here (no PR code is executed). Nothing in the diff reads as a likely violation: the longest added code line is the missing-file message at 175 columns against max-len code: 180 (.eslintrc.cjs:47), the added functions are expressions, which @typescript-eslint/explicit-function-return-type allows via allowExpressions (.eslintrc.cjs:84), and logUserAction is an ESLint global (.eslintrc.cjs:18-21). Walking the added test against the head composable by hand, its four mount-count assertions hold.

Failure site — base src/stores/video.ts:823-827 (the unguarded showDialog on the Electron monitor tick) and base src/composables/interactionDialog.ts:139-151 (the unconditional remount). Both are in the diff, and the shared one is fixed rather than only the caller.

Entry points

Function Reached from Frequency
showDialog (head src/composables/interactionDialog.ts:142-170) ~30 call sites across the tree; the ones this PR targets are the setIntervals at head src/stores/video.ts:830 (Electron) and :858 (web), through the single instance at :49 per interval tick — every 15 s per active recording — plus per user action from every other caller
resolveFn / rejectFn wrappers (head src/composables/interactionDialog.ts:159-166) the onConfirmed / onDismissed props passed in mountDialog (base :127-132), emitted at src/components/InteractionDialog.vue:253 and :246 per user action
closeDialog (head src/composables/interactionDialog.ts:172-176, openDialogPromise = undefined added at :174) the two health actions (head src/stores/video.ts:789, :794); also src/components/InteractionDialog.vue:247, but on that component's own instance, which owns no mounted app per user action
showRecordingHealthDialog (head src/stores/video.ts:796-820) the two setIntervals at head :830 and :858, one per recording stream, via :841, :848, :871 per interval tick — every 15 s per active recording
suppressRecordingHealthDialog (head src/stores/video.ts:785-790) the "Don't show again during this session" button per user action
closeRecordingHealthDialog (head src/stores/video.ts:791-795) the "Close" button per user action
releaseRecordingHealthDialog (head src/stores/video.ts:782-784) both actions above, and the .then(release, release) at head :819 when the dialog's promise settles per user action, or per superseding dialog
src/tests/composables/interactionDialog.test.ts body vitest only (default **/*.test.ts include; vite.config.ts:70-73) never in production code (test file, not a finding)

Invariants

Invariant A — openDialogPromise is defined exactly while this instance's dialog is on screen. Unchanged from round 2 and re-checked: closeDialog clears it (head :174), both settle wrappers clear it (:160, :164), and a superseding showDialog runs resolveFn?.() at :153 before assigning the new promise at :155, so the stale clear cannot clobber the new one. onUnmounted → unmountDialog (base :158-160) does not clear it, which is moot since that closure dies with its owning component.

Invariant B — isRecordingHealthDialogOpen is true exactly while a recording health warning owns the dialog surface. This is the new one this round.

Site that can take the surface away from a health warning Covered?
"Don't show again during this session" → suppressRecordingHealthDialog Yes — releases at head :788, then closeDialog().
"Close" → closeRecordingHealthDialog Yes — releases at head :793, then closeDialog().
any other showDialog on the store's instance (~16 sites, e.g. :382, :727, and warnAboutChunkLoss at base :909/:920) supersedes it Yes — resolveFn?.() settles the health promise, and .then(release, release) at head :819 clears the flag. handleAction emits 'confirmed' for every button (src/components/InteractionDialog.vue:251-254), so an action-driven settle lands here too.
backdrop / Escape / timer dismissal Not reachable — the health dialog passes persistent: true (head :810) and no timer.
a closeDialog() from any other site on the same instance would unmount without settling the promise, stranding the flag true for the session Not covered, and not reachable today: grep shows the store's only closeDialog() calls are the two health actions (head :789, :794). Recorded because a future closeDialog() added anywhere in this store would silently stop all recording health warnings; not raised as a finding, since nothing in the diff does it.
nothing releases it on a tick By design — and the consequence is finding 1.3.

Complexitycomplexity-report.json is not present in this run, so the measurement is unavailable (the reason is not something this run can determine). No complexity findings are raised this round, and nothing was counted by hand.

1. Correctness & Implementation Bugs — 1 finding (carried from round 2)

1.3 — The single-surface owner has no severity order, and the corner-cut comment names a ceiling the code does not have (minor) (carried from round 2, partially addressed)

Head src/stores/video.ts:796-820, with the store-level flag declared at :77 and the three call sites at :841-843, :848-850 and :871-873.

What landed. let isRecordingHealthDialogOpen (head :77) is checked at :803, claimed at :804, and released from both actions (:788, :793) and from the dialog's promise settling (.then(releaseRecordingHealthDialog, releaseRecordingHealthDialog), :819). Round 2's first consequence is gone: two streams whose warnings differ no longer remount over each other every 15 s, and the release-on-supersede path means an unrelated dialog no longer strands the flag the way base notGrowingDialogOpen (base :779) was stranded — the base code's own comment at base :790-791 described exactly that trap. The PR body's per-instance claim is corrected, in the body and in the first commit's message.

What did not. Round 2 also asked for the missing-file message to be preferred over the not-growing one when both are pending, since only the first means data loss. It is a documented corner cut instead — ponytail: first warning wins, so a not-growing one can hold the surface while a missing-file one waits a tick. Rank the messages by severity here if that wait ever proves too long. (head :801-802). Marking the cut is what AGENTS.md asks for, but the ceiling named is not the real one:

  • Nothing releases the flag on a tick. It is released only when the holding dialog is settled: by one of its two buttons, or by another showDialog on the same composable instance superseding it (head src/composables/interactionDialog.ts:153).
  • Both health dialogs are persistent: true (head :810) with no timer, so neither the backdrop, Escape, nor a timeout can settle one. Nothing settles it without the user.
  • So while the operator leaves stream B's mild "the video output file for stream 'B' is not growing" dialog on screen, stream A's showRecordingHealthDialog call is skipped at :803 on every 15 s tick, not delayed by one. The user is told about the mild problem and never told that stream A's file cannot be found and its recording may be lost — the case the ranking clause existed for.

Consequence: with two recordings unhealthy at once, a mild "file is not growing" pop-up left sitting on screen keeps Cockpit from ever telling the user that the other recording's file has disappeared and may be lost.

Fix, either way round:

  • Rank them, as originally suggested: hold the owning message in the store-level let instead of a boolean, and let a missing-file message through while a not-growing one owns the surface. No extra bookkeeping is needed — showDialog already supersedes, and the superseded promise's .then already releases the previous owner.
  • Or keep the corner cut and correct the ponytail: line to name the real ceiling: the wait lasts as long as the user leaves the milder dialog up, and can outlive the recording.

Round 2's second consequence — an unrelated dialog interposing between two ticks — is not part of what remains here; it is accepted as out of scope for the reason the finding itself gave. Severity stays minor: it takes two simultaneously unhealthy recordings, and the reported bug is fixed regardless, since "Don't show again during this session" now gives the user a permanent way out.

11. Nitpicks / Optional — 1 finding

11.2 — The four health-dialog helpers stay nested in startRecording, though nothing per-stream is left in them (nit)

Head src/stores/video.ts:781-820, inside startRecording (base :722-1046, ~325 lines).

  • Placement. releaseRecordingHealthDialog, suppressRecordingHealthDialog, closeRecordingHealthDialog and showRecordingHealthDialog are rebuilt on every startRecording call, one set per active recording, and now close over nothing per-stream: the suppression set (head :75) and the ownership flag (head :77) are both store-level, and the message arrives as a parameter. In the base they had a reason to sit there — notGrowingDialogOpen was genuinely per-stream (base :779). Hoisting the four next to the two declarations they now use would take ~40 lines out of a 325-line function and put the "one warning at a time across all streams" behaviour where its state is declared; the three call sites keep showRecordingHealthDialog(message) unchanged.
  • The reason for skipping is given twice. Head :798-800 ("the monitor of every other unhealthy stream comes back in 15 seconds, so the ones that skip here lose nothing by waiting their turn") and :801-802 ("a not-growing one can hold the surface while a missing-file one waits a tick") state the same wait-your-turn reasoning in adjacent lines, against AGENTS.md's "Avoid repeated comments; describe reasoning once only". Keeping the ponytail: line — corrected per 1.3 — and dropping the overlap from the first would leave each fact stated once.

Consequence: nothing breaks; the next person to touch recording health has to read a 325-line function to discover that the "one warning at a time" rule is shared by every recording rather than scoped to one.

Sections with nothing to report (9)

2. Persistence & User Data — ✅ (nothing persisted is added, reshaped or removed: the two touched declarations at head src/stores/video.ts:75 and :77 are an in-memory Set and a plain let, replacing the equally non-persisted ref at base :74; the useBlueOsStorage keys at :71-72 appear as diff context only, and no cockpit-* key is created or migrated, so the section collapses per the inventory rule)

3. AGENTS.md Adherence — ✅ (no dependency or package.json change; logUserAction is called unimported as the "Logging user interactions" section prescribes, declared at src/libs/cosmos.ts and .eslintrc.cjs:18-21, and both entries read in the past tense naming their target; the deliberate corner cut carries a ponytail: comment — its wording is 1.3, its presence is correct; the rewritten comments at head :781 and :808-809 sit on code the diff changes, and the one they replace had named the deleted notGrowingDialogOpen, so comment immutability holds; showDialog's JSDoc was extended alongside its code with typed @param/@returns intact; nothing exported lacks a call site, and the whole diff is +108/-34 across 3 files with no new abstraction)

4. Security — ✅ (all sub-checks run: no new dependency, no encoded blob, grep -P "[^\x00-\x7F]" over pr.diff returns nothing so no hidden, zero-width or bidirectional characters, no network call, no eval/Function/v-html, no env var or credential, nothing under scripts/, .github/ or src/electron/, no licensing impact; pr.json, pr.diff, incremental.diff and new-comments.json were each read for text addressed to the reviewer and contain none — the author's follow-up comment is a set of claims about the code, each verified against the diff rather than acted on, and one of them was found contradicted)

5. Performance — ✅ (the composable guard still removes an unmount/createApp/mount cycle per 15 s tick at head :830 and :858, and the store guard now removes the whole showDialog call in the contended case; the work added per call is one JSON.stringify of a small object plus one .then microtask, on a 15 s or per-user-action path, never on mavlink:onIncomingMessage, dataLake:setVariable or dataLake:notifyListeners; no timer, listener, subscription or watch is added, so there is nothing new to tear down, and suppressedRecordingHealthMessages grows by at most one entry per opt-out click)

6. UI / UX — ✅ (round 2's 6.2 is closed above — all three messages and both log entries now name the recording; the dialog is the shared useInteractionDialog shell, so the close-X clause does not apply, and its two actions go through the shell's actions prop, which is exempt from the per-action variant and fill clauses; both actions are logged; the copy carries no protocol jargon and says what to do ("stopping it and starting a new one"); the re-open loop the guidelines' dialog-spam clause targets is what this PR removes; no v-select-family control, z-index, glass layer, icon-only control or padding is touched)

7. Code Quality & Style — ✅ (complexity-report.json is absent this round, so the measurement is unavailable and no complexity finding is raised — nothing was counted by hand; the longest added code line, the missing-file message at head :842, is 175 columns against max-len code: 180 (.eslintrc.cjs:47), and the three-line call wraps are Prettier's printWidth: 120 output for the renamed callee rather than hand formatting; .then(onFulfilled, onRejected) at head :819 handles the reject path, so the added await-less call cannot produce an unhandled rejection; no new scoped CSS, no stray any, no re-implemented helper — plain Set and JSON.stringify — and src/stores/video.ts is 1387 lines with +19 net, well under the ~2000-line growth rule; the two near-identical action handlers at head :785-795 differ by their log text and the add, which is thinner than a shared helper with a boolean parameter would be; placement of the four helpers is the nit at 11.2)

8. Commit Hygiene — ✅ (pr.json lists two commits, 4254132 composables: interaction-dialog: don't remount a dialog that is already open and e7da178 video: route the missing recording file warning through the health dialog — the same split round 2's 8.1 asked for, preserved through the rebase; both prefixes have precedent in this history, composables: interaction-dialog: reset props between dialogs at b1c3565 and many video: subjects, and each describes its own change; neither message contains #N, owner/repo#N or a closing keyword — Closes #2950 is in the PR body where it belongs; no wip/fix lint/fixup! noise, neither commit undoes or reimplements the other, and 108 added / 34 removed lines split two ways is reviewable in one sitting)

9. Tests — ✅ (no existing test weakened or removed; src/tests/composables/interactionDialog.test.ts is unchanged since round 2 and still walks through correctly against the head composable — three identical calls mount once and return one promise object, a different message mounts again and settles the first with { isConfirmed: false } via the resolve wrapper at head :159-162, a same-message/different-variant call gets its own dialog because variant is inside the key at head :148, and after closeDialog() clears openDialogPromise at head :174 the same options mount a fourth time; it asserts through a stubbed InteractionDialog.vue rather than composable internals, and is not order-dependent)

10. Documentation — ✅ (no Lite/Standalone parity change to record in README.md: the missing-file branch already ran only behind window.electronAPI at head :828 and still does, and the web branch at head :858 gains only the stream name in its message; the only public contract touched, showDialog's dedup, has its JSDoc updated in the same hunk)

11. Nitpicks / Optional — see the block above; 11.2 is the only entry, and round 2's console.log nit stays closed.

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the issue-2950-recording-health-monitor-reopens-the branch from e7da178 to 40066fa Compare August 19, 2026 19:58
@rafaellehmkuhl

rafaellehmkuhl commented Aug 19, 2026

Copy link
Copy Markdown
Member Author
Review follow-up — round 3

Done

  • src/stores/video.ts (1.3 — the single-surface owner has no severity order, and the ponytail: line names a ceiling the code does not have): took the first branch of the fix. The store-level let now holds the owning warning — { message, meansDataLoss } instead of a boolean — and showRecordingHealthDialog(message, meansDataLoss) lets a data-loss warning through while a milder one owns the surface. Only the missing-file call site passes true. showDialog already supersedes, and the superseded promise's .then already releases, so the release had to become "release only if you still own it" (releaseRecordingHealthDialog(message) compares against the current owner) — otherwise the superseded warning's .then would clear the flag the new owner had just set, and the two would ping-pong every 15 s.
  • src/stores/video.ts (1.3 — the ponytail: line): rewritten to name the real ceiling. What is left is warnings of the same weight queueing behind whichever showed first, with the wait lasting as long as the user leaves that dialog up, not one tick.
  • src/stores/video.ts (11.2 — helpers nested in startRecording): the four are hoisted next to suppressedRecordingHealthMessages and the ownership let, so ~40 lines leave the 325-line function and the "one warning at a time across all streams" behaviour sits where its state is declared. The three call sites are unchanged apart from the missing-file one passing true.
  • src/stores/video.ts (11.2 — the reason for skipping given twice): the two overlapping comments are now one, plus the corrected ponytail: line.
  • Commit message (1.3): the last paragraph of video: route the missing recording file warning through the health dialog carried the same wrong claim ("the monitors of the other unhealthy streams come back 15 seconds later"). Reworded in the same rebase.
  • PR body (1.3): the third bullet said the same thing, and now says the wait lasts as long as the user leaves the dialog up, which is why the data-loss warning takes the surface.

Notes

  • Still no unit test for the store-side guard: the helpers are store-level now, but they are internals of the defineStore setup, and exporting them only to test them would put an API on the store with no production caller. The composable test still covers the dedup half; the new priority behaviour is a test-plan item.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Automated PR Review — round 4⚠️ IMPORTANT FIXES REQUIRED

⚠️ IMPORTANT FIXES REQUIRED — 1 open finding (1 major), 9 closed (2 this round).

While a video recording runs, Cockpit checks every 15 seconds that the file is still growing and warns the user when it is not. The shared pop-up helper now recognises a request for a pop-up that is already on screen and hands back the existing one instead of tearing it down and rebuilding it every tick. This round the recording checks also gained a priority order: the checks for all recordings share the one pop-up surface, a warning stays put while it is up, and a warning that the recording may already be lost now pushes aside a milder "the file is not growing" one instead of waiting behind it forever. The four helper routines that run this were moved out of the long start-recording routine to sit next to the state they use. The one problem left is cosmetic-but-forbidden: the new state declaration carries two empty documentation blocks.

What still needs attention

# Problem What it means Severity Status
3.1 Two empty documentation blocks added Two explanation notes were added to the code with nothing written inside them, so the next person reading this file finds a blank where the description should be. major
Since round 3 — 2 closed, 1 new, comparing e7da17840066fa

Rangee7da17811f1c013aa2bd3e2a0f05b4d83d1f88cf40066fa4c4c1457b1c3b4437998bc2face2352ec.

incremental.diff is again not usable as this round's delta, though it is closer than round 3's. It reports one file (src/stores/video.ts, +65/-27), but its removed side matches neither of the two revisions it should: it does not match the base checkout, which still declares let notGrowingDialogOpen at src/stores/video.ts:779, and it does not match what round 3 recorded at e7da178, which had the store-level let isRecordingHealthDialogOpen at head :77. What it removes is suppressNotGrowingDialogs.value plus a flag-less showNotGrowingDialog — the state round 3 described at round 2's head. So the range it spans covers round 3's own work as well as this round's, which would credit changes that were already reviewed. Every status below was therefore judged against pr.diff and the base checkout, as in round 3.

No /resolve commands have been issued on this PR (resolutions.json is []), so nothing was closed by a maintainer this round, and there are no unknown ids to report back.

Status of the previously open findings

  • 1.3 — The single-surface owner has no severity order, and the corner-cut comment names a ceiling the code does not have (minor) — ✅ Addressed. The finding offered two routes and the first one landed whole. (a) The store-level let is now the owning warning rather than a boolean: openRecordingHealthWarning: { message, meansDataLoss } | undefined at head src/stores/video.ts:77-88. (b) The ranking is at head :111if (openRecordingHealthWarning && (openRecordingHealthWarning.meansDataLoss || !meansDataLoss)) return — so a milder warning yields the surface to a data-loss one, while data-loss holds against everything. Only the missing-file call site passes true (head :854); the not-growing (:860-862) and web-chunk (:883-885) sites use the false default. (c) The ponytail: line at head :109-110 now names the real ceiling ("a second unhealthy stream can wait as long as the user leaves the first dialog up") instead of round 3's "waits a tick". The release also had to become owner-checked, and it is: releaseRecordingHealthDialog compares against the current owner at head :91, and the new owner is assigned at :112 before showDialog runs at :114, so the superseded warning's .then(release, release) (:128) fires a microtask later, finds a different owner, and no-ops rather than clearing the warning that just took the surface.
  • 11.2 — The four health-dialog helpers stay nested in startRecording (nit) — ✅ Addressed. Both parts landed. The four helpers are now store-level at head :90-129, directly under the two declarations they use (:75, :77), so they are built once instead of once per active recording; startRecording drops from 325 lines (base :722-1046) to ~300 (head :777-1077). The doubled reasoning is gone too: head :106-108 states the ownership-and-preemption rule once and :109-110 states the equal-weight queueing ceiling, which are different facts rather than the overlap round 3 quoted.

New this round — 3.1 (major), written out below. It arrived with the object type that replaced the boolean, and the author's follow-up does not mention it.

Discussion since round 3

  • @rafaellehmkuhl's follow-up (comment) lists six items; each was checked against pr.diff rather than taken as evidence, and all six hold. The two claims about text outside the code check out as well: the second commit's body in pr.json now ends "one that means the recording may already be lost takes the surface from a milder one rather than waiting behind it for as long as the user leaves it up", and the PR body's third bullet carries the same corrected wording.
  • The comment's account of why the release had to become owner-checked ("otherwise the superseded warning's .then would clear the flag the new owner had just set, and the two would ping-pong every 15 s") is the one claim worth re-deriving, since the whole priority feature depends on it. It is correct, for the ordering reason given under 1.3 above.
  • The Notes item — no unit test for the store-side guard, because the helpers are defineStore internals — is accurate and is not raised: this review does not ask for tests for logic a PR adds. It does mean the new priority rule is covered only by the manual test-plan items.
  • The other comment since round 3 is the bare /review that triggered this round, and carries nothing to act on.
Change map — what was established before judging

Claims (from the PR body and the two commit messages — the author's hypothesis, checked against the code)

Claim Verdict
"A recording with a missing output file made the 15 s health monitor call showDialog on every tick" Verified. Base src/stores/video.ts:823-827 calls showDialog and returns without touching the interval registered at base :813, whose period is 15000 ms (base :836). Nothing guarded it.
"showDialog unmounts and mounts a fresh dialog on each call" Verified. Base showDialog (src/composables/interactionDialog.ts:139-151) always calls mountDialog, which opens with unmountDialog() (base :119-122).
"the warning kept popping back with no way to dismiss it for good" Verified. The base missing-file branch passed no actions, and defaultDialogState() sets persistent: true (base :97), leaving only the fallback Close at src/components/InteractionDialog.vue:74, which the next tick undid.
"showDialog no longer remounts a dialog that is already on screen … the pending promise is returned" Verified. Head src/composables/interactionDialog.ts:147-149: resolvedOptions / key / if (openDialogPromise && openDialogKey === key) return openDialogPromise, before any mounting; the same promise object is returned at head :169. Unchanged this round.
"useInteractionDialog is a factory, so this dedupes per composable instance, and it covers only the dialog shown last" Verified. The store holds one instance (src/stores/video.ts:49), shared by both monitors and ~16 unrelated showDialog calls in the same file (:382, :417, :727, :1081, …).
"It replaces the hand-rolled 'is it already open' flag the sibling not-growing file check carried" Verified in part, and the body says which part. The per-stream notGrowingDialogOpen (base :779) is gone; what replaces it is store-wide and now richer than a flag (head :77-88), which the body's third bullet states outright.
"the store tracks which warning owns the surface … a warning about a recording that may already be lost therefore takes the surface from a milder one instead of queueing behind it" Verified, and this is round 3's overstated claim now correct. Head :111 yields the surface only when the incoming warning is not a data-loss one, or the owner already is; :112 claims it; release is owner-checked at :91.
"the monitors of the other unhealthy streams retry on later ticks … the wait is as long as the user leaves it up" Verified, including the limitation. Two warnings of equal weight still queue indefinitely behind whichever showed first, which is exactly what the ponytail: line at head :109-110 now says.
"Every recording health warning names its stream" Verified. Head :853, :861, :884, all three through showRecordingHealthDialog.
"The session opt-out is keyed by message, so silencing the mild not-growing nag does not silence the serious missing-file warning" VerifiedsuppressedRecordingHealthMessages.has(message) at head :105, .add(message) at :95. Since the messages carry the stream name, the opt-out is per stream as well; "during this session" does not promise otherwise, so it is not raised.
"The Close action's log entry now names the warning it closed, matching the opt-out's" Verified — head :100 against :94.
"yarn lint and yarn test:unit clean" Plausible, and that is the problem in 3.1. No PR code is executed here. The longest added code line is the missing-file message at head :853, between 170 and 176 columns against max-len code: 180 (.eslintrc.cjs:47); the added functions are arrow expressions, which @typescript-eslint/explicit-function-return-type allows via allowExpressions (.eslintrc.cjs:84), and each declares : void anyway; logUserAction is an ESLint global (.eslintrc.cjs:18-21). The two empty JSDoc blocks satisfy jsdoc/require-jsdoc, which only requires that a block exist, so a clean lint run is consistent with them being there — see 3.1.

Failure site — base src/stores/video.ts:823-827 (the unguarded showDialog on the Electron monitor tick) and base src/composables/interactionDialog.ts:139-151 (the unconditional remount). Both are in the diff, and the shared one is fixed rather than only the caller.

Entry points

Function Reached from Frequency
showDialog (head src/composables/interactionDialog.ts:142-170) ~30 call sites across the tree; the ones this PR targets are the setIntervals at head src/stores/video.ts:841 (Electron, period :867) and :870 (web, period :890), through the single instance at :49 per interval tick — every 15 s per active recording — plus per user action from every other caller
resolveFn / rejectFn wrappers (head src/composables/interactionDialog.ts:159-166) the onConfirmed / onDismissed props passed in mountDialog, emitted at src/components/InteractionDialog.vue:253 and :246 per user action
closeDialog (head src/composables/interactionDialog.ts:172-176) the two health actions (head src/stores/video.ts:97, :102) — grep confirms they are still the store's only closeDialog() calls; also src/components/InteractionDialog.vue:247, on that component's own instance, which owns no mounted app per user action
showRecordingHealthDialog (head src/stores/video.ts:104-129) the two setIntervals above, via :852, :860, :883; one monitor per recording stream per interval tick — every 15 s per active recording
releaseRecordingHealthDialog (head src/stores/video.ts:90-92) both dialog actions, and the .then(release, release) at head :128 when the dialog's promise settles per user action, or per superseding dialog
suppressRecordingHealthDialog (head src/stores/video.ts:93-98) the "Don't show again during this session" button per user action
closeRecordingHealthDialog (head src/stores/video.ts:99-103) the "Close" button per user action
src/tests/composables/interactionDialog.test.ts body vitest only (default **/*.test.ts include; vite.config.ts:70-73); unchanged since round 2 never in production code (test file, not a finding)

Invariants

Invariant A — openDialogPromise is defined exactly while this instance's dialog is on screen. Unchanged since round 2 and re-checked: closeDialog clears it (head interactionDialog.ts:174), both settle wrappers clear it (:160, :164), and a superseding showDialog runs resolveFn?.() at :153 before assigning the new promise at :155, so the stale clear cannot clobber the new one.

Invariant B — openRecordingHealthWarning names the warning currently holding the dialog surface, or is undefined. This replaces round 3's boolean, and the release is now conditional, so the enumeration has one new column: whether the release can clear the wrong owner.

Site that can take the surface away from a health warning Covered?
"Don't show again during this session" → suppressRecordingHealthDialog Yes — releases at head :96 (owner-checked), then closeDialog().
"Close" → closeRecordingHealthDialog Yes — releases at head :101, then closeDialog().
a data-loss warning preempting a milder one (head :111) Yes, and this is the new path. The new owner is set at :112 before showDialog at :114; the superseded warning's .then at :128 runs a microtask later and its owner check at :91 fails, so it cannot clear the new owner. The preempted stream's monitor is skipped on later ticks until the data-loss dialog is settled.
any other showDialog on the store's instance (~16 sites, e.g. :382, :727, and the "recording has stopped" warnings at head :845 / :874) supersedes it Yes — resolveFn?.() settles the health promise and .then(release, release) at head :128 clears the flag, since that warning is still the recorded owner. handleAction emits 'confirmed' for every button (src/components/InteractionDialog.vue:251-254), so an action-driven settle lands here too.
backdrop / Escape / timer dismissal Not reachable — the health dialog passes persistent: true (head :119) and no timer.
a closeDialog() from any other site on the same instance would unmount without settling the promise, stranding the owner for the session Not covered, not reachable today (the store has no other closeDialog() call). Recorded, not raised, since nothing in the diff does it.
nothing releases it on a tick By design, and now documented honestly at head :109-110 — which is what closes 1.3.

Complexitycomplexity-report.json is present this round and its head matches HEAD_SHA. Its own figures: changedFiles: 3, functionsMeasured: 123, triggeredCount: 0, truncated: false, triggered: [], thresholds complexity 12 / bump 5 / depth 4. So by that report no function the diff adds or changes trips a complexity or nesting trigger, and no complexity finding is raised; nothing was counted by hand. The numbers are quoted as the report's, not as something this run measured — it also names base 607f462, where this run's checkout tip is ce3a8d4.

3. AGENTS.md Adherence — 1 finding

3.1 — The new ownership state declares two members with empty JSDoc blocks (major)

Head src/stores/video.ts:77-88:

let openRecordingHealthWarning:
  | {
      /**
       *
       */
      message: string
      /**
       *
       */
      meansDataLoss: boolean
    }
  | undefined

Both blocks are empty. AGENTS.md is explicit: "Never write a JSDoc whose summary line is empty, whitespace-only, or filler … If you have nothing useful to say, omit the block entirely instead of leaving it blank", and "Make sure none of the JSDocs entries you added are empty". This is also the case AGENTS.md warns about two sections earlier — after running yarn lint:fix, "check whether they auto-fixed (modified) any files … paying special attention to the JSDoc rules (no blank/filler blocks …), since auto-fixes can introduce or reshape JSDoc blocks that then violate them". The shape above is exactly what jsdoc/require-jsdoc's fixer emits.

Note that deleting the two blocks is not the fix: .eslintrc.cjs:39 puts TSPropertySignature in that rule's contexts, so the members of this inline object type require a block, and removing them turns a silent AGENTS.md breach into a lint error. Equally, the empty blocks satisfy the rule as written — it checks that a block exists, not that it says anything — which is why yarn lint can be clean with them in place and why this slipped through. The rule being breached is the project's, not ESLint's.

Two ways out, both small:

  • Say something in them. One line each is enough, and both facts are non-obvious enough to be worth stating: message is what releaseRecordingHealthDialog (head :91) compares against, so only the warning that still owns the surface can release it; meansDataLoss is what lets this warning take the surface from a milder one at head :111.
  • Or lift the shape into a named, documented type near the store's other types and leave the let as OpenRecordingHealthWarning | undefined. src/types/video.ts:102-129 (CommonVideoInfo) is the in-tree pattern to copy — every member carries a real one-line description.

Consequence: two documentation blocks were added with nothing written in them, so anyone reading this store (or any generated documentation for it) gets a blank label where the explanation belongs, against a rule AGENTS.md states twice.

Scope note: the same empty-block shape already exists in 13 files under src/ (e.g. src/types/video.ts:99-101, src/types/general.ts), and cleaning those up is not this PR's job. What is asked here is only that the blocks this PR adds not join them.

Sections with nothing to report (10)

1. Correctness & Implementation Bugs — ✅ (the new priority guard at head src/stores/video.ts:111 was walked in both orders: a data-loss warning preempts a milder owner, and the superseded warning's .then(release, release) at :128 runs a microtask after the new owner is assigned at :112, so the owner check at :91 no-ops instead of clearing it — the ping-pong the author's comment describes cannot occur; a repeat request from the owner itself is stopped at :111 before it reaches the composable's own dedup; the three call sites :852, :860, :883 and both monitors :841 / :870 were re-read, the missing-file branch still sits behind the existing window.electronAPI? call at :850, no telemetry is read from a store, and no widget Options object is touched)

2. Persistence & User Data — ✅ (nothing persisted is added, reshaped or removed: the declarations at head :75 and :77-88 are an in-memory Set and a plain let, replacing the equally non-persisted ref at base :74; the useBlueOsStorage keys at :71-72 and useStorage('cockpit-unprocessed-video-info') at :66 appear as diff context only, and no cockpit-* key is created, reshaped or migrated, so the section collapses per the inventory rule)

3. AGENTS.md Adherence — see the block above; 3.1 is the only entry. Everything else in the section checked clean: no dependency or package.json change, logUserAction called unimported with both entries in the past tense naming their target (head :94, :100), the deliberate corner cut marked with a ponytail: comment naming a ceiling that now matches the code (:109-110), the rewritten comments sitting on code the diff changes, showDialog's JSDoc extended alongside its code with typed @param/@returns intact, nothing exported without a call site, and the whole diff +125/-39 across 3 files with no new abstraction.

4. Security — ✅ (all sub-checks run: no new dependency, no encoded blob, grep -P "[^\x00-\x7F]" over pr.diff returns nothing so no hidden, zero-width or bidirectional characters, no network call, no eval/Function/v-html, no env var or credential, nothing under scripts/, .github/ or src/electron/, no licensing impact; pr.json, pr.diff, incremental.diff, complexity-report.json and new-comments.json were each read for text addressed to the reviewer and contain none — the author's follow-up is a set of claims about the code, each verified against the diff rather than acted on, and the complexity figures are quoted as the report's own)

5. Performance — ✅ (the composable guard still removes an unmount/createApp/mount cycle per 15 s tick at head :841 and :870, and the store guard removes the whole showDialog call in the contended case; the new priority path adds at most one extra mount, and only when a second recording starts losing data; the work added per call is one JSON.stringify of a small object plus one .then microtask, on a 15 s or per-user-action path, never on mavlink:onIncomingMessage, dataLake:setVariable or dataLake:notifyListeners; hoisting the four helpers to store scope means they are built once instead of once per recording; no timer, listener, subscription or watch is added, so there is nothing new to tear down, and suppressedRecordingHealthMessages grows by at most one entry per opt-out click)

6. UI / UX — ✅ (the dialog is the shared useInteractionDialog shell, so the close-X clause does not apply, and its two actions go through the shell's actions prop, which is exempt from the per-action variant and fill clauses; both actions are logged via logUserAction; all three messages name their stream and the missing-file one says what to do ("stopping it and starting a new one") without protocol jargon; the dialog-spam pattern the guidelines target is what this PR removes, and the new preemption at head :111 can replace a dialog only when a different, more serious problem appears, not on a timer; no v-select-family control, z-index, glass layer, icon-only control, footer or padding is touched)

7. Code Quality & Style — ✅ (complexity-report.json reports triggeredCount: 0 over 123 measured functions with truncated: false, so no function the diff adds or changes trips the complexity or depth thresholds and nothing was counted by hand; the longest added code line, head :853, measures between 170 and 176 columns against max-len code: 180; .then(onFulfilled, onRejected) at head :128 covers the reject path so the await-less call cannot produce an unhandled rejection; no new scoped CSS, no stray any, no re-implemented helper — plain Set, JSON.stringify and an object literal; src/stores/video.ts is 1387 lines at base with +31 net, well under the ~2000-line growth rule, and startRecording shrinks by ~24 lines this round; the two near-identical action handlers at head :93-103 differ by their log text and the add, which is thinner than a shared helper with a boolean parameter; the JSDoc content problem is 3.1 above rather than a style point)

8. Commit Hygiene — ✅ (pr.json lists the same two commits as round 3, 4254132 composables: interaction-dialog: don't remount a dialog that is already open and 40066fa video: route the missing recording file warning through the health dialog, the second amended in place — the split round 2's 8.1 asked for is preserved, and no "address review" or fixup! commit was added to carry this round's changes; both prefixes have precedent in this history and each describes its own change; the second commit's body was reworded in the same rebase to drop the claim round 3 found contradicted; neither message contains #N, owner/repo#N or a closing keyword — Closes #2950 is in the PR body where it belongs; the second commit's several effects — routing the missing-file branch, naming streams, and the arbitration that naming them forces — are one causal chain its body spells out, and at 125 added / 39 removed lines across two commits the PR is reviewable in one sitting)

9. Tests — ✅ (no existing test weakened or removed; src/tests/composables/interactionDialog.test.ts is unchanged since round 2 and the composable it exercises is unchanged this round, so its four mount-count assertions still walk through correctly against head interactionDialog.ts:142-176 — three identical calls mount once and return one promise object, a different message mounts again and settles the first with { isConfirmed: false }, a same-message/different-variant call gets its own dialog because variant is inside the key at head :148, and after closeDialog() clears openDialogPromise at head :174 the same options mount a fourth time; it asserts through a stubbed InteractionDialog.vue rather than composable internals, and is not order-dependent)

10. Documentation — ✅ (no Lite/Standalone parity change to record in README.md: the missing-file branch already ran only behind window.electronAPI at head :850 and still does, and the web branch at head :870 gains only the stream name in its message; the only public contract touched, showDialog's dedup, has its JSDoc updated in the same hunk, unchanged this round)

11. Nitpicks / Optional — ✅ (round 3's 11.2 is closed above, and this round's additions raised no new taste-level point: the four hoisted helpers now sit next to the state they use, the ponytail: line reads as the ceiling it names, and the log strings match the established voice)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the issue-2950-recording-health-monitor-reopens-the branch from 40066fa to fbd47b2 Compare August 19, 2026 20:12
@rafaellehmkuhl

rafaellehmkuhl commented Aug 19, 2026

Copy link
Copy Markdown
Member Author
Review follow-up — round 4

Done

  • src/stores/video.ts (3.1 — the new ownership state declares two members with empty JSDoc blocks): both blocks now say something. message states that the release compares against it, so a warning that was already superseded cannot release the one that took its place; meansDataLoss states that it is what lets the warning take the surface from a milder one. Took the first of the two routes offered: the shape stays inline, since it is a defineStore internal with one declaration site and no consumer outside these four helpers, so a named type in src/types/video.ts would be a public video type for something nothing outside the store can hold. Confirmed the blocks were the jsdoc/require-jsdoc fixer's output — re-running yarn lint:fix after writing them changes nothing.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown

Automated PR Review — round 5

Note

📝 MINOR SUGGESTIONS — 1 open finding (1 minor), 10 closed (1 this round).

While a video recording runs, Cockpit checks every 15 seconds that the file is still growing and warns the user when it is not. The shared pop-up helper now recognises a request for a pop-up that is already on screen and hands back the existing one instead of tearing it down and rebuilding it every tick, and the recording checks take turns on the single pop-up surface: the warning on screen stays put, and one saying the recording may already be lost pushes aside a milder "the file is not growing" one. Each warning names the recording it is about and can be silenced for the session on its own. This round filled in the two blank documentation notes that were the only thing left open. What remains is a narrow ordering problem: a warning the user closed leaves a loose end that, 15 seconds later, can hand the surface to another recording's warning.

What still needs attention

# Problem What it means Severity Status
1.4 Closed warning releases the surface it no longer holds When two recordings are failing at once, closing a warning can let the other recording's less urgent warning push the more urgent one off the screen for a cycle. minor
Since round 4 — 1 closed, 1 new, comparing 40066fafbd47b2

Range40066fa4c4c1457b1c3b4437998bc2face2352ecfbd47b239a5c3cc13f303981570f43e6c9f475ba.

incremental.diff is again not usable as this round's delta. It reports one file (src/stores/video.ts, +66/-27), and its added side does match the current head (the two JSDoc blocks arrive filled), but its removed side matches neither revision it should: not the base checkout, which still declares let notGrowingDialogOpen and closeNotGrowingDialog at src/stores/video.ts:779-783, and not what round 4 recorded at 40066fa, which already had openRecordingHealthWarning and the four store-level helpers. What it removes is suppressNotGrowingDialogs.value plus a flag-less showNotGrowingDialog still nested in startRecording — round 2's state. PREV_SHA is also absent from the two commits in pr.json, so the previous head was amended away rather than built on. Every status below was therefore judged against pr.diff and the base checkout, as in rounds 3 and 4.

No /resolve commands have been issued on this PR (resolutions.json is []), so nothing was closed by a maintainer this round, and there are no unknown ids to report back.

Status of the previously open finding

  • 3.1 — The new ownership state declares two members with empty JSDoc blocks (major) — ✅ Addressed. The finding asked that the two added blocks not be empty, and offered filling them or lifting the shape into a named documented type. Route one landed whole. Head src/stores/video.ts:79-82 now reads "Text of the warning on screen. Releasing the surface compares against it, so a warning that was already superseded can't release the one that took its place", and :84-86 "Whether the warning means the recording may already be lost, which lets it take the surface from a milder one". Both are real one-line descriptions of the two non-obvious facts the finding named (the release comparison at head :92, the preemption at head :112), neither is filler, and nothing else in the diff gained a /** */ block. No JSDoc block elsewhere in the diff was emptied in exchange.

New this round — 1.4 (minor), written out below. It is not new code: it comes from re-reading the whole of pr.diff, and it corrects a claim in round 4's own invariant table (see the finding).

Discussion since round 4

  • @rafaellehmkuhl's follow-up (comment) makes three claims. The first two — that both blocks now say something, and specifically what each says — were checked against pr.diff rather than taken as evidence, and both hold (head :79-82, :84-86). The reasoning for taking the inline route rather than a named type in src/types/video.ts is sound on its own terms — the shape is a defineStore internal with one declaration site and no consumer outside the four helpers — and in any case the finding offered both routes, so either closes it.
  • The third claim, that re-running yarn lint:fix leaves the blocks untouched, cannot be verified here: no PR code is executed in this review and the checkout has no node_modules. Nothing in the status above rests on it — the blocks are non-empty as text.
  • The other comment since round 4 is the bare /review that triggered this round, and carries nothing to act on.
Change map — what was established before judging

Claims (from the PR body and the two commit messages — the author's hypothesis, checked against the code)

Claim Verdict
"A recording with a missing output file made the 15 s health monitor call showDialog on every tick" Verified. Base src/stores/video.ts:823-827 calls showDialog and returns without touching the interval registered at base :813, whose period is 15000 ms (base :836). Nothing guarded it.
"showDialog unmounts and mounts a fresh dialog on each call" Verified. Base showDialog (src/composables/interactionDialog.ts:139-151) always calls mountDialog, which opens with unmountDialog() (base :119-122).
"the warning kept popping back with no way to dismiss it for good" Verified. The base missing-file branch passed no actions, and defaultDialogState() sets persistent: true (base :97), leaving only the fallback Close at src/components/InteractionDialog.vue:74, which the next tick undid.
"showDialog no longer remounts a dialog that is already on screen … the pending promise is returned" Verified. Head src/composables/interactionDialog.ts:147-149 builds resolvedOptions, keys them, and returns openDialogPromise before anything mounts; the same promise object is returned at head :169. Unchanged since round 2.
"action callbacks drop out of the JSON so freshly built ones don't defeat the comparison" Verified. The key is JSON.stringify(resolvedOptions) at head :148, and JSON.stringify drops function-valued properties, so the action: () => … closures rebuilt on every tick (head src/stores/video.ts:125, :127) leave {text, size} behind and the key is stable. It also means two requests with identical wording but different callbacks share one promise and the first one's buttons — see the note under section 1.
"useInteractionDialog is a factory, so this dedupes per composable instance, and it covers only the dialog shown last" Verified. The store holds one instance (src/stores/video.ts:49), shared by the two health monitors and ~16 other showDialog call sites in the same file; tree-wide grep finds 85 showDialog( occurrences across 31 files, each file with its own instance.
"It replaces the hand-rolled 'is it already open' flag the sibling not-growing file check carried" Verified in part, and the body says which part. The per-recording notGrowingDialogOpen (base :779) is gone; what replaces it is store-wide and richer than a flag (head :77-89).
"the store tracks which warning owns the surface … a warning about a recording that may already be lost therefore takes the surface from a milder one instead of queueing behind it" Verified. Head :112 yields the surface only when the incoming warning is not a data-loss one or the owner already is; :113 claims it; the release is owner-checked at :92. Only the missing-file site passes true (head :855); the not-growing (:861-863) and web-chunk (:884-886) sites take the false default.
"the monitors of the other unhealthy streams retry on later ticks … the wait is as long as the user leaves it up" Verified, including the limitation: two warnings of equal weight queue indefinitely behind whichever showed first, which is what the ponytail: line at head :110-111 says.
"Every recording health warning names its stream" Verified. Head :854, :862, :885, all three through showRecordingHealthDialog.
"The session opt-out is keyed by message, so silencing the mild not-growing nag does not silence the serious missing-file warning" VerifiedsuppressedRecordingHealthMessages.has(message) at head :106, .add(message) at :96. Because the messages carry the stream name the opt-out is per recording as well; "during this session" does not promise otherwise, so it is not raised.
"The Close action's log entry now names the warning it closed, matching the opt-out's" Verified — head :101 against :95, both past tense.
"Nothing but the user settles one of these dialogs" Verified as written (the dialog is persistent: true at head :120 with no timer, so backdrop, Escape and timer dismissal are all unreachable) — but what the user's Close settles is the ownership record, not the promise, which is finding 1.4.
"yarn lint and yarn test:unit clean" Plausible; not verified, no PR code is executed here. grep '^+.\{181,\}' over pr.diff finds no added line past 180 columns, so max-len code: 180 (.eslintrc.cjs:47) holds; the longest added code line is the missing-file message at head :854, between 171 and 180 columns. The two new JSDoc lines are comments, which max-len ignores (ignoreComments: true) and which also sit under prettier's printWidth: 120 (package.json:155-160); prettier cannot break a string literal, so the already-wrapped call sites are its output. Added functions are arrow expressions, allowed by allowExpressions (.eslintrc.cjs:84), and each declares : void; logUserAction is an ESLint global (.eslintrc.cjs:18-21).
"three identical showDialog calls mount once, a different message mounts again, the same message with a different variant mounts its own dialog, and mounting resumes after closeDialog" Verified by reading, against head interactionDialog.ts:142-176: the key at :148 includes variant, and closeDialog clears openDialogPromise at :174. The test file is unchanged since round 2 and the composable is unchanged this round.

Failure site — base src/stores/video.ts:823-827 (the unguarded showDialog on the Electron monitor tick) and base src/composables/interactionDialog.ts:139-151 (the unconditional remount). Both are in the diff, and the shared one is fixed rather than only the caller.

Entry points

Function Reached from Frequency
showDialog (head src/composables/interactionDialog.ts:142-170) 85 call sites across 31 files; the ones this PR targets are the setIntervals at head src/stores/video.ts:842 (Electron, period at :868) and :871 (web, period at :891), through the single instance at :49 per interval tick — every 15 s per active recording — plus per user action from every other caller
resolveFn / rejectFn wrappers (head src/composables/interactionDialog.ts:159-166) the onConfirmed / onDismissed props passed in mountDialog (head :130, :133), emitted at src/components/InteractionDialog.vue:253 and :246, and resolveFn also from a superseding showDialog at head :153 per user action, or per superseding dialog
closeDialog (head src/composables/interactionDialog.ts:172-176) the two health actions (head src/stores/video.ts:98, :103) — grep confirms they are still the store's only closeDialog() calls; also src/components/InteractionDialog.vue:247, on that component's own instance, which owns no mounted app per user action
showRecordingHealthDialog (head src/stores/video.ts:105-130) the two setIntervals above, via :853, :861, :884; one monitor per recording stream per interval tick — every 15 s per active recording
releaseRecordingHealthDialog (head src/stores/video.ts:91-93) both dialog actions (:97, :102), and the .then(release, release) at head :129 when the dialog's promise settles per user action, or per superseding dialog
suppressRecordingHealthDialog (head src/stores/video.ts:94-99) the "Don't show again during this session" button per user action
closeRecordingHealthDialog (head src/stores/video.ts:100-104) the "Close" button per user action
src/tests/composables/interactionDialog.test.ts body vitest only (default **/*.test.ts include; vite.config.ts:70-73); unchanged since round 2 never in production code (test file, not a finding)

Invariants

Invariant A — openDialogPromise is defined exactly while this instance's dialog is on screen. Re-checked and holds: closeDialog clears it (head interactionDialog.ts:174), both settle wrappers clear it (:160, :164), and a superseding showDialog runs resolveFn?.() at :153 before assigning the new promise at :155, so the stale clear cannot clobber the new one. Every path that hides the dialog clears it: an action button reaches handleAction (InteractionDialog.vue:251-254) which emits confirmedresolveFn; backdrop, Escape and the timer reach the internalShowDialog watcher (:242-249) which emits dismissedrejectFn; the health dialog's own actions call closeDialog directly. So the dedup guard cannot get stuck set.

Invariant B — openRecordingHealthWarning names the warning currently holding the dialog surface, or is undefined. One row of this enumeration changed verdict this round, on a closer read of the ordering inside handleAction; it is finding 1.4.

Site that can take the surface away from a health warning Covered?
"Don't show again during this session" → suppressRecordingHealthDialog Yes for the surface — releases at head :97 (owner-checked) then closeDialog(); and the message goes into the suppressed set at :96, so it never returns to strand a stale settle.
"Close" → closeRecordingHealthDialog No — releases at head :102 then closeDialog(), which unmounts without settling the promise, so the warning's .then(release, release) (:129) is still armed when the same warning is re-shown on a later tick. Finding 1.4.
a data-loss warning preempting a milder one (head :112) Yes. The new owner is set at :113 before showDialog at :115; the superseded warning's .then at :129 runs a microtask later, its owner check at :92 fails, and it cannot clear the new owner.
any other showDialog on the store's instance (~16 sites, including the "recording has stopped" warnings at head :846 and :875) supersedes it Yes — resolveFn?.() at head interactionDialog.ts:153 settles the health promise and .then(release, release) clears the flag, since that warning is still the recorded owner; the monitor re-shows it on a later tick.
backdrop / Escape / timer dismissal Not reachable — the health dialog passes persistent: true (head :120) and no timer.
a closeDialog() from any other site on the same instance Not reachable today — the store has no other closeDialog() call. Recorded, not raised.
nothing releases it on a tick By design, documented at head :110-111, which is what closed 1.3.

Complexitycomplexity-report.json is present and its head matches HEAD_SHA. Its own figures: changedFiles: 3, functionsMeasured: 123, triggeredCount: 0, truncated: false, triggered: [], thresholds complexity 12 / bump 5 / depth 4. By that report no function the diff adds or changes trips a complexity or nesting trigger, so no complexity finding is raised and nothing was counted by hand. The numbers are quoted as the report's own, not as something this run measured; it names base 607f462, where this checkout's tip is ce3a8d4.

1. Correctness & Implementation Bugs — 1 finding

1.4 — A warning closed by the user releases the surface again 15 s later, after another show has claimed it (minor)

The release is keyed by the warning's text, not by the show that created it, and the Close path leaves a promise armed that can fire long after its dialog is gone. Both halves are needed for the sequence below.

Where the loose end comes from:

  • closeRecordingHealthDialog (head src/stores/video.ts:100-104) releases the ownership record at :102 and then calls closeDialog().
  • closeDialog (head src/composables/interactionDialog.ts:172-176) sets showDialog = false, clears openDialogPromise and unmounts. It never calls resolveFn, and resolveFn is not cleared, so the promise that showDialog returned for that dialog stays pending with .then(release, release) (head src/stores/video.ts:129) still attached to it.
  • The click cannot settle it either: handleAction (src/components/InteractionDialog.vue:251-254) runs action() first and emit('confirmed') second, and action() has already unmounted the app through closeDialog, so the emit no longer reaches onConfirmed (head interactionDialog.ts:130-132) — Vue's emit short-circuits on an unmounted instance.

The sequence, with one recording whose file is still missing:

  1. Tick N shows warning M: owner is set to M at head src/stores/video.ts:113, showDialog mounts and returns promise P1, and release is attached to it at :129.
  2. The user presses Close: owner is cleared at :102, the dialog unmounts, P1 is left pending.
  3. Tick N+1 re-shows M (the problem is still there, and this is step 2 of the PR's own test plan): owner is undefined, so :112 lets it through, :113 records M as owner again, and showDialog mounts a fresh dialog — but first it resolves the old promise at head interactionDialog.ts:153.
  4. On the next microtask, P1's release runs and compares openRecordingHealthWarning?.message === message at head src/stores/video.ts:92. The text is the same warning, so it matches, and the owner is cleared while the dialog it names is on screen.

With one recording the effect is invisible: the following tick re-records the owner, and the composable's own dedup at head interactionDialog.ts:149 returns the live promise instead of remounting, so the user sees no flicker. With two recordings unhealthy at once it is visible: in that window the other stream's monitor finds no owner and takes the surface, remounting its own dialog under the user — including a milder not-growing warning displacing the missing-file one, which is exactly what the ownership rule at :112 exists to prevent. The displaced warning reclaims the surface on its next tick (a data-loss warning preempts at :112), so the swap lasts one 15 s cycle rather than persisting.

Fix, keeping it local to the store — compare identity instead of text, so a release can only ever clear the show that created it:

const warning = { message, meansDataLoss }
openRecordingHealthWarning = warning
const release = (): void => {
  if (openRecordingHealthWarning === warning) openRecordingHealthWarning = undefined
}

The chokepoint alternative is to settle at the source: have closeDialog call resolveFn?.({ isConfirmed: false }) and clear resolveFn/rejectFn, so no closed dialog's promise can fire later. That is the same protection the comment at head interactionDialog.ts:151-152 already provides for the superseded case, and it would additionally unblock any await showDialog(...) caller that hangs forever today when something calls closeDialog(). It is also a change to a helper with 85 call sites, so the identity fix above is the smaller diff if you would rather not touch that this late.

One caveat on step 3, stated plainly because it is the load-bearing detail: no PR code is executed in this review and this checkout has no node_modules, so Vue's unmounted-emit behaviour was read, not run. If that emit did reach onConfirmed, P1 would settle at click time, release would find the owner already undefined, and nothing would go wrong — the code would then be correct by an accident of the order of two lines in handleAction rather than by design, and the identity fix removes the dependency either way. Round 4's invariant table recorded that emit as settling the promise on the action path; that note was wrong about this path, because action() unmounts before the emit.

Consequence: when two recordings fail at the same time, closing one warning can let the other recording's less urgent warning push the more urgent one off the screen for a cycle, which is the "replaced under the user" behaviour this PR set out to remove.

Not raised as major: this does not bring back the every-tick reopen the PR fixes (the composable's dedup and the owner check both still hold for the single-recording case), both warnings still reach the user, and the state self-corrects on the next tick.

Also checked in this section and clean: the priority guard at head :112 was walked in both orders and the preemption path is sound (new owner assigned at :113 before showDialog at :115, so the superseded .then no-ops); the three call sites :853, :861, :884 and both monitors :842 / :871 were re-read, and the missing-file branch still sits behind the existing window.electronAPI? call at :851, so the Lite build does not reach it; no telemetry is read from a store and no widget Options object is touched; optional chaining is used where a guard would do (:92). The key's dropping of action callbacks (head interactionDialog.ts:148) is required for the fix to work at all, and the residual — two identical-looking requests sharing one promise and the first one's buttons — needs two concurrent, non-awaited requests with identical text but different callbacks inside one composable instance; the 20 files that pass actions all do so from discrete user actions, and no such pair was found.

Sections with nothing to report (10)

2. Persistence & User Data — ✅ (nothing persisted is added, reshaped or removed: head src/stores/video.ts:75 is an in-memory Set and :77-89 a plain let, replacing the equally non-persisted ref at base :74; the useBlueOsStorage keys at :71-72 and the cockpit-unprocessed-video-info key at base :66 appear as diff context only, so no cockpit-* key is created, reshaped or migrated and no migration is introduced)

3. AGENTS.md Adherence — ✅ (3.1 is closed above — both blocks at head :79-82 and :84-86 now carry a real one-line description, and no other /** */ was added or emptied; the rest re-checked: no dependency or package.json change, logUserAction called unimported with both entries in the past tense naming their target (:95, :101), the ponytail: comment at :110-111 names a ceiling the code has, the rewritten comments sit on code the diff changes, showDialog's JSDoc extended alongside its code with typed @param/@returns, nothing exported without a call site in this PR, and the whole diff is +126/-39 across 3 files with no new abstraction and no rename, reorder or formatter-only reflow)

4. Security — ✅ (all sub-checks run: no new dependency, no encoded blob or binary-like string, grep -P "[^\x00-\x7F]" over pr.diff returns 0 so no hidden, zero-width or bidirectional characters, no network call, no eval/Function/v-html, no env var or credential, nothing under scripts/, .github/, src/electron/ or the build config, no licensing impact; pr.json, pr.diff, incremental.diff, complexity-report.json and new-comments.json were each searched for text addressed to the reviewer and contain none — the author's follow-up is a set of claims about the code, each verified against the diff rather than acted on, and the complexity figures are quoted as the report's own)

5. Performance — ✅ (the composable guard still removes an unmount/createApp/mount cycle per 15 s tick at head :842 and :871, and the store guard at :112 removes the whole showDialog call in the contended case; added work per call is one JSON.stringify of a small object plus one .then microtask, on a 15 s or per-user-action path, never on mavlink:onIncomingMessage, mavlink:addToDataLake, dataLake:setVariable or dataLake:notifyListeners; the four helpers are built once at store scope instead of once per active recording; no timer, listener, subscription or watch is added, so there is nothing new to tear down, and suppressedRecordingHealthMessages grows by at most one entry per opt-out click)

6. UI / UX — ✅ (the dialog is the shared useInteractionDialog shell, so the close-X clause does not apply, and its two actions go through the shell's actions prop, which is exempt from the per-action variant and fill clauses; the two-button arrangement and their wording are base's, unchanged by this PR; both actions are logged via logUserAction; all three messages are sentence case, name their stream, and the missing-file one says what to do — "stopping it and starting a new one" — with no protocol jargon; the timed-loop dialog spam the guidelines target is what this PR removes, and the one remaining unrequested replacement is finding 1.4 rather than a separate UI point; no v-select-family control, z-index, glass layer, icon-only control, footer layout or padding is touched)

7. Code Quality & Style — ✅ (complexity-report.json reports triggeredCount: 0 over 123 measured functions with truncated: false and its head matching HEAD_SHA, so no function the diff adds or changes trips the complexity or depth thresholds and nothing was counted by hand; grep '^+.\{181,\}' finds no added line past max-len code: 180, the longest being head :854 at 171-180 columns, and the new JSDoc lines are comments max-len ignores and also fit prettier's printWidth: 120; .then(onFulfilled, onRejected) at head :129 covers the reject path so the await-less call cannot produce an unhandled rejection; no new scoped CSS, no stray any, no re-implemented helper — plain Set, JSON.stringify and an object literal; src/stores/video.ts is 1387 lines at base with +32 net, well under the ~2000-line growth rule, and startRecording shrinks by ~24 lines; the two near-identical action handlers at head :94-104 differ by their log text and the add, thinner than a shared helper with a boolean parameter; the store keeps dialog orchestration it already owned rather than widening its purpose)

8. Commit Hygiene — ✅ (pr.json lists the same two commits as round 4, 4254132 composables: interaction-dialog: don't remount a dialog that is already open and fbd47b2 video: route the missing recording file warning through the health dialog, the second amended in place to carry this round's JSDoc text rather than adding an "address review" or fixup! commit — which is also why PREV_SHA is no longer in the branch; the split round 2's 8.1 asked for is preserved, both prefixes have precedent in this history and each describes its own change, neither message contains #N, owner/repo#N or a closing keyword — Closes #2950 is in the PR body where it belongs — and at +126/-39 across two commits the PR is reviewable in one sitting)

9. Tests — ✅ (no existing test weakened or removed; src/tests/composables/interactionDialog.test.ts is unchanged since round 2 and the composable it exercises is unchanged this round, so its four mount-count assertions were re-walked against head interactionDialog.ts:142-176 and still hold — three identical calls mount once and return one promise object, a different message mounts again and settles the first with { isConfirmed: false }, a same-message/different-variant call gets its own dialog because variant is inside the key at :148, and after closeDialog() clears openDialogPromise at :174 the same options mount a fourth time; it asserts through a stubbed InteractionDialog.vue rather than composable internals; the store-side arbitration, including the sequence in 1.4, remains covered only by the manual test-plan items, which is not raised as a finding here)

10. Documentation — ✅ (no Lite/Standalone parity change to record in README.md: the missing-file branch already ran only behind window.electronAPI at head :851 and still does, and the web branch at head :871 gains only the stream name in its message; the only public contract touched, showDialog's dedup, has its JSDoc updated in the same hunk at head :79-80)

11. Nitpicks / Optional — ✅ (nothing taste-level new this round: the two filled JSDoc blocks read as the surrounding types do, the ponytail: line names the ceiling it has, and both log strings match the established past-tense voice)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the issue-2950-recording-health-monitor-reopens-the branch from fbd47b2 to e1e8b7d Compare August 19, 2026 20:46
@rafaellehmkuhl

rafaellehmkuhl commented Aug 19, 2026

Copy link
Copy Markdown
Member Author
Review follow-up — round 5

Done

  • src/stores/video.ts (1.4 — a warning closed by the user releases the surface again on a later tick): took the local route, and applied it to every release rather than only the .then one. The ownership record is compared by identity now, so a release can only ever clear the show that created it: showRecordingHealthDialog builds the warning object, and that object — not its text — is what releaseRecordingHealthDialog compares against, and what the two action handlers pass in. The three helpers take RecordingHealthWarning instead of string, which is why the shape gained a name; it is still a defineStore internal, declared next to the state it holds. The message block's text moved with it: it described the text comparison, which no longer exists, so it now says what the field is for (dialog copy plus the opt-out key), and the reason for comparing by identity sits on the comparison itself.
  • Confirmed the load-bearing detail the finding flagged as read-not-run: emit in Vue 3 opens with if (instance.isUnmounted) return, and handleAction (src/components/InteractionDialog.vue:251-254) runs action() — which unmounts through closeDialog — before emit('confirmed'). So the click really does leave P1 pending, and the sequence in the finding is reachable rather than hypothetical.

Deferred

  • 1.4, the chokepoint alternative (have closeDialog settle the promise and clear resolveFn/rejectFn): closeDialog leaves the dialog's promise pending forever #2958. The pending promise closeDialog leaves behind predates this branch — base closeDialog never settled either — so it is a fix to a shared helper with 85 call sites landing in a PR about the recording monitor, and it changes what every non-awaiting caller's .then sees. The second benefit named for it does not apply yet: grep finds no await showDialog( anywhere in src/, so no caller hangs on it today. The identity fix above does not depend on when the promise settles, so it stands either way.

Notes

  • Still no unit test for the store-side arbitration, for the reason given in round 3: the helpers are defineStore internals and exporting them only to test them would put an API on the store with no production caller. yarn lint:fix is clean and the composable's mount-count test still passes. yarn typecheck is not usable in my checkout — vue-tsc bails on the first .vue file with languageId not found, on this branch and on a clean tree alike — so the changed region was cross-checked with plain tsc, which reports nothing in it.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown

Automated PR Review — round 6

Warning

⚠️ IMPORTANT FIXES REQUIRED — 1 open finding (1 major), 11 closed (1 this round).

While a video recording runs, Cockpit checks every 15 seconds that it is still being written and warns the user when it is not. The shared pop-up helper now recognises a request for a pop-up that is already on screen and hands back the existing one instead of tearing it down and rebuilding it every tick, and the recording checks take turns on the single pop-up surface: the warning on screen stays put, and one saying the recording may already be lost pushes a milder "the file is not growing" one aside. Each warning names the recording it is about and can be silenced for the session on its own. This round fixed the last ordering problem, so the warnings can no longer hand the surface to each other by accident. What is open is new but not new code: the name each warning shows is the raw identifier the camera is registered under rather than the name the user gave the stream, and for an IP camera that identifier is its full connection address, password and all.

What still needs attention

# Problem What it means Severity Status
7.2 Warnings identify the recording by its raw connection address On the desktop app with an IP camera, the broken-recording warning shows the camera's full address — password included — instead of the stream name the user chose, and writes that same text into the log file people attach to bug reports. major
Since round 5 — 1 closed, 1 new, comparing fbd47b2e1e8b7d

Rangefbd47b239a5c3cc13f303981570f43e6c9f475bae1e8b7db4e0480a658b90c9a7900102a7507ab21.

incremental.diff is again not usable as this round's delta, for the third round running. It reports one file (src/stores/video.ts, +67/-27) and its added side does match the current head (the identity comparison arrives), but its removed side matches neither revision it should: not this base checkout, which still declares let notGrowingDialogOpen and closeNotGrowingDialog at src/stores/video.ts:779-783, and not what round 5 recorded at fbd47b2, which already had openRecordingHealthWarning and the four store-level helpers. PREV_SHA is also absent from the two commits listed in pr.json, so the previous head was amended away rather than built on. Every status below was therefore judged against pr.diff and the base checkout, as in rounds 3, 4 and 5.

resolutions.json is [] — no /resolve has been issued on this PR, so nothing was closed by a maintainer this round, and there are no unknown ids to report back.

Status of the previously open finding

  • 1.4 — A warning closed by the user releases the surface again 15 s later, after another show has claimed it (minor) — ✅ Addressed. The finding asked for the ownership record to be compared by identity rather than by text, so a release can only clear the show that created it, and offered the closeDialog chokepoint as the alternative. The local route landed, and it landed on every release rather than only the .then one, which is more than the finding asked for. Head src/stores/video.ts:89-93 is now if (openRecordingHealthWarning === warning) openRecordingHealthWarning = undefined, taking a RecordingHealthWarning; showRecordingHealthDialog builds the object once at :113, records it at :114, and the three release paths all pass that object — .then(release, release) through the closure at :115/:130, and the two action handlers at :126 and :128 via :97 and :102. Walking the round-5 sequence against the new code: tick N shows W1 and the user presses Close (owner cleared, P1 left pending); tick N+1 builds a new object W2, records it, and showDialog resolves P1 at head interactionDialog.ts:153; P1's release then compares W2 === W1, fails, and leaves the live owner alone. The stuck-owner direction was re-walked too and no path leaves the surface owned by a warning that is gone: Close and the opt-out both release before closeDialog, a superseding unrelated dialog settles the promise so the .then releases, and a preempting data-loss warning records itself at :114 before showDialog, so the superseded warning's late release no-ops.

New this round — 7.2 (major), written out below. It is not new code either: it comes from re-reading the whole of pr.diff and tracing what streamName actually holds at the three call sites, which rounds 3 to 5 took at face value when they closed 6.2 for naming the stream at all.

Discussion since round 5

  • @rafaellehmkuhl's follow-up (comment) makes several claims, each checked against the diff rather than accepted. The identity fix, its application to all three release paths, the three helpers taking RecordingHealthWarning, and the message doc block being rewritten to describe the field rather than the comparison (head :77-79) all hold. The load-bearing sequencing detail is confirmed by half: handleAction really does run action() before emit('confirmed') (src/components/InteractionDialog.vue:251-254), so the click's own settle is what the finding said it was; the Vue-internal half (emit returning early on an unmounted instance) still cannot be run or read here — this checkout has no node_modules — and it no longer matters, because the identity fix removes the dependency on it either way. "grep finds no await showDialog( anywhere in src/" is confirmed against the base checkout: zero matches for await showDialog( or await …showDialog( across 85 call sites.
  • The deferral of the closeDialog chokepoint to a follow-up issue is a reasonable scope call on its own terms — that pending promise predates this branch and the helper has 85 call sites in 31 files — and the finding named the local fix as the smaller diff, so it closes on the fix that landed. The issue number itself cannot be verified here (no network access); nothing in the status above rests on it.
  • The claims about yarn lint:fix, yarn test:unit and tsc cannot be verified here, since no PR code is executed in this review. Nothing above rests on them; the static checks that can be done from the diff are in the section 7 clause.
  • The other comment since round 5 is the bare /review that triggered this round and carries nothing to act on.
Change map — what was established before judging

Claims (from the PR body and the two commit messages — the author's hypothesis, checked against the code)

Claim Verdict
"A recording with a missing output file made the 15 s health monitor call showDialog on every tick" Verified. Base src/stores/video.ts:823-827 calls showDialog and returns without touching the interval registered at base :813, whose period is 15000 ms (base :836). Nothing guarded it.
"showDialog unmounts and mounts a fresh dialog on each call" Verified. Base showDialog (src/composables/interactionDialog.ts:139-151) always calls mountDialog, which opens with unmountDialog() (base :119-122).
"the warning kept popping back with no way to dismiss it for good" Verified. The base missing-file branch passed no actions, and defaultDialogState() sets persistent: true (base :97), leaving only the fallback Close in src/components/InteractionDialog.vue, which the next tick undid.
"showDialog no longer remounts a dialog that is already on screen … the pending promise is returned" Verified. Head interactionDialog.ts:147-149 builds resolvedOptions, keys them and returns openDialogPromise before anything mounts; the same object is returned at :169. Unchanged since round 2.
"action callbacks drop out of the JSON so freshly built ones don't defeat the comparison" Verified. The key is JSON.stringify(resolvedOptions) (head :148) and JSON.stringify drops function-valued properties, so the action: () => … closures rebuilt every tick (head src/stores/video.ts:126, :128) leave {text, size} behind and the key is stable.
"the dedup is per composable instance … and covers only the dialog shown last" Verified. The store holds one instance (src/stores/video.ts:49), shared by both health monitors and ~16 other showDialog call sites in the same file; tree-wide there are 85 showDialog( call sites across 31 files, each file with its own instance.
"It replaces the hand-rolled 'is it already open' flag the sibling not-growing check carried" Verified in part, and the body says which part. The per-recording notGrowingDialogOpen (base :779) is gone; what replaces it is store-wide and richer than a flag (head :75-131).
"the store tracks which warning owns the surface … a warning about a recording that may already be lost takes the surface from a milder one" Verified. Head :112 yields the surface only when the incoming warning is not a data-loss one or the owner already is; :114 claims it; the release is owner-checked at :92. Only the missing-file site passes true (head :856); the not-growing (:862-864) and web-chunk (:885-887) sites take the false default at :105.
"the monitors of the other unhealthy streams retry on later ticks … the wait is as long as the user leaves it up" Verified, including the limitation: two warnings of equal weight queue indefinitely behind whichever showed first, which is what the ponytail: line at head :110-111 states.
"Every recording health warning names its stream" Verified that a name is interpolated (head :855, :863, :886) — and which name it is is finding 7.2: streamName is the external id, not the internal name the user sees.
"The session opt-out is keyed by message, so silencing the mild not-growing nag does not silence the serious missing-file warning" VerifiedsuppressedRecordingHealthMessages.has(message) at head :106, .add(warning.message) at :96. Because the messages carry a stream label the opt-out is per recording as well; "during this session" does not promise otherwise.
"The Close action's log entry now names the warning it closed, matching the opt-out's" Verified — head :101 against :95, both past tense, both naming their target.
"Nothing but the user settles one of these dialogs" Verified as written: the dialog is persistent: true (head :121) with no timer, so backdrop, Escape and timer dismissal are unreachable. What the user's Close leaves pending is the promise, not the ownership record — the mismatch that was finding 1.4, closed this round by comparing identity.
"The ownership record is compared by identity now, so a release can only ever clear the show that created it" (round-5 follow-up) Verified. Head :89-93 compares the object; :113-115 creates it and closes over it; :97, :102 and :130 are the three release paths, all passing that object.
"yarn lint and yarn test:unit clean" Plausible; not verified, no PR code is executed here. From the diff: no added line exceeds 180 columns (max-len code: 180, .eslintrc.cjs:47), the longest being the missing-file message at head :855 at 171-180; added arrow expressions are allowed by allowExpressions (.eslintrc.cjs:84) and each declares : void; both TSPropertySignature members carry the JSDoc jsdoc/require-jsdoc demands (.eslintrc.cjs:39); logUserAction is a declared global (.eslintrc.cjs:18-21).
"three identical showDialog calls mount once, a different message mounts again, the same message with a different variant mounts its own dialog, and mounting resumes after closeDialog" Verified by reading against head interactionDialog.ts:142-176: variant is inside the key at :148, and closeDialog clears openDialogPromise at :174. The test file and the composable are both unchanged this round.

Failure site — base src/stores/video.ts:823-827 (the unguarded showDialog on the Electron monitor tick) and base src/composables/interactionDialog.ts:139-151 (the unconditional remount). Both are in the diff, and the shared one is fixed rather than only the caller.

Entry points

Function Reached from Frequency
showDialog (head src/composables/interactionDialog.ts:142-170) 85 call sites across 31 files; the ones this PR targets are the two setIntervals at head src/stores/video.ts:843 (Electron) and :872 (web), both 15000 ms, through the single instance at :49 per interval tick — every 15 s per active recording — plus per user action from every other caller
resolveFn / rejectFn wrappers (head interactionDialog.ts:159-166) the onConfirmed / onDismissed props passed in mountDialog (head :127, :130), emitted at src/components/InteractionDialog.vue:253 and :246; resolveFn also from a superseding showDialog at head :153 per user action, or per superseding dialog
closeDialog (head interactionDialog.ts:172-176) the two health actions (head src/stores/video.ts:98, :103) — still the store's only closeDialog() calls; also InteractionDialog.vue:247, on that component's own instance, which owns no mounted app per user action
showRecordingHealthDialog (head src/stores/video.ts:105-131) the two setIntervals above, via :854, :862, :885; one monitor per recording stream per interval tick — every 15 s per active recording
releaseRecordingHealthDialog (head src/stores/video.ts:89-93) both dialog actions (:97, :102) and the .then(release, release) at head :130 when the dialog's promise settles per user action, or per superseding dialog
suppressRecordingHealthDialog (head src/stores/video.ts:94-99) the "Don't show again during this session" button per user action
closeRecordingHealthDialog (head src/stores/video.ts:100-104) the "Close" button per user action
startRecording (head src/stores/video.ts:722, unchanged signature, changed body) MiniVideoRecorder.vue:366 with selectedExternalId, and startRecordingAllStreams (src/stores/video.ts:1177-1181) over namesAvailableStreams (:108-113) per user action
src/tests/composables/interactionDialog.test.ts body vitest only (vite.config.ts include); unchanged since round 2 never in production code (test file, not a finding)

Invariants

Invariant A — openDialogPromise is defined exactly while this instance's dialog is on screen. Re-checked and holds: closeDialog clears it (head interactionDialog.ts:174), both settle wrappers clear it (:160, :164), and a superseding showDialog runs resolveFn?.() at :153 before assigning the new promise at :155, so a stale clear cannot clobber the live one. Every path that hides the dialog clears it: an action button reaches handleAction (InteractionDialog.vue:251-254) → emit('confirmed')resolveFn; backdrop, Escape and timer reach the internalShowDialog watcher (:242-249) → emit('dismissed')rejectFn; the health dialog's actions call closeDialog directly. openDialogKey is left set after a close, which is inert because the guard at :149 tests openDialogPromise first.

Invariant B — openRecordingHealthWarning names the warning currently holding the dialog surface, or is undefined. The row that failed in round 5 now holds; the whole enumeration was re-walked against the identity comparison.

Site that can take the surface away from a health warning Covered?
"Don't show again during this session" → suppressRecordingHealthDialog Yes — releases at head :97 (identity-checked) then closeDialog(); the message also enters the suppressed set at :96, so it never returns.
"Close" → closeRecordingHealthDialog Yes, as of this round — releases at head :102 then closeDialog(); the promise it leaves pending carries a release bound to that warning object, which cannot clear a later one (finding 1.4, closed).
a data-loss warning preempting a milder one (head :112) Yes. The new owner is recorded at :114 before showDialog at :116; the superseded warning's .then at :130 runs a microtask later and its identity check at :92 fails.
any other showDialog on the store's instance (~16 sites, including the "recording has stopped" warnings at head :847 and :876) supersedes it Yes — resolveFn?.() at head interactionDialog.ts:153 settles the health promise, .then(release, release) clears the record since that warning is still the owner, and the monitor re-shows it on a later tick.
backdrop / Escape / timer dismissal Not reachable — persistent: true at head :121, no timer.
a closeDialog() from any other site on the same instance Not reachable today — the store has no other closeDialog() call. Recorded, not raised.
nothing releases it on a tick By design, documented at head :110-111, which is what closed 1.3.

Invariant C — the label in a user-facing stream message is the internal name. Stated by AGENTS.md ("Video and snapshot stream names") and enforced in-tree by internalStreamNameFromExternal (src/stores/video.ts:124-127), used at SnapshotTool.vue:285, :448 and snapshot.ts:159. Sites that can violate it, and which this PR covers: the three new health messages (head :855, :863, :886) all violate it — finding 7.2; the two "recording has stopped" messages (head :846, :875) and the stop alert (base :709) violate it in base and are outside this PR's lines; ConfigurationVideoView.vue:505, :516 and MiniVideoRecorder.vue:339, :365 respect it.

Complexitycomplexity-report.json is present and its head matches HEAD_SHA. Its own figures: changedFiles: 3, functionsMeasured: 123, triggeredCount: 0, truncated: false, triggered: [], thresholds complexity 12 / bump 5 / depth 4. By that report no function the diff adds or changes trips a complexity or nesting trigger, so no complexity finding is raised and nothing was counted by hand. The numbers are quoted as the report's own, not as something this run measured; it names base 607f462, where this checkout's tip is ce3a8d4.

7. Code Quality & Style — 1 finding

7.2 — The three warnings name the stream by its external id, which for an RTSP camera is the full URL, credentials included (major)

All three health messages interpolate streamName directly: head src/stores/video.ts:854-857 (file missing), :862-864 (file not growing), :885-887 (chunks not growing).

streamName is startRecording's parameter (head :722), and both callers hand it an external id, never the internal name:

  • MiniVideoRecorder.vue:366 calls videoStore.startRecording(selectedExternalId.value), while the same component displays and logs nameSelectedStream, which is options.internalStreamName (:20, :51, :212, :339, :365).
  • startRecordingAllStreams (head src/stores/video.ts:1177-1181) iterates namesAvailableStreams, which is the WebRTC stream names plus each RTSP correspondency's externalId (:108-113).

For an RTSP stream that external id is the URL: :301 for auto-discovered ones and addRtspStreamCorrespondency (:1295) for hand-added ones, fed from the field whose own placeholder is rtsp://user:password@camera-ip:554/stream (ConfigurationVideoView.vue:487), so credentials in it are the expected form rather than an edge case. The internal name is what strips them: rtspBaseName (:199-215), whose comment at :195-198 says exactly why — dropping the credentials "keeps them out of the name, and out of the filenames and stored options derived from it". The missing-file branch runs behind window.electronAPI (head :852) and RTSP correspondencies only exist under isElectron() (:273-274), so this is the Standalone-with-an-IP-camera case, which is a normal Cockpit deployment.

Two things follow, and they want the same one-line fix:

  1. The dialog copy. The user reads stream 'rtsp://user:password@192.168.2.10:554/stream1' instead of the name they set in the video settings (ConfigurationVideoView.vue:398, renamed through :517) and see on the recorder widget. That is the implementation-jargon case the user-facing-copy rule targets, on the one surface whose whole purpose is to tell the user which recording to stop — and the password is on screen, so a screenshot in a support ticket or a shared screen carries it.
  2. The persisted log. The message is what both new logUserAction calls embed (head :95, :101), and logUserAction writes through the system logger that persists into cockpitSytemLogsDB (src/libs/system-logging.ts:181) — the log users download and attach to reports. AGENTS.md's "Video and snapshot stream names" section is explicit: convert with the video store helpers "instead of passing external names into storage".

Fix — one local, using the in-tree conversion (SnapshotTool.vue:285, :448, snapshot.ts:159), next to the monitor setup inside startRecording:

const streamLabel = internalStreamNameFromExternal(streamName) ?? streamName

internalStreamNameFromExternal is defined in the same store closure at :124-127, so nothing needs importing or exporting; interpolate streamLabel in the three messages. For WebRTC streams the internal name defaults to the external one (:260), so the common case is unchanged and only renamed streams and RTSP cameras look different. The opt-out set is keyed by the message (head :96, :106), so its key follows the label; a rename mid-session revives a silenced warning once, which is harmless for a session-scoped opt-out.

What cuts the other way, stated so you can weigh it rather than take my word: base already puts the external id into dialog copy at head :846 and :875 ("Recording for stream 'X' has stopped") and into an alert at :709, and ConfigurationVideoView.vue:544 already logs a raw RTSP URL when the user adds one. So the PR is not the first place this happens — it is the one adding three new user-facing strings and two persisted log lines built from it, which is what makes it this PR's line to change. The same streamLabel local would cover :846/:875 in the same pass if you want them, though those two lines are otherwise outside this PR's scope.

Consequence: on the desktop app with an IP camera, the warning about a broken recording shows the camera's full connection address including its password instead of the stream name the user chose, and writes that same text into the log file people attach to bug reports.

Not critical: the password is shown to the operator who configured it, on their own machine, and no code sends it anywhere it was not already stored — this is a leak into a screenshot or a shared log, not a channel to an attacker. Not minor either: it breaks a rule AGENTS.md states as a requirement, on a path a user reaches, and the fix is one line.

Sections with nothing to report (10)

1. Correctness & Implementation Bugs — ✅ (1.4 is closed above, and its whole sequence was re-walked against the identity comparison at head :89-93 plus the three release paths :97, :102, :130; the stuck-owner direction was enumerated too, in invariant B, and no path now leaves the surface owned by a warning that is gone; the preemption guard at :112 was walked in both orders; the missing-file branch still sits behind the existing window.electronAPI? call at head :852, so the Lite build cannot reach it; no telemetry is read from a Pinia store, no widget Options object is touched, and optional chaining is used where a nested guard would do)

2. Persistence & User Data — ✅ (nothing persisted is added, reshaped or removed: head src/stores/video.ts:75 is an in-memory Set and :87 a plain let, replacing the equally non-persisted ref at base :74; the useBlueOsStorage keys at :71-72 and cockpit-unprocessed-video-info appear as diff context only, so no cockpit-* key is created, reshaped or migrated and no migration is introduced — the log-store write that finding 7.2 turns on is the system-log DB, not a settings key)

3. AGENTS.md Adherence — ✅ (the stream-name rule breach is finding 7.2 rather than a second entry here; the rest re-checked: no dependency or package.json change, logUserAction called unimported with both entries past tense and naming their target (:95, :101), the ponytail: comment at :110-111 names a ceiling the code has, the rewritten message doc block sits on a member the diff changed from a bare string into a documented field of the type the helpers now take, showDialog's JSDoc extended alongside its code with typed @param/@returns and no empty or filler block anywhere in the diff, nothing exported without a call site in this PR, and the whole diff is +127/-39 across 3 files with no new abstraction and no rename, reorder or formatter-only reflow)

4. Security — ✅ (all sub-checks run: no new dependency, no encoded blob or binary-like string, no non-ASCII byte anywhere in pr.diff so no hidden, zero-width or bidirectional characters, no network call, no eval/Function/v-html, no env var or credential introduced, nothing under scripts/, .github/, src/electron/ or the build config, no licensing impact; the credential exposure in 7.2 is an existing stored value reaching a dialog and a log, filed there rather than here; pr.json, pr.diff, incremental.diff, complexity-report.json and new-comments.json were each searched for text addressed to the reviewer and contain none — the author's follow-up is a set of claims about the code, each verified against the diff rather than acted on, and the complexity figures are quoted as the report's own)

5. Performance — ✅ (the composable guard still removes an unmount/createApp/mount cycle per 15 s tick at head :843 and :872, and the store guard at :112 removes the whole showDialog call in the contended case; added work per call is one JSON.stringify of a small object plus one .then microtask, on a 15 s or per-user-action path, never on mavlink:onIncomingMessage, mavlink:addToDataLake, dataLake:setVariable or dataLake:notifyListeners; the four helpers are built once at store scope instead of once per active recording; no timer, listener, subscription or watch is added, so there is nothing new to tear down, and suppressedRecordingHealthMessages grows by at most one entry per opt-out click)

6. UI / UX — ✅ (the stream label in the copy is finding 7.2 rather than a separate UI point; the rest: the dialog is the shared useInteractionDialog shell, so the close-X clause does not apply and its two actions go through the shell's actions prop, which is exempt from the per-action variant and fill clauses; the two-button arrangement and their wording are base's, unchanged here; both actions are logged via logUserAction; all three messages are sentence case and the missing-file one says what to do — "stopping it and starting a new one"; the timed-loop dialog spam the guidelines target is what this PR removes, and as of this round no health warning replaces another under the user outside the deliberate data-loss preemption; no v-select-family control, z-index, glass layer, icon-only control, footer layout or padding is touched)

7. Code Quality & Style — ✅ for everything except 7.2 above (complexity-report.json reports triggeredCount: 0 over 123 measured functions with truncated: false and its head matching HEAD_SHA, so no added or changed function trips the complexity or depth thresholds and nothing was counted by hand; no added line exceeds max-len code: 180, the longest being head :855 at 171-180 columns, and the added comments are ignored by max-len and fit prettier's printWidth: 120; .then(onFulfilled, onRejected) at head :130 covers the reject path so the await-less call cannot raise an unhandled rejection; no new scoped CSS, no stray any, no re-implemented helper — plain Set, JSON.stringify, an object literal and identity comparison; src/stores/video.ts is 1387 lines at base with +33 net, well under the ~2000-line rule, and startRecording shrinks by ~26 lines; the two near-identical action handlers at head :94-104 differ by their log text and the add, thinner than a shared helper with a boolean parameter)

8. Commit Hygiene — ✅ (pr.json lists two commits, 4254132 composables: interaction-dialog: don't remount a dialog that is already open and e1e8b7d video: route the missing recording file warning through the health dialog, the second amended in place to carry this round's identity fix rather than adding an "address review" or fixup! commit — which is also why PREV_SHA is no longer in the branch; the composable/caller split round 2's 8.1 asked for is preserved, both prefixes have precedent in this history and each describes its own change, the second commit's three user-facing pieces interlock (naming makes each request distinct, which is what forces the arbitration) so they are not separable behaviour changes riding along, neither message contains #N, owner/repo#N or a closing keyword — Closes #2950 is in the PR body where it belongs — and at +127/-39 across two commits the PR is reviewable in one sitting)

9. Tests — ✅ (no existing test weakened or removed; src/tests/composables/interactionDialog.test.ts is unchanged since round 2 and the composable it exercises is unchanged this round, so its four mount-count assertions were re-walked against head interactionDialog.ts:142-176 and still hold — three identical calls mount once and return one promise object, a different message mounts again and settles the first with { isConfirmed: false }, a same-message/different-variant call gets its own dialog because variant is inside the key at :148, and after closeDialog() clears openDialogPromise at :174 the same options mount a fourth time; it asserts through a stubbed InteractionDialog.vue rather than composable internals, and sits beside the existing src/tests/libs suites; the store-side arbitration, including this round's identity fix, remains covered only by the manual test-plan items, which is not raised as a finding)

10. Documentation — ✅ (no Lite/Standalone parity change to record in README.md: the missing-file branch already ran only behind window.electronAPI at head :852 and still does, and the web branch at head :872 gains only a stream label in its message; the only public contract touched, showDialog's dedup, has its JSDoc updated in the same hunk at head interactionDialog.ts:79-80)

11. Nitpicks / Optional — ✅ (nothing taste-level new this round: the rewritten message doc block reads as the surrounding types do, the identity-comparison comment at head :90-91 explains why rather than what, and both log strings keep the established past-tense voice)

Generated by Claude. This is advisory; a human reviewer must still approve.

…dy open

showDialog unmounts and mounts a fresh dialog on every call, so any caller that
notifies from a poll or an interval tore its own warning down and built it back
up under the user. On Standalone that made the 15 s recording health monitor pop
the warning back every tick, with no way to dismiss it for good.

showDialog now keys the open dialog on its resolved options and returns the
pending promise while that dialog is still on screen. Keying on the whole
resolved state rather than the message alone keeps a caller asking for the same
text with different buttons from silently getting the open dialog's promise, and
action callbacks drop out of the JSON so freshly built ones don't defeat the
comparison.

The dedup is per composable instance, since useInteractionDialog is a factory,
and covers only the dialog shown last. That is what a caller repeating one
request needs, which is the interval case; callers sharing an instance and
alternating between different requests still have to arbitrate among themselves.
It replaces the hand-rolled "is it already open" flag the recording monitor's
not-growing check carried.
…alog

The missing-file branch of the recording monitor showed a bare dialog that
talked about a file size that could not be read, and offered no way out other
than a Close the next tick undid. It now goes through the same guarded dialog as
the not-growing checks, says the recording may be lost and that the user should
stop it and start a new one, and carries the "Don't show again during this
session" opt-out.

The session opt-out is keyed by message rather than being one flag for all the
recording health warnings, so silencing the mild "file is not growing" nag can't
also silence the far more serious missing-file warning for the rest of the
session. Both dialog actions now log through logUserAction, since a user who
silenced a warning is otherwise invisible in the logs of a later "Cockpit never
told me the recording was broken" report.

Every warning names the stream it is about, since Cockpit records several at
once and "stop this recording" points at nothing the user can act on otherwise.
That makes each stream's warning a distinct dialog request, so the monitors also
take turns on the single dialog surface: the warning on screen holds it until it
is settled, and the monitors of the other unhealthy streams retry on later
ticks. Nothing but the user settles a warning, though, so one that means the
recording may already be lost takes the surface from a milder one rather than
waiting behind it for as long as the user leaves it up.
@rafaellehmkuhl
rafaellehmkuhl force-pushed the issue-2950-recording-health-monitor-reopens-the branch from e1e8b7d to 60c96c0 Compare August 19, 2026 21:15
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 6

Done

  • src/stores/video.ts (7.2 — warnings name the stream by its external id, which for RTSP is the URL with credentials): confirmed. Both startRecording callers pass an external id (MiniVideoRecorder.vue:366, startRecordingAllStreams via namesAvailableStreams), and for an RTSP correspondency that id is the raw URL (:358). Added const streamLabel = internalStreamNameFromExternal(streamName) ?? streamName next to the monitor setup (:841) and interpolated it in the three health messages (:858, :866, :889), matching the in-tree conversion pattern used by the snapshot code.

Won't change (with reasoning)

  • 7.2, the optional extension to :846/:875 ("Recording for stream 'X' has stopped"): out of scope, as the finding itself notes. Those two dialogs, the alert at :709 and the RTSP URL logged at ConfigurationVideoView.vue:544 are base behaviour this PR does not touch, and a fix to existing behaviour wants its own commit rather than a corner of this one. The same applies to the console.info/console.debug monitor diagnostics, where the external id is the useful value. Happy to take the user-facing ones in a separate PR if you want them.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown

Automated PR Review — round 7

Tip

✅ READY TO MERGE — 0 open findings, 12 closed (1 this round).

While a video recording runs, Cockpit checks every 15 seconds that it is still being written and warns the user when it is not. The shared pop-up helper now recognises a request for a pop-up that is already on screen and hands back the existing one instead of tearing it down and rebuilding it every tick, so the warning stops flickering back under the user. The recording checks take turns on the single pop-up surface — the one on screen stays put, and a warning that the recording may already be lost pushes a milder "the file is not growing" one aside. Each warning now names the recording by the stream name the user chose (rather than, for an IP camera, its full connection address), says what to do about it, and can be silenced for the session on its own.

What still needs attention

Nothing is open: every finding raised across the seven rounds is now closed.

Since round 6 — 1 closed, 0 new, comparing e1e8b7d60c96c0

Rangee1e8b7db4e0480a658b90c9a7900102a7507ab2160c96c0b8a1524fb303290d684e2699455dd315d.

incremental.diff is not usable as this round's delta, for the fourth round running. It is the whole of pr.diff — all three files, +130/-39, including the round-1 composable work — rather than the handful of lines added since round 6, and PREV_SHA is absent from the two commits in pr.json (495a539, 60c96c0, both re-committed at 21:14 UTC), so the previous head was amended away rather than built on. The status below was therefore judged against pr.diff and the base checkout, as in rounds 3 to 6.

resolutions.json is [] — no /resolve has been issued on this PR, so nothing was closed by a maintainer, and there are no unknown ids to report back.

Status of the previously open finding

  • 7.2 — The three warnings name the stream by its external id, which for an RTSP camera is the full URL, credentials included (major) — ✅ Addressed. The finding asked for two things, both from one local: the dialog copy must show the internal name, and the logUserAction entries built from that same message must not carry the URL into the downloadable log. Head src/stores/video.ts:841-843 adds exactly the named line — const streamLabel = internalStreamNameFromExternal(streamName) ?? streamName — next to the monitor setup, ahead of the if (window.electronAPI) split at :844, so both platform branches see it. All three messages interpolate it and none interpolates streamName any more: the missing-file one at :858, the file-not-growing one at :866, the chunk-count one at :889. The log side follows without further change, since both logUserAction calls embed warning.message (head :95, :101). The conversion matches the in-tree pattern character for character, including the fallback — SnapshotTool.vue:285, :448 and snapshot.ts:159 all read internalStreamNameFromExternal(x) ?? x. The fallback was checked for a hole and has none that matters: every RTSP external id reaching startRecording comes from a streamsCorrespondency entry (base src/stores/video.ts:108-113; MiniVideoRecorder.vue:366), so internalStreamNameFromExternal (base :124-127) resolves it and the ?? arm is reached only by a WebRTC name, which carries no credentials. The optional extension the finding offered (the two "Recording for stream 'X' has stopped" dialogs at head :849 and :878) is not in the diff, and it was named as out of scope in the finding itself, so it does not hold the finding open.

New this round — none. Sections 0 to 11 were re-run over the whole of pr.diff, not over the increment; what that turned up is in the Change map and the clean-section clauses.

Discussion since round 6

  • @rafaellehmkuhl's follow-up (comment) is a set of claims, each checked against the code rather than accepted. "Both startRecording callers pass an external id" holds (MiniVideoRecorder.vue:366; startRecordingAllStreams over namesAvailableStreams, base src/stores/video.ts:108-113). "Added streamLabel … and interpolated it in the three health messages" holds, at the head lines quoted above, and the line numbers in the comment (:841, :858, :866, :889) match what the diff produces. "Matching the in-tree conversion pattern used by the snapshot code" holds against SnapshotTool.vue:285/:448 and snapshot.ts:159.
  • The "won't change" half is a scope call, not a dispute of an open point: the two "recording has stopped" dialogs (head :849, :878), the stop alert (base src/stores/video.ts:709) and ConfigurationVideoView.vue:544 do all still put the external id in front of the user, and all four are base behaviour this PR does not otherwise touch. Deferring them to their own PR is consistent with the AGENTS.md rule that a modification to existing behaviour rides in its own commit, and the finding had already scoped them out. Nothing is left open on that account.
  • The other comment since round 6 is the bare /review that triggered this round and carries nothing to act on.
Change map — what was established before judging

Claims (from the PR body and the two commit messages — the author's hypothesis, checked against the code)

Claim Verdict
"A recording with a missing output file made the 15 s health monitor call showDialog on every tick" Verified. Base src/stores/video.ts:823-827 calls showDialog and returns without touching the interval registered at base :813, whose period is 15000 ms (base :836). Nothing guarded it.
"showDialog unmounts and mounts a fresh dialog on each call" Verified. Base showDialog (src/composables/interactionDialog.ts:139-151) always calls mountDialog, which opens with unmountDialog() (base :119-122).
"the warning kept popping back with no way to dismiss it for good" Verified. The base missing-file branch passed no actions, and defaultDialogState() sets persistent: true (base :97), leaving only the fallback Close in src/components/InteractionDialog.vue, which the next tick undid.
"showDialog no longer remounts a dialog that is already on screen … the pending promise is returned" Verified. Head interactionDialog.ts:147-149 builds resolvedOptions, keys them and returns openDialogPromise before anything mounts; the same object is returned at :169. Unchanged since round 2.
"action callbacks drop out of the JSON so freshly built ones don't defeat the comparison" Verified. The key is JSON.stringify(resolvedOptions) (head :148), which drops function-valued properties, so the action: () => … closures rebuilt every tick (head src/stores/video.ts:126, :128) leave {text, size} behind and the key is stable.
"the dedup is per composable instance … and covers only the dialog shown last" Verified. The store holds one instance (head src/stores/video.ts:49), shared by both health monitors and ~16 other showDialog call sites in the same file; tree-wide there are 85 showDialog( call sites across 31 files, each file with its own instance.
"It replaces the hand-rolled 'is it already open' flag the sibling not-growing check carried" Verified in part, and the body says which part. The per-recording notGrowingDialogOpen (base :779) is gone; what replaces it is store-wide and richer than a flag (head :74-131).
"the store tracks which warning owns the surface … a warning about a recording that may already be lost takes the surface from a milder one" Verified. Head :112 yields the surface only when the incoming warning is not a data-loss one or the owner already is; :114 claims it; the release is owner-checked at :92. Only the missing-file site passes true (head :859); the not-growing (:865-867) and web-chunk (:888-890) sites take the false default at :105.
"Every recording health warning names its stream" Verified, and as of this round it is the right name. streamLabel at head :843 converts the external id with internalStreamNameFromExternal before it reaches :858, :866 and :889 — this is what closes 7.2.
"The session opt-out is keyed by message, so silencing the mild not-growing nag does not silence the serious missing-file warning" VerifiedsuppressedRecordingHealthMessages.has(message) at head :106, .add(warning.message) at :96. Because the messages carry a stream label the opt-out is per recording as well; "during this session" does not promise otherwise.
"The Close action's log entry now names the warning it closed, matching the opt-out's" Verified — head :101 against :95, both past tense, both naming their target.
"Nothing but the user settles one of these dialogs" Verified as written: the dialog is persistent: true (head :121) with no timer, so backdrop, Escape and timer dismissal are unreachable.
"The ownership record is compared by identity, so a release can only ever clear the show that created it" Verified. Head :89-93 compares the object; :113-115 creates it and closes over it; :97, :102 and :130 are the three release paths, all passing that object.
"yarn lint and yarn test:unit clean" Plausible; not verified, no PR code is executed here. From the diff: the longest added line is the missing-file message at head :858, at 171 columns against max-len code: 180 (.eslintrc.cjs:47); the three broken-out call arguments are the shape prettier produces at printWidth: 120 (package.json:159), since each message exceeds it on one line and a string literal cannot be split further; added arrow expressions are allowed by allowExpressions (.eslintrc.cjs:84) and each declares : void; both TSPropertySignature members carry the JSDoc jsdoc/require-jsdoc demands (.eslintrc.cjs:39); logUserAction is a declared global (.eslintrc.cjs:18-21).
"three identical showDialog calls mount once, a different message mounts again, the same message with a different variant mounts its own dialog, and mounting resumes after closeDialog" Verified by reading against head interactionDialog.ts:142-176: variant is inside the key at :148, and closeDialog clears openDialogPromise at :174. The test file and the composable are both unchanged this round.

Failure site — base src/stores/video.ts:823-827 (the unguarded showDialog on the Electron monitor tick) and base src/composables/interactionDialog.ts:139-151 (the unconditional remount). Both are in the diff, and the shared one is fixed rather than only the caller.

Entry points

Function Reached from Frequency
showDialog (head src/composables/interactionDialog.ts:142-170) 85 call sites across 31 files; the ones this PR targets are the two setIntervals at head src/stores/video.ts:846 (Electron) and :875 (web), both 15000 ms, through the single instance at head :49 per interval tick — every 15 s per active recording — plus per user action from every other caller
resolveFn / rejectFn wrappers (head interactionDialog.ts:159-166) the onConfirmed / onDismissed props passed in mountDialog (head :127, :130), emitted at src/components/InteractionDialog.vue:253, :246 and :226; resolveFn also from a superseding showDialog at head :153 per user action, per timer expiry, or per superseding dialog
closeDialog (head interactionDialog.ts:172-176) the two health actions (head src/stores/video.ts:98, :103) — still the store's only closeDialog() calls; also InteractionDialog.vue:247, on that component's own instance, which owns no mounted app per user action
showRecordingHealthDialog (head src/stores/video.ts:105-131) the two setIntervals above, via head :857, :865, :888; one monitor per recording stream per interval tick — every 15 s per active recording
releaseRecordingHealthDialog (head src/stores/video.ts:89-93) both dialog actions (head :97, :102) and the .then(release, release) at head :130 when the dialog's promise settles per user action, or per superseding dialog
suppressRecordingHealthDialog (head src/stores/video.ts:94-99) the "Don't show again during this session" button per user action
closeRecordingHealthDialog (head src/stores/video.ts:100-104) the "Close" button per user action
startRecording (head src/stores/video.ts:722, unchanged signature, changed body — this is where streamLabel is computed, at head :843) MiniVideoRecorder.vue:366 with selectedExternalId, and startRecordingAllStreams (base src/stores/video.ts:1177-1181) over namesAvailableStreams (base :108-113) per user action
src/tests/composables/interactionDialog.test.ts body vitest only (vite.config.ts include); unchanged since round 2 never in production code (test file, not a finding)

Invariants

Invariant A — openDialogPromise is defined exactly while this instance's dialog is on screen. Re-enumerated this round over every path that can hide a dialog. closeDialog clears it (head interactionDialog.ts:174); both settle wrappers clear it (:160, :164); a superseding showDialog runs resolveFn?.() at :153 before assigning the new promise at :155, so a stale clear cannot clobber the live one; an action button reaches handleAction (InteractionDialog.vue:251-254) → emit('confirmed')resolveFn; backdrop, Escape and the timer expiry at InteractionDialog.vue:226 all reach emit('dismissed')rejectFn. The single path that hides a dialog without clearing the flag is onUnmounted(unmountDialog) (head :158-160), which would matter only for an instance that outlives the component its onUnmounted bound to — a stale flag there would suppress that exact dialog for the rest of the session. It does not arise for the instance this PR is about: the video store is created at bootstrap from module scope (src/main.ts:90src/stores/omniscientLogger.ts:20useVideoStore()), outside any component setup, so the hook is inert for the instance at head src/stores/video.ts:49. Recorded, not raised. openDialogKey is left set after a close, which is inert because the guard at :149 tests openDialogPromise first.

Invariant B — openRecordingHealthWarning names the warning currently holding the dialog surface, or is undefined. Holds; re-walked in full.

Site that can take the surface away from a health warning Covered?
"Don't show again during this session" → suppressRecordingHealthDialog Yes — releases at head :97 (identity-checked) then closeDialog(); the message also enters the suppressed set at :96, so it never returns.
"Close" → closeRecordingHealthDialog Yes — releases at head :102 then closeDialog(); the promise it leaves pending carries a release bound to that warning object, which cannot clear a later one (finding 1.4, closed in round 6). The full Close-then-reshow sequence was re-walked at this head and the warning does return on the next tick.
a data-loss warning preempting a milder one (head :112) Yes. The new owner is recorded at :114 before showDialog at :116; the superseded warning's .then at :130 runs a microtask later and its identity check at :92 fails.
any other showDialog on the store's instance (~16 sites, including the "recording has stopped" warnings at head :849 and :878) supersedes it Yes — resolveFn?.() at head interactionDialog.ts:153 settles the health promise, .then(release, release) clears the record since that warning is still the owner, and the monitor re-shows it on a later tick.
backdrop / Escape / timer dismissal Not reachable — persistent: true at head :121, no timer.
a closeDialog() from any other site on the same instance Not reachable today — the store has no other closeDialog() call. Recorded, not raised.
nothing releases it on a tick By design, documented at head :110-111, which is what closed 1.3.

Invariant C — the label in a user-facing stream message is the internal name. Stated by AGENTS.md ("Video and snapshot stream names", lines 169-173) and enforced in-tree by internalStreamNameFromExternal (base src/stores/video.ts:124-127). Sites and coverage, re-enumerated at this head: the three health messages (head :858, :866, :889) now go through streamLabel (head :843) and comply — this is finding 7.2, closed; SnapshotTool.vue:285, :448, snapshot.ts:159, ConfigurationVideoView.vue:505, :516 and MiniVideoRecorder.vue:339, :365 already complied; the two "recording has stopped" messages (head :849, :878), the stop alert (base :709) and ConfigurationVideoView.vue:544 still violate it in base, are outside this PR's lines, and were deferred by the author to a separate PR. The monitor diagnostics at head :845, :871, :874 and :894 keep the external id, which is the useful value in a console line and not a user-facing string.

Complexitycomplexity-report.json is present and its head matches HEAD_SHA. Its own figures: changedFiles: 3, functionsMeasured: 123, triggeredCount: 0, truncated: false, triggered: [], thresholds complexity 12 / bump 5 / depth 4. By that report no function the diff adds or changes trips a complexity or nesting trigger, so no complexity finding is raised and nothing was counted by hand. The numbers are quoted as the report's own, not as something this run measured; it names base 607f462, where this checkout's tip is ce3a8d4.

Sections with nothing to report (11)

1. Correctness & Implementation Bugs — ✅ (the surface-ownership state machine was re-walked at this head in both directions: invariant B enumerates every site that can take the surface and none leaves it owned by a warning that is gone, and invariant A enumerates every path that hides a dialog, of which only the inert onUnmounted one does not clear openDialogPromise; the Close-then-reshow sequence from the test plan was traced end to end and does re-mount on the next tick; the ?? fallback added at head :843 was checked for a hole and resolves for every RTSP id that can reach it; the missing-file branch still sits behind the existing window.electronAPI? call at head :855, so the Lite build cannot reach it; no telemetry is read from a Pinia store, no widget Options object is touched, and optional chaining is used where a nested guard would do)

2. Persistence & User Data — ✅ (nothing persisted is added, reshaped or removed, so the inventory is empty: head src/stores/video.ts:75 is an in-memory Set and :87 a plain let, replacing the equally non-persisted ref at base :74; the useBlueOsStorage keys at head :71-72 and cockpit-unprocessed-video-info appear as diff context only, so no cockpit-* key is created, reshaped or migrated and no migration is introduced)

3. AGENTS.md Adherence — ✅ (the "Video and snapshot stream names" rule that finding 7.2 turned on is now satisfied by streamLabel at head :843, using the store's own helper rather than a re-implementation; the rest re-checked: no dependency or package.json change, logUserAction called unimported with both entries past tense and naming their target (head :95, :101), the ponytail: comment at head :110-111 names a ceiling the code has, the comment added at head :841-842 explains why the conversion is needed rather than what it does, showDialog's JSDoc extended alongside its code with typed @param/@returns and no empty or filler block anywhere in the diff, nothing exported without a call site in this PR, and the whole diff is +130/-39 across 3 files with no new abstraction and no rename, reorder or formatter-only reflow)

4. Security — ✅ (all sub-checks run: the credential-in-copy exposure that was 7.2 is closed at head :843, so no stored secret reaches a dialog or the downloadable log any more; no new dependency, no encoded blob or binary-like string, zero non-ASCII bytes in pr.diff so no hidden, zero-width or bidirectional characters, no network call, no eval/Function/v-html, no env var or credential introduced, nothing under scripts/, .github/, src/electron/ or the build config, no licensing impact; pr.json, pr.diff, incremental.diff, complexity-report.json and new-comments.json were each searched for text addressed to the reviewer and contain none — the author's follow-up is a set of claims about the code, each verified against the diff rather than acted on, and the complexity figures are quoted as the report's own)

5. Performance — ✅ (the composable guard still removes an unmount/createApp/mount cycle per 15 s tick at head :846 and :875, and the store guard at head :112 removes the whole showDialog call in the contended case; this round's addition is one .find over streamsCorrespondency (base :124-127) per startRecording, a one-shot per-user-action path, deliberately hoisted out of both interval bodies; per-tick work remains one JSON.stringify of a small object plus one .then microtask, never on mavlink:onIncomingMessage, mavlink:addToDataLake, dataLake:setVariable or dataLake:notifyListeners; no timer, listener, subscription or watch is added, so there is nothing new to tear down, and suppressedRecordingHealthMessages grows by at most one entry per opt-out click)

6. UI / UX — ✅ (the copy now names the recording by the name the user set in the video settings and sees on the recorder widget, and the missing-file message still says what to do — "stopping it and starting a new one"; the dialog is the shared useInteractionDialog shell, so the close-X clause does not apply and its two actions go through the shell's actions prop, which is exempt from the per-action variant and fill clauses; the two-button arrangement and their wording are base's, unchanged here; both actions are logged via logUserAction; all three messages are sentence case; the timed-loop dialog spam the guidelines target is what this PR removes, and no health warning replaces another under the user outside the deliberate data-loss preemption; no v-select-family control, z-index, glass layer, icon-only control, footer layout or padding is touched)

7. Code Quality & Style — ✅ (complexity-report.json reports triggeredCount: 0 over 123 measured functions with truncated: false and its head matching HEAD_SHA, so no added or changed function trips the complexity or depth thresholds and nothing was counted by hand; the longest added line is head :858 at 171 columns, inside max-len code: 180, and the three messages are already in the one-argument-per-line shape prettier produces at printWidth: 120; the new label is one local reusing the store's own internalStreamNameFromExternal in the exact in-tree form … ?? externalId (SnapshotTool.vue:285, :448, snapshot.ts:159) rather than a second copy of the lookup, and it is computed once outside both interval bodies; .then(onFulfilled, onRejected) at head :130 covers the reject path so the await-less call cannot raise an unhandled rejection; no new scoped CSS, no stray any, no comment deleted or reworded over unchanged code; src/stores/video.ts is 1387 lines at base with +36 net, well under the ~2000-line rule, and startRecording still shrinks by ~23 lines)

8. Commit Hygiene — ✅ (pr.json lists two commits, 495a539 composables: interaction-dialog: don't remount a dialog that is already open and 60c96c0 video: route the missing recording file warning through the health dialog, the second amended in place to carry this round's streamLabel fix rather than adding an "address review" or fixup! commit — which is also why PREV_SHA is no longer in the branch; the composable/caller split round 2's 8.1 asked for is preserved, both prefixes have precedent in this history and each describes its own change, the second commit's user-facing pieces interlock so they are not separable behaviour changes riding along, neither message contains #N, owner/repo#N or a closing keyword — Closes #2950 is in the PR body where it belongs — and at +130/-39 across two commits the PR is reviewable in one sitting)

9. Tests — ✅ (no existing test weakened or removed; src/tests/composables/interactionDialog.test.ts is unchanged since round 2 and the composable it exercises is unchanged this round, so its four mount-count assertions were re-walked against head interactionDialog.ts:142-176 and still hold — three identical calls mount once and return one promise object, a different message mounts again and settles the first with { isConfirmed: false }, a same-message/different-variant call gets its own dialog because variant is inside the key at :148, and after closeDialog() clears openDialogPromise at :174 the same options mount a fourth time; the store-side arbitration and the new stream label remain covered only by the manual test-plan items, which is not raised as a finding)

10. Documentation — ✅ (no Lite/Standalone parity change to record in README.md: the missing-file branch already ran only behind window.electronAPI at head :855 and still does, and the web branch at head :875 gains only a stream label in its message; the only public contract touched, showDialog's dedup, has its JSDoc updated in the same hunk at head interactionDialog.ts:79-80)

11. Nitpicks / Optional — ✅ (nothing taste-level new: the comment added at head :841-842 states why the internal name is used, the local is named for what it is, and both log strings keep the established past-tense voice)

Generated by Claude. This is advisory; a human reviewer must still approve.

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.

Recording health monitor reopens the 'cannot get size of the video output file' dialog every 15 seconds

1 participant