diff --git a/CMakeLists.txt b/CMakeLists.txt index fce4814..b48bffa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -239,6 +239,7 @@ if(BUILD_EDIT_PATH_RECORDER) video-path-pilot/job_pipeline.py video-path-pilot/media_reconstruct.py video-path-pilot/normalize_sample.py + video-path-pilot/reliability.py video-path-pilot/validate_sample.py video-path-pilot/validate_video_path.py video-path-pilot/sample.schema.json diff --git a/documentation.md b/documentation.md index 2ec1d8b..b30f321 100644 --- a/documentation.md +++ b/documentation.md @@ -933,3 +933,67 @@ interactive acceptance, privacy policy, and broader real-project coverage. Update this file whenever the recorder architecture, event schema, acceptance results, known limitations, build/run procedure, or roadmap changes. Do not erase failed experiments; document what was attempted and why it changed. + +## Windows reliability and recovery hardening + +The July 2026 Windows editor sessions exposed three independent failure +classes. Several launches failed before Kdenlive reached GUI-ready without +retaining the process error; one session emitted thousands of invalid H.264 NAL +unit errors; and several otherwise normal exit-code-zero sessions were rejected +because their recorder stream lacked `session.end`. The optional +`mltopenfx.dll` also failed MLT registration because it did not match the +packaged MLT ABI. The Windows package excludes that optional module until an +ABI-matched build can be verified. + +The supervisor now records its editor executable, arguments, QProcess error +text, exit code, and exit-status classification in `session.json`. A missing +`session.end` is treated as a recorder-lifecycle failure rather than proof of a +native crash. On Windows, EditPath configures per-user Windows Error Reporting +LocalDumps for both `EditPath.exe` and `kdenlive.exe`; dump files are written +under the active session's `crash-dumps/` directory. This does not require +administrator access, although a managed Windows policy may disable it. + +Kdenlive already saves a modified recorder project every 30 seconds when no +modal dialog is active. EditPath adds an independent recovery layer through +`video-path-pilot/reliability.py`. Once per supervisor heartbeat it validates +`edit.kdenlive`, creates an atomic content-addressed recovery copy when the +project changed, and retains the latest ten periodic copies plus milestone +copies. Synchronous milestone snapshots are attempted when Kdenlive exits and +before dataset finalization. Invalid or truncated XML never replaces a +known-good recovery copy. + +Recovery files have this layout: + +```text +session_.../ + edit.kdenlive + recovery/ + manifest.json + project-000001.kdenlive + project-000002.kdenlive +``` + +`select-recovery` validates the main project, Kdenlive backups, and EditPath +snapshots and reports the newest valid candidate. Recovery evidence is never +removed because rendering, validation, reconstruction, or packaging failed. +Unsaved in-memory changes made less than the Kdenlive save interval before a +hard process kill remain a limitation. + +The recorder's **Export Diagnostics** action creates +`EditPath-Diagnostics-.zip` beside the session directory. It contains +sanitized console and supervisor logs, manifests, raw event streams, recovery +inventory, crash dumps when present, system/build metadata, and a +machine-readable `diagnosis.json`. User home paths are redacted and original +media is not copied. Referenced media is probed and decode-checked when the +packaged FFmpeg tools are available; only its identity, metadata, hashes, and +errors enter the report. + +Useful developer commands are: + +```bash +python3 video-path-pilot/reliability.py snapshot SESSION --reason periodic +python3 video-path-pilot/reliability.py select-recovery SESSION +python3 video-path-pilot/reliability.py restore-recovery SESSION +python3 video-path-pilot/reliability.py diagnostics SESSION +python3 -m unittest video-path-pilot/tests/test_reliability.py +``` diff --git a/packaging/windows/build-editpath.ps1 b/packaging/windows/build-editpath.ps1 index a499769..ce52a05 100644 --- a/packaging/windows/build-editpath.ps1 +++ b/packaging/windows/build-editpath.ps1 @@ -302,6 +302,12 @@ New-Item -ItemType Directory -Force $sitePackages | Out-Null & $python.Source -m pip install --disable-pip-version-check --no-deps --target $sitePackages "zstandard==0.23.0" if ($LASTEXITCODE -ne 0) { Stop-Build "could not install the pinned zstandard runtime into embedded Python." } +# OpenFX is optional and Craft occasionally contributes an mltopenfx.dll built +# against a different MLT ABI. Loading that binary prints a registration error +# on every launch and has caused misleading crash reports. Do not ship it until +# the package can prove that its mlt_register export matches this MLT runtime. +Get-ChildItem $portable -Recurse -File -Filter "mltopenfx.dll" | Remove-Item -Force + $packagedEditPath = Join-Path $bin "edit_path" if (-not (Test-Path (Join-Path $packagedEditPath "__main__.py"))) { Stop-Build "the edit_path reconstruction package is missing from the portable build." diff --git a/video-path-pilot/gui/main.cpp b/video-path-pilot/gui/main.cpp index 7232147..bb89262 100644 --- a/video-path-pilot/gui/main.cpp +++ b/video-path-pilot/gui/main.cpp @@ -64,6 +64,24 @@ QString sessionsRoot() return QDir(videos).filePath(QStringLiteral("EditPathSessions")); } +void configureWindowsCrashDumps(const QString &session) +{ +#ifdef Q_OS_WIN + const QString dumpFolder = QDir(session).filePath(QStringLiteral("crash-dumps")); + QDir().mkpath(dumpFolder); + for (const QString &executable : {QStringLiteral("kdenlive.exe"), QStringLiteral("EditPath.exe")}) { + QSettings dumps(QStringLiteral("HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\Windows Error Reporting\\LocalDumps\\%1").arg(executable), + QSettings::NativeFormat); + dumps.setValue(QStringLiteral("DumpFolder"), QDir::toNativeSeparators(dumpFolder)); + dumps.setValue(QStringLiteral("DumpType"), 2); + dumps.setValue(QStringLiteral("DumpCount"), 5); + dumps.sync(); + } +#else + Q_UNUSED(session) +#endif +} + bool prepareRenderSafetyConfig(const QString &configName, const QString &session, QString *problem) { const QString configRoot = QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation); @@ -237,8 +255,11 @@ class RecorderWindow final : public QMainWindow m_openSession->setEnabled(false); m_openCompleted = new QPushButton(QStringLiteral("Open Dataset Sample")); m_openCompleted->setEnabled(false); + m_exportDiagnostics = new QPushButton(QStringLiteral("Export Diagnostics")); + m_exportDiagnostics->setEnabled(false); secondary->addWidget(m_openSession); secondary->addWidget(m_openCompleted); + secondary->addWidget(m_exportDiagnostics); secondary->addStretch(); layout->addLayout(secondary); m_toggleDetails = new QPushButton(QStringLiteral("Show technical details")); @@ -272,6 +293,7 @@ class RecorderWindow final : public QMainWindow connect(m_openSession, &QPushButton::clicked, this, [this] { openFolder(m_session, QStringLiteral("Session folder")); }); connect(m_openCompleted, &QPushButton::clicked, this, [this] { openFolder(m_session + QStringLiteral("/completed-sample"), QStringLiteral("Generated sample")); }); + connect(m_exportDiagnostics, &QPushButton::clicked, this, &RecorderWindow::exportDiagnostics); connect(&m_editor, qOverload(&QProcess::finished), this, &RecorderWindow::editorFinished); connect(&m_editor, &QProcess::started, this, [this] { writeManifest(QStringLiteral("recording")); @@ -281,6 +303,8 @@ class RecorderWindow final : public QMainWindow m_activity->appendPlainText(QStringLiteral("Kdenlive process started; waiting for its GUI-ready signal…")); }); connect(&m_editor, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) { + m_lastProcessError = QStringLiteral("%1: %2").arg(int(error)).arg(m_editor.errorString()); + m_activity->appendPlainText(QStringLiteral("Editor process error: %1").arg(m_lastProcessError)); if (error == QProcess::FailedToStart) { m_heartbeat.stop(); m_readyPoll.stop(); @@ -296,7 +320,10 @@ class RecorderWindow final : public QMainWindow connect(&m_worker, qOverload(&QProcess::finished), this, &RecorderWindow::workerFinished); m_heartbeat.setInterval(60000); connect(&m_heartbeat, &QTimer::timeout, this, [this] { - if (m_editor.state() != QProcess::NotRunning) writeManifest(QStringLiteral("recording")); + if (m_editor.state() != QProcess::NotRunning) { + writeManifest(QStringLiteral("recording")); + createRecoverySnapshot(QStringLiteral("periodic"), false); + } }); m_readyPoll.setInterval(250); connect(&m_readyPoll, &QTimer::timeout, this, [this] { @@ -334,6 +361,10 @@ class RecorderWindow final : public QMainWindow {QStringLiteral("kdenlive_pid"), qint64(m_editor.processId())}, {QStringLiteral("last_exit_code"), m_lastEditorExitCode}, {QStringLiteral("last_exit_crashed"), m_lastEditorExitCrashed}, + {QStringLiteral("last_exit_status"), m_lastExitStatus}, + {QStringLiteral("last_process_error"), m_lastProcessError}, + {QStringLiteral("editor_program"), m_editorProgram}, + {QStringLiteral("editor_arguments"), m_editorArguments.join(QLatin1Char(' '))}, {QStringLiteral("updated_at_utc"), QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)}}; QSaveFile file(QDir(m_session).filePath(QStringLiteral("session.json"))); const QByteArray encoded = QJsonDocument(manifest).toJson(QJsonDocument::Indented); @@ -356,6 +387,7 @@ class RecorderWindow final : public QMainWindow m_segment = manifest.value(QStringLiteral("segment")).toInt(); m_sessionLabel->setText(m_session); m_openSession->setEnabled(true); + m_exportDiagnostics->setEnabled(true); const QString status = manifest.value(QStringLiteral("status")).toString(); if (status == QStringLiteral("ready_to_finish")) { m_finish->setEnabled(true); @@ -364,7 +396,11 @@ class RecorderWindow final : public QMainWindow m_showExistingCompletion = true; } else if (status == QStringLiteral("recovery_available") || status == QStringLiteral("recording")) { const QString project = QDir(previous).filePath(QStringLiteral("edit.kdenlive")); - const bool canRecover = QFileInfo::exists(project); + QProcess recovery; + recovery.setProcessChannelMode(QProcess::MergedChannels); + recovery.start(pythonExecutable(), {m_repoRoot + QStringLiteral("/video-path-pilot/reliability.py"), QStringLiteral("restore-recovery"), previous}); + const bool recoveryChecked = recovery.waitForFinished(15000) && recovery.exitCode() == 0; + const bool canRecover = recoveryChecked && QFileInfo::exists(project); m_recover->setVisible(canRecover); offerConfirmedNewSession(); writeManifest(QStringLiteral("recovery_available")); @@ -372,9 +408,11 @@ class RecorderWindow final : public QMainWindow "Kdenlive closed unexpectedly, but your saved work is available. Click Resume Editing to continue where you left off.") : QStringLiteral("Kdenlive closed before the project could be saved. The session folder is available for technical review."), true); - m_activity->appendPlainText(canRecover - ? QStringLiteral("Interrupted session detected. Recovery will create recording segment %1.").arg(m_segment + 1) - : QStringLiteral("Interrupted session detected, but edit.kdenlive is missing.")); + m_activity->appendPlainText( + canRecover ? QStringLiteral("Interrupted session detected. The newest valid recovery was selected; segment %1 will record resumed work.") + .arg(m_segment + 1) + : QStringLiteral("Interrupted session detected, but no valid recovery project was found: %1") + .arg(QString::fromUtf8(recovery.readAll()).trimmed())); m_showExistingCompletion = true; } else if (status == QStringLiteral("packaged")) { m_openCompleted->setEnabled(true); @@ -398,8 +436,10 @@ class RecorderWindow final : public QMainWindow setStatus(QStringLiteral("EditPath could not create a folder for this edit. Check that your Videos folder is writable, then try again."), true); return; } + configureWindowsCrashDumps(m_session); m_sessionLabel->setText(m_session); m_openSession->setEnabled(true); + m_exportDiagnostics->setEnabled(true); m_openCompleted->setEnabled(false); writeManifest(QStringLiteral("created")); launchSegment(); @@ -407,6 +447,7 @@ class RecorderWindow final : public QMainWindow void launchSegment() { + configureWindowsCrashDumps(m_session); const QString runtimeProblem = guiRuntimeProblem(); if (!runtimeProblem.isEmpty()) { setStatus(runtimeProblem, true); @@ -468,6 +509,9 @@ class RecorderWindow final : public QMainWindow arguments = {raw}; if (QFileInfo::exists(project)) arguments.append(project); #endif + m_editorProgram = program; + m_editorArguments = arguments; + m_lastProcessError.clear(); m_editor.start(program, arguments); } @@ -478,6 +522,8 @@ class RecorderWindow final : public QMainWindow m_launchProgress->setVisible(false); m_lastEditorExitCode = exitCode; m_lastEditorExitCrashed = exitStatus != QProcess::NormalExit || exitCode != 0; + m_lastExitStatus = exitStatus == QProcess::NormalExit ? QStringLiteral("normal_exit") : QStringLiteral("crash_exit"); + createRecoverySnapshot(m_lastEditorExitCrashed ? QStringLiteral("crash_recovery") : QStringLiteral("editor_exit"), true); if (m_lastEditorExitCrashed) writeManifest(QStringLiteral("recovery_available")); m_title->setText(QStringLiteral("

EditPath

Kdenlive has closed. Your work is being checked and saved.

")); m_instructions->setVisible(true); @@ -492,6 +538,7 @@ class RecorderWindow final : public QMainWindow void finishSession() { + createRecoverySnapshot(QStringLiteral("pre_finish"), true); m_finish->setEnabled(false); m_workerPurpose = QStringLiteral("finalize"); m_workerTranscript.clear(); @@ -502,6 +549,35 @@ class RecorderWindow final : public QMainWindow m_worker.start(pythonExecutable(), {m_repoRoot + QStringLiteral("/video-path-pilot/job_pipeline.py"), QStringLiteral("finalize-freeform"), m_session}); } + void createRecoverySnapshot(const QString &reason, bool wait) + { + if (m_session.isEmpty() || !QFileInfo::exists(QDir(m_session).filePath(QStringLiteral("edit.kdenlive")))) return; + const QString script = m_repoRoot + QStringLiteral("/video-path-pilot/reliability.py"); + const QStringList arguments{script, QStringLiteral("snapshot"), m_session, QStringLiteral("--reason"), reason}; + if (wait) { + QProcess snapshot; + snapshot.setProcessChannelMode(QProcess::MergedChannels); + snapshot.start(pythonExecutable(), arguments); + if (!snapshot.waitForFinished(15000) || snapshot.exitCode() != 0) { + m_activity->appendPlainText(QStringLiteral("Recovery snapshot warning: %1").arg(QString::fromUtf8(snapshot.readAll()).trimmed())); + } else { + m_activity->appendPlainText(QStringLiteral("Recovery copy saved: %1").arg(QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss")))); + } + } else { + QProcess::startDetached(pythonExecutable(), arguments, m_repoRoot); + } + } + + void exportDiagnostics() + { + if (m_session.isEmpty() || m_worker.state() != QProcess::NotRunning) return; + m_workerPurpose = QStringLiteral("diagnostics"); + m_workerTranscript.clear(); + m_exportDiagnostics->setEnabled(false); + setStatus(QStringLiteral("Creating a privacy-safe diagnostics ZIP…")); + m_worker.start(pythonExecutable(), {m_repoRoot + QStringLiteral("/video-path-pilot/reliability.py"), QStringLiteral("diagnostics"), m_session}); + } + void readWorker() { const QString output = QString::fromUtf8(m_worker.readAllStandardOutput()).trimmed(); @@ -572,6 +648,14 @@ class RecorderWindow final : public QMainWindow QStringLiteral("

We couldn't create the sample yet

Your project and rendered video have not been deleted or changed.

")); setStatus(friendlyFinalizationError(m_workerTranscript), true); } + } else if (m_workerPurpose == QStringLiteral("diagnostics")) { + m_exportDiagnostics->setEnabled(true); + if (success) { + setStatus(QStringLiteral("Diagnostics ZIP created. Its location is shown in technical details.")); + QApplication::clipboard()->setText(m_workerTranscript.trimmed()); + } else { + setStatus(QStringLiteral("Diagnostics could not be exported. Your project and recovery copies are unaffected."), true); + } } m_workerPurpose.clear(); } @@ -618,13 +702,15 @@ class RecorderWindow final : public QMainWindow } QString m_repoRoot, m_session, m_sessionId, m_configName, m_workerPurpose, m_readyFile, m_workerTranscript; + QString m_lastProcessError, m_lastExitStatus{QStringLiteral("not_started")}, m_editorProgram; + QStringList m_editorArguments; int m_segment{0}; int m_lastEditorExitCode{0}; QProcess m_editor, m_worker; QTimer m_heartbeat, m_readyPoll; bool m_showExistingCompletion{false}, m_lastEditorExitCrashed{false}, m_confirmNewSession{false}; QLabel *m_title{}, *m_instructions{}, *m_status{}, *m_sessionLabel{}; - QPushButton *m_start{}, *m_recover{}, *m_finish{}, *m_openSession{}, *m_openCompleted{}, *m_toggleDetails{}; + QPushButton *m_start{}, *m_recover{}, *m_finish{}, *m_openSession{}, *m_openCompleted{}, *m_exportDiagnostics{}, *m_toggleDetails{}; QPlainTextEdit *m_activity{}; QProgressBar *m_launchProgress{}; }; diff --git a/video-path-pilot/reliability.py b/video-path-pilot/reliability.py new file mode 100644 index 0000000..a9950b1 --- /dev/null +++ b/video-path-pilot/reliability.py @@ -0,0 +1,398 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-only +"""Crash diagnostics and independent project recovery for EditPath sessions.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import platform +import re +import shutil +import subprocess +import tempfile +import xml.etree.ElementTree as ET +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +SENSITIVE_PATH = re.compile(r"(?i)(?:[A-Z]:\\Users\\[^\\\r\n]+|/home/[^/\r\n]+)") +DECODER_PATTERNS = { + "invalid_nal_unit_size": re.compile(r"Invalid NAL unit size", re.I), + "missing_picture": re.compile(r"missing picture in access unit", re.I), + "bad_source_image": re.compile(r"bad src image pointers", re.I), +} + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for block in iter(lambda: source.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def valid_project(path: Path) -> tuple[bool, str]: + if not path.is_file() or path.stat().st_size == 0: + return False, "missing or empty" + try: + root = ET.parse(path).getroot() + except (ET.ParseError, OSError) as error: + return False, str(error) + return (root.tag in {"mlt", "kdenlive"}), f"unexpected root element {root.tag!r}" + + +def _read_manifest(path: Path) -> dict[str, Any]: + if not path.exists(): + return {"schema_version": "1.0", "snapshots": []} + try: + value = json.loads(path.read_text(encoding="utf-8")) + return value if isinstance(value, dict) else {"schema_version": "1.0", "snapshots": []} + except (OSError, json.JSONDecodeError): + return {"schema_version": "1.0", "snapshots": []} + + +def _atomic_json(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as output: + json.dump(value, output, indent=2, ensure_ascii=False) + output.write("\n") + temporary = Path(output.name) + os.replace(temporary, path) + + +def create_snapshot(session: Path, reason: str = "periodic", keep: int = 10) -> dict[str, Any]: + project = session / "edit.kdenlive" + valid, detail = valid_project(project) + if not valid: + raise ValueError(f"project snapshot rejected: {detail}") + recovery = session / "recovery" + recovery.mkdir(parents=True, exist_ok=True) + manifest_path = recovery / "manifest.json" + manifest = _read_manifest(manifest_path) + snapshots = manifest.setdefault("snapshots", []) + digest = sha256(project) + if snapshots and snapshots[-1].get("sha256") == digest and reason == "periodic": + return {"created": False, "reason": "unchanged", "snapshot": snapshots[-1]} + + sequence = max((int(item.get("sequence", 0)) for item in snapshots), default=0) + 1 + name = f"project-{sequence:06d}.kdenlive" + target = recovery / name + temporary = recovery / f".{name}.tmp" + shutil.copy2(project, temporary) + copied_valid, copied_detail = valid_project(temporary) + if not copied_valid or sha256(temporary) != digest: + temporary.unlink(missing_ok=True) + raise ValueError(f"copied project snapshot rejected: {copied_detail}") + os.replace(temporary, target) + entry = { + "sequence": sequence, + "timestamp_utc": utc_now(), + "file": name, + "sha256": digest, + "bytes": target.stat().st_size, + "reason": reason, + "validation": "valid_xml", + } + snapshots.append(entry) + + milestones = {"pre_render", "pre_finish", "crash_recovery"} + periodic = [item for item in snapshots if item.get("reason") not in milestones] + remove = periodic[:-max(1, keep)] + remove_names = {item.get("file") for item in remove} + for name_to_remove in remove_names: + if isinstance(name_to_remove, str): + (recovery / name_to_remove).unlink(missing_ok=True) + manifest["snapshots"] = [item for item in snapshots if item.get("file") not in remove_names] + manifest["updated_at_utc"] = utc_now() + _atomic_json(manifest_path, manifest) + return {"created": True, "snapshot": entry} + + +def select_recovery(session: Path) -> dict[str, Any]: + candidates: list[dict[str, Any]] = [] + recovery = session / "recovery" + manifest = _read_manifest(recovery / "manifest.json") + paths = [session / "edit.kdenlive", *session.glob("edit_backup*.kdenlive")] + paths.extend(recovery / str(item["file"]) for item in manifest.get("snapshots", []) if item.get("file")) + for path in paths: + valid, detail = valid_project(path) + if path.exists(): + candidates.append( + { + "path": str(path), + "valid": valid, + "validation": detail if not valid else "valid_xml", + "mtime_ns": path.stat().st_mtime_ns, + "bytes": path.stat().st_size, + "sha256": sha256(path) if valid else None, + } + ) + valid_candidates = [item for item in candidates if item["valid"]] + selected = max(valid_candidates, key=lambda item: item["mtime_ns"]) if valid_candidates else None + return {"selected": selected, "candidates": sorted(candidates, key=lambda item: item["mtime_ns"], reverse=True)} + + +def restore_recovery(session: Path) -> dict[str, Any]: + report = select_recovery(session) + selected = report["selected"] + if not selected: + raise ValueError("no valid recovery candidate exists") + source = Path(selected["path"]) + project = session / "edit.kdenlive" + if source == project: + return {"restored": False, "reason": "main_project_is_newest", "selected": selected} + recovery = session / "recovery" + recovery.mkdir(parents=True, exist_ok=True) + if project.exists(): + preserved = recovery / f"pre-restore-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}.kdenlive" + shutil.copy2(project, preserved) + temporary = session / ".edit.kdenlive.restore" + shutil.copy2(source, temporary) + valid, detail = valid_project(temporary) + if not valid or sha256(temporary) != selected["sha256"]: + temporary.unlink(missing_ok=True) + raise ValueError(f"recovery restore verification failed: {detail}") + os.replace(temporary, project) + return {"restored": True, "selected": selected, "destination": str(project)} + + +def sanitize(text: str) -> str: + return SENSITIVE_PATH.sub("", text) + + +def windows_evidence() -> dict[str, Any]: + if os.name != "nt": + return {"available": False, "reason": "not_windows"} + powershell = shutil.which("powershell.exe") or shutil.which("powershell") + if not powershell: + return {"available": False, "reason": "powershell_unavailable"} + commands = { + "hardware": ( + "Get-CimInstance Win32_OperatingSystem,Win32_ComputerSystem,Win32_VideoController " + "| Select-Object __CLASS,Caption,Version,OSArchitecture,TotalPhysicalMemory,Name,DriverVersion " + "| ConvertTo-Json -Depth 3" + ), + "application_errors": ( + "$start=(Get-Date).AddDays(-2); " + "Get-WinEvent -FilterHashtable @{LogName='Application';StartTime=$start} -ErrorAction SilentlyContinue " + "| Where-Object {$_.ProviderName -in @('Application Error','Windows Error Reporting') " + "-and $_.Message -match 'kdenlive|EditPath'} " + "| Select-Object -First 20 TimeCreated,Id,ProviderName,LevelDisplayName,Message " + "| ConvertTo-Json -Depth 3" + ), + } + evidence: dict[str, Any] = {"available": True} + for name, command in commands.items(): + try: + completed = subprocess.run( + [powershell, "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + text=True, + timeout=20, + ) + evidence[name] = { + "exit_code": completed.returncode, + "output": sanitize(completed.stdout[-30000:]), + "error": sanitize(completed.stderr[-4000:]), + } + except (OSError, subprocess.TimeoutExpired) as error: + evidence[name] = {"error": str(error)} + return evidence + + +def _event_summary(session: Path) -> dict[str, Any]: + files = sorted(session.glob("raw-events-*.jsonl")) + total = 0 + ended = False + malformed = 0 + for path in files: + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + try: + event = json.loads(line) + total += 1 + ended = event.get("event_type") == "session.end" + except json.JSONDecodeError: + malformed += 1 + return {"segments": len(files), "events": total, "malformed_lines": malformed, "session_end_recorded": ended} + + +def _decoder_counts(session: Path) -> dict[str, int]: + combined = "\n".join(path.read_text(encoding="utf-8", errors="replace") for path in session.glob("kdenlive-console-*.log")) + return {name: len(pattern.findall(combined)) for name, pattern in DECODER_PATTERNS.items()} + + +def project_media(session: Path) -> list[Path]: + project = session / "edit.kdenlive" + valid, _ = valid_project(project) + if not valid: + return [] + root = ET.parse(project).getroot() + project_root = Path(root.get("root") or project.parent) + found: list[Path] = [] + for property_element in root.findall(".//property[@name='resource']"): + value = (property_element.text or "").strip() + if not value or value in {"black", "blue"} or value.startswith(("#", "color:")): + continue + if re.match(r"^[0-9.]+:", value) and not re.match(r"^[A-Za-z]:[\\/]", value): + value = value.split(":", 1)[1] + path = Path(value) + if not path.is_absolute(): + path = project_root / path + try: + path = path.resolve() + except OSError: + pass + if path not in found: + found.append(path) + return found + + +def media_preflight(session: Path) -> list[dict[str, Any]]: + ffprobe = shutil.which("ffprobe") + ffmpeg = shutil.which("ffmpeg") + results: list[dict[str, Any]] = [] + for index, path in enumerate(project_media(session), 1): + item: dict[str, Any] = { + "asset_id": f"asset-{index:03d}", + "filename": path.name, + "exists": path.is_file(), + "bytes": path.stat().st_size if path.is_file() else None, + "sha256": sha256(path) if path.is_file() else None, + } + if path.is_file() and ffprobe: + probe = subprocess.run( + [ffprobe, "-v", "error", "-show_streams", "-show_format", "-of", "json", str(path)], + capture_output=True, + text=True, + timeout=30, + ) + item["ffprobe_passed"] = probe.returncode == 0 + item["ffprobe"] = json.loads(probe.stdout) if probe.returncode == 0 else {"error": sanitize(probe.stderr[-4000:])} + if path.is_file() and ffmpeg: + try: + decode = subprocess.run( + [ffmpeg, "-v", "error", "-i", str(path), "-map", "0:v:0?", "-map", "0:a:0?", "-f", "null", "-"], + capture_output=True, + text=True, + timeout=120, + ) + item["decode_passed"] = decode.returncode == 0 + item["decode_errors"] = sanitize(decode.stderr[-8000:]) + except subprocess.TimeoutExpired: + item["decode_passed"] = False + item["decode_errors"] = "decode validation timed out after 120 seconds" + results.append(item) + return results + + +def classify(session: Path) -> dict[str, Any]: + try: + manifest = json.loads((session / "session.json").read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + manifest = {} + events = _event_summary(session) + decoder = _decoder_counts(session) + status = str(manifest.get("status", "unknown")) + crashed = bool(manifest.get("last_exit_crashed")) + if status == "start_failed": + kind = "startup_failure" + elif crashed: + kind = "process_crash" + elif any(decoder.values()): + kind = "media_decode_error" + elif not events["session_end_recorded"]: + kind = "missing_session_end" + elif status == "validation_failed": + kind = "recording_validation_failure" + else: + kind = "none" + return { + "schema_version": "1.0", + "generated_at_utc": utc_now(), + "failure_type": kind, + "process": "kdenlive", + "exit_code": manifest.get("last_exit_code"), + "exit_status": manifest.get("last_exit_status", "unknown"), + "process_error": manifest.get("last_process_error", ""), + "events": events, + "decoder_errors": decoder, + "media_preflight": media_preflight(session), + "recovery": select_recovery(session), + } + + +def export_diagnostics(session: Path, destination: Path | None = None) -> Path: + destination = destination or session.parent / f"EditPath-Diagnostics-{session.name}.zip" + diagnosis = classify(session) + allow_names = { + "session.json", + "supervisor-activity.log", + "recovery/manifest.json", + } + files = [ + path + for path in session.rglob("*") + if path.is_file() + and ( + path.relative_to(session).as_posix() in allow_names + or path.name.startswith(("kdenlive-console-", "raw-events-")) + or path.suffix.lower() in {".dmp"} + ) + ] + system = { + "generated_at_utc": utc_now(), + "platform": platform.platform(), + "python": platform.python_version(), + "machine": platform.machine(), + "package_commit": os.environ.get("EDIT_PATH_BUILD_COMMIT", "unknown"), + "windows": windows_evidence(), + } + with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr("diagnosis.json", sanitize(json.dumps(diagnosis, indent=2))) + archive.writestr("system.json", sanitize(json.dumps(system, indent=2))) + for path in files: + relative = path.relative_to(session).as_posix() + if path.suffix.lower() == ".dmp": + archive.write(path, f"session/{relative}") + else: + archive.writestr(f"session/{relative}", sanitize(path.read_text(encoding="utf-8", errors="replace"))) + return destination + + +def main() -> int: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + snapshot = subparsers.add_parser("snapshot") + snapshot.add_argument("session", type=Path) + snapshot.add_argument("--reason", default="periodic") + snapshot.add_argument("--keep", type=int, default=10) + recovery = subparsers.add_parser("select-recovery") + recovery.add_argument("session", type=Path) + restore = subparsers.add_parser("restore-recovery") + restore.add_argument("session", type=Path) + diagnostics = subparsers.add_parser("diagnostics") + diagnostics.add_argument("session", type=Path) + diagnostics.add_argument("--output", type=Path) + arguments = parser.parse_args() + if arguments.command == "snapshot": + result: Any = create_snapshot(arguments.session, arguments.reason, arguments.keep) + elif arguments.command == "select-recovery": + result = select_recovery(arguments.session) + elif arguments.command == "restore-recovery": + result = restore_recovery(arguments.session) + else: + result = {"diagnostics": str(export_diagnostics(arguments.session, arguments.output))} + print(json.dumps(result, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/video-path-pilot/tests/test_reliability.py b/video-path-pilot/tests/test_reliability.py new file mode 100644 index 0000000..6cb7d20 --- /dev/null +++ b/video-path-pilot/tests/test_reliability.py @@ -0,0 +1,100 @@ +# SPDX-License-Identifier: GPL-3.0-only + +import json +import tempfile +import unittest +import zipfile +from pathlib import Path + +import sys + +sys.path.insert(0, str(Path(__file__).parents[1])) +from reliability import classify, create_snapshot, export_diagnostics, restore_recovery, select_recovery + + +class ReliabilityTests(unittest.TestCase): + def project(self, root: Path, content: str = "") -> Path: + path = root / "edit.kdenlive" + path.write_text(content, encoding="utf-8") + return path + + def test_snapshot_is_atomic_versioned_and_deduplicated(self): + with tempfile.TemporaryDirectory() as temporary: + session = Path(temporary) + project = self.project(session) + first = create_snapshot(session) + duplicate = create_snapshot(session) + project.write_text("", encoding="utf-8") + second = create_snapshot(session) + self.assertTrue(first["created"]) + self.assertFalse(duplicate["created"]) + self.assertTrue(second["created"]) + self.assertEqual(len(list((session / "recovery").glob("project-*.kdenlive"))), 2) + + def test_invalid_project_never_replaces_good_snapshot(self): + with tempfile.TemporaryDirectory() as temporary: + session = Path(temporary) + project = self.project(session) + create_snapshot(session) + project.write_text("", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "rejected"): + create_snapshot(session) + selected = select_recovery(session)["selected"] + self.assertIn("recovery", selected["path"]) + restored = restore_recovery(session) + self.assertTrue(restored["restored"]) + self.assertEqual(project.read_text(), "") + + def test_retention_preserves_milestones(self): + with tempfile.TemporaryDirectory() as temporary: + session = Path(temporary) + project = self.project(session) + create_snapshot(session, "pre_render", keep=2) + for index in range(4): + project.write_text(f"{index}", encoding="utf-8") + create_snapshot(session, "periodic", keep=2) + manifest = json.loads((session / "recovery/manifest.json").read_text()) + self.assertEqual(sum(item["reason"] == "periodic" for item in manifest["snapshots"]), 2) + self.assertTrue(any(item["reason"] == "pre_render" for item in manifest["snapshots"])) + + def test_missing_session_end_is_not_classified_as_native_crash(self): + with tempfile.TemporaryDirectory() as temporary: + session = Path(temporary) + self.project(session) + (session / "session.json").write_text( + json.dumps({"status": "validation_failed", "last_exit_code": 0, "last_exit_crashed": False}) + ) + (session / "raw-events-001.jsonl").write_text( + json.dumps({"event_type": "session.start"}) + "\n", encoding="utf-8" + ) + self.assertEqual(classify(session)["failure_type"], "missing_session_end") + + def test_decoder_errors_take_precedence_over_missing_end(self): + with tempfile.TemporaryDirectory() as temporary: + session = Path(temporary) + self.project(session) + (session / "session.json").write_text(json.dumps({"status": "validation_failed"})) + (session / "kdenlive-console-001.log").write_text("Invalid NAL unit size\nmissing picture in access unit") + diagnosis = classify(session) + self.assertEqual(diagnosis["failure_type"], "media_decode_error") + self.assertEqual(diagnosis["decoder_errors"]["invalid_nal_unit_size"], 1) + + def test_diagnostics_zip_sanitizes_home_paths(self): + with tempfile.TemporaryDirectory() as temporary: + session = Path(temporary) / "session-test" + session.mkdir() + self.project(session) + (session / "session.json").write_text(json.dumps({"status": "start_failed"})) + (session / "kdenlive-console-001.log").write_text(r"C:\Users\Alice\Videos\secret.mp4") + output = export_diagnostics(session) + with zipfile.ZipFile(output) as archive: + text = archive.read("session/kdenlive-console-001.log").decode() + self.assertNotIn("Alice", text) + self.assertIn("", text) + diagnosis_text = archive.read("diagnosis.json").decode() + self.assertNotIn(str(Path.home()), diagnosis_text) + self.assertEqual(json.loads(diagnosis_text)["failure_type"], "startup_failure") + + +if __name__ == "__main__": + unittest.main()