Hmbown/CodeWhale · error · ExecError

recording already running

Error message

recording ${recording.id} already running

What it means

`recordingStart` refuses to start a second screen recording while one is already active in this backend instance. The HarmonyOS backend supports exactly one snapshot-series recording at a time (`recording` singleton); starting another without stopping the first throws with the existing recording's id.

Solutions

  1. Call `recordingStop({ id: <running id from the error message> })` before starting a new recording.
  2. Check `recordingStatus()`/`recordingList()` before calling recordingStart.
  3. Reuse the existing recording instead of starting a new one.

Example fix

// before
await backend.recordingStart({ intervalMs: 400 }); // throws if one is running
// after
const st = backend.recordingStatus?.({ id: lastRecordingId });
if (st?.running) await backend.recordingStop({ id: lastRecordingId });
const rec = await backend.recordingStart({ intervalMs: 400 });
Defensive patterns

Strategy: validation

Validate before calling

const st = backend.recordingStatus?.({ id: activeRecordingId });
if (st?.running) throw new Error("a recording is already active: " + activeRecordingId);

Type guard

const canStart = !recordingState.active;

Try / catch

try { rec = await backend.recordingStart({ intervalMs: 400 }); }
catch (e) {
  const m = String(e.message).match(/recording (\S+) already running/);
  if (m) await backend.recordingStop({ id: m[1] }).catch(() => {});
  else throw e;
}

Prevention

When it happens

Trigger: Calling `recordingStart()` when a previous `recordingStart()` has not yet been finished with `recordingStop()` (backends/harmonyos.mjs:273). The message embeds the currently running recording's id.

Common situations: Retrying a failed recordingStop and then starting again; two parallel automation tasks both starting recordings; forgetting to stop a recording after capturing an action sequence.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/89c1b0348db86406. Report an issue: GitHub.

Appendix: source

Thrown at crates/tui/plugins/computer-use/src/backends/harmonyos.mjs:273

    },
    set_value: async ({ target, value }) => {
      const b = await centerOf(target);
      await uiInput(["click", String(b.cx), String(b.cy)]);
      await new Promise((r) => setTimeout(r, 300));
      await uiInput(["inputText", String(b.cx), String(b.cy), escDeviceText(String(value))]);
      return { action_sent: true, strategy: "uitest-element" };
    },
    select_text: async () => { throw new ExecError("select_text is not exposed by uitest dumpLayout/uiInput on the harmony backend"); },
    perform_action: async ({ target, action }) => {
      const b = await centerOf(target);
      if (action === "longClick") return uiInput(["longClick", String(b.cx), String(b.cy)]);
      return uiInput(["click", String(b.cx), String(b.cy)]);
    },
    read_clipboard: async () => { throw new ExecError("clipboard read is not exposed by hdc on current HarmonyOS builds"); },
    write_clipboard: async () => { throw new ExecError("clipboard write is not exposed by hdc on current HarmonyOS builds"); },
    cursor_position: async () => { throw new ExecError("cursor position does not exist on touch devices"); },
    recordingStart: async ({ intervalMs = 400 } = {}) => {
      if (recording) throw new ExecError(`recording ${recording.id} already running`);
      if (!(await have("ffmpeg"))) throw new ExecError("ffmpeg is required on the host to mux harmony snapshot-series recordings");
      const id = crypto.randomBytes(4).toString("hex");
      const dir = fs.mkdtempSync(path.join(os.tmpdir(), `cu-rec-${id}-`));
      const startedAt = new Date().toISOString();
      const rec = recording = { id, dir, startedAt, intervalMs, seq: 0, controller: new AbortController() };
      const tick = () => {
        if (rec.stopped || rec.pending) return rec.pending;
        rec.pending = (async () => {
          const opts = { timeoutMs: 15_000, signal: rec.controller.signal };
          const remote = `${DEVICE_TMP}-rec-${id}-${String(rec.seq).padStart(5, "0")}.jpeg`;
          try {
            await deviceOut(["snapshot_display", "-f", remote], opts);
            await exec.pullFile(remote, path.join(dir, `f${String(rec.seq).padStart(5, "0")}.jpeg`), opts);
            rec.seq++;
          } finally { await shell(["rm", "-f", remote], opts).catch(() => {}); }
        })().catch(() => {}).finally(() => { rec.pending = null; });
        return rec.pending;
      };

View on GitHub (pinned to 73e0f67d83)