Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions .github/workflows/LinuxInstall.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
name: Linux_AppImage

on:
# Packaging runs on every pull request as well, so a change that breaks the MSI or
# the AppImage fails here instead of surviving until someone dispatches a run by
# hand. Narrow this with a `paths:` filter if the runner cost becomes a problem.
pull_request:

# Keep manual packaging qualification available for exact source SHAs.
# Dispatch-only. PRs targeting `dev` run ci.yml; MSI/AppImage qualification
# stays on the release-candidate path with an exact source SHA.
workflow_dispatch:
inputs:
source_sha:
Expand Down Expand Up @@ -40,13 +36,13 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
path: loop
ref: ${{ inputs.source_sha || github.event.pull_request.head.sha }}
ref: ${{ inputs.source_sha }}
fetch-depth: 0

- name: Verify exact source SHA
working-directory: loop
env:
EXPECTED_SOURCE_SHA: ${{ inputs.source_sha || github.event.pull_request.head.sha }}
EXPECTED_SOURCE_SHA: ${{ inputs.source_sha }}
run: |
if ! [[ "$EXPECTED_SOURCE_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "::error::source_sha must be a full 40-character Git SHA"
Expand Down
12 changes: 4 additions & 8 deletions .github/workflows/WindowsInstall.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
name: Windows_MSI

on:
# Packaging runs on every pull request as well, so a change that breaks the MSI or
# the AppImage fails here instead of surviving until someone dispatches a run by
# hand. Narrow this with a `paths:` filter if the runner cost becomes a problem.
pull_request:

# Keep manual packaging qualification available for exact source SHAs.
# Dispatch-only. PRs targeting `dev` run ci.yml; MSI/AppImage qualification
# stays on the release-candidate path with an exact source SHA.
workflow_dispatch:
inputs:
source_sha:
Expand Down Expand Up @@ -35,14 +31,14 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
path: loop
ref: ${{ inputs.source_sha || github.event.pull_request.head.sha }}
ref: ${{ inputs.source_sha }}
fetch-depth: 0

- name: Verify exact source SHA
working-directory: loop
shell: pwsh
env:
EXPECTED_SOURCE_SHA: ${{ inputs.source_sha || github.event.pull_request.head.sha }}
EXPECTED_SOURCE_SHA: ${{ inputs.source_sha }}
run: |
if ($env:EXPECTED_SOURCE_SHA -notmatch "^[0-9a-fA-F]{40}$") {
throw "source_sha must be a full 40-character Git SHA"
Expand Down
68 changes: 54 additions & 14 deletions LoopLibCore/sources/pdfdiff.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@
#include "pdfcms.h"
#include "pdfconstants.h"
#include "pdfalgorithmlcs.h"
#include "pdfjobscheduler.h"
#include "pdfpainter.h"

#include <QtConcurrent/QtConcurrent>
#include <QUuid>

#include "pdfdbgheap.h"

Expand Down Expand Up @@ -138,12 +139,38 @@ void PDFDiff::start()

if (m_options.testFlag(Asynchronous))
{
m_futureWatcher = std::nullopt;
m_futureWatcher.emplace();
if (m_jobFinishedConnection)
{
disconnect(m_jobFinishedConnection);
m_jobFinishedConnection = {};
}

pdf::PDFJobSpec spec;
spec.jobId = QStringLiteral("pdf-diff-%1").arg(QUuid::createUuid().toString(QUuid::WithoutBraces));
spec.kind = pdf::PDFJobKind::Batch;
spec.priority = pdf::PDFJobPriority::Background;
spec.operationId = QStringLiteral("pdf.diff");

m_future = QtConcurrent::run(std::bind(&PDFDiff::perform, this));
connect(&*m_futureWatcher, &QFutureWatcher<PDFDiffResult>::finished, this, &PDFDiff::onComparationPerformed);
m_futureWatcher->setFuture(m_future);
m_activeJobId = pdf::PDFJobScheduler::global().submit(spec, [this](pdf::PDFJobContext& context)
{
if (context.isCancellationRequested())
{
m_cancelled = true;
return;
}
m_result = perform(); });

m_jobFinishedConnection = connect(&pdf::PDFJobScheduler::global(),
&pdf::PDFJobScheduler::jobFinished,
this,
[this](const pdf::PDFJobSnapshot& snapshot)
{
if (snapshot.jobId != m_activeJobId)
{
return;
}
onComparationPerformed(snapshot.status == pdf::PDFJobStatus::Cancelled);
});
}
else
{
Expand All @@ -155,12 +182,20 @@ void PDFDiff::start()

void PDFDiff::stop()
{
if (m_futureWatcher && !m_futureWatcher->isFinished())
if (m_activeJobId.isEmpty())
{
return;
}

const QString jobId = m_activeJobId;
m_cancelled = true;
pdf::PDFJobScheduler::global().cancel(jobId);
pdf::PDFJobScheduler::global().waitForFinished(jobId);
m_activeJobId.clear();
if (m_jobFinishedConnection)
{
// Do stop only if process doesn't finished already.
// If we are finished, we do not want to set cancelled state.
m_cancelled = true;
m_futureWatcher->waitForFinished();
disconnect(m_jobFinishedConnection);
m_jobFinishedConnection = {};
}
}

Expand Down Expand Up @@ -900,10 +935,15 @@ void PDFDiff::finalizeGraphicsPieces(PDFDiffPageContext& context)
std::copy(hash.data(), hash.data() + size, context.pageHash.data());
}

void PDFDiff::onComparationPerformed()
void PDFDiff::onComparationPerformed(bool cancelled)
{
m_cancelled = false;
m_result = m_future.result();
m_cancelled = cancelled;
m_activeJobId.clear();
if (m_jobFinishedConnection)
{
disconnect(m_jobFinishedConnection);
m_jobFinishedConnection = {};
}
Q_EMIT comparationFinished();
}

Expand Down
9 changes: 4 additions & 5 deletions LoopLibCore/sources/pdfdiff.h
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,8 @@
#include "pdfalgorithmlcs.h"
#include "pdfdocumenttextflow.h"

#include <QMetaObject>
#include <QObject>
#include <QFuture>
#include <QFutureWatcher>

#include <atomic>

Expand Down Expand Up @@ -385,7 +384,7 @@ class LOOPLIBCORESHARED_EXPORT PDFDiff : public QObject
PDFDiffResult& result);
void finalizeGraphicsPieces(PDFDiffPageContext& context);

void onComparationPerformed();
void onComparationPerformed(bool cancelled);

/// Calculates real epsilon for a page. Epsilon is used in page
/// comparation process, where points closer that epsilon
Expand All @@ -404,8 +403,8 @@ class LOOPLIBCORESHARED_EXPORT PDFDiff : public QObject
PDFDiffResult m_result;
PDFDocumentTextFlowFactory::Algorithm m_textAnalysisAlgorithm;

QFuture<PDFDiffResult> m_future;
std::optional<QFutureWatcher<PDFDiffResult>> m_futureWatcher;
QString m_activeJobId;
QMetaObject::Connection m_jobFinishedConnection;
};

} // namespace pdf
Expand Down
1 change: 1 addition & 0 deletions LoopLibCore/sources/pdfrepairprimitives.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ class PDFAddBleedRepair final : public PDFRepairOperation
plan->domains = domains();
plan->expectedChanges.pageBoxes = true;
plan->expectedChanges.pageContent = true;
plan->expectedChanges.images = true;
plan->expectedChanges.metadata = true;
plan->validators = { PDFRepairValidatorKind::StructuralIntegrity,
PDFRepairValidatorKind::NormalPreflight };
Expand Down
1 change: 1 addition & 0 deletions UnitTests/tst_repairoperationtest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ void RepairOperationTest::addBleedExpectedChanges_areMeasuredWithoutUnexpectedDi
QVERIFY(transaction.plans().first().expectedChanges.metadata);
QVERIFY(transaction.plans().first().expectedChanges.pageBoxes);
QVERIFY(transaction.plans().first().expectedChanges.pageContent);
QVERIFY(transaction.plans().first().expectedChanges.images);
QVERIFY(transaction.apply());

QTemporaryDir directory;
Expand Down
61 changes: 61 additions & 0 deletions UnitTests/tst_repairoperatoracceptance.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class RepairOperatorAcceptanceTest : public QObject
private slots:
void initTestCase();
void repairOperation_addBleedIsFailClosedAndAtomic();
void repairOperation_unicodeAndSpacePaths_addBleedPassesWithoutUnexpectedChange();

private:
QString m_defaultProfilePath;
Expand Down Expand Up @@ -92,6 +93,66 @@ void RepairOperatorAcceptanceTest::repairOperation_addBleedIsFailClosedAndAtomic
QVERIFY(!report.value(QStringLiteral("output")).toObject().value(QStringLiteral("sha256")).toString().isEmpty());
}

void RepairOperatorAcceptanceTest::repairOperation_unicodeAndSpacePaths_addBleedPassesWithoutUnexpectedChange()
{
const QString sourcePdf = operatoracceptance::fixturePath(QStringLiteral("bleed-missing.pdf"));
QVERIFY(QFile::exists(sourcePdf));

QTemporaryDir temporaryDirectory;
QVERIFY(temporaryDirectory.isValid());

const QString nestedDir = temporaryDirectory.path() + QStringLiteral("/shop files");
QVERIFY(QDir().mkpath(nestedDir));

const QString targetPdf = nestedDir + QStringLiteral("/café poster.pdf");
QVERIFY(QFile::copy(sourcePdf, targetPdf));
QVERIFY(QFile::exists(targetPdf));

const QString outputPath = nestedDir + QStringLiteral("/café poster_bleed.pdf");
const QString reportPath = nestedDir + QStringLiteral("/repair-report.json");

QByteArray stdOut;
QByteArray stdErr;
int exitCode = -1;
QVERIFY(operatoracceptance::runPdfTool(m_pdfToolPath,
{ QStringLiteral("repair"),
targetPdf,
QStringLiteral("--operation"),
QStringLiteral("add-bleed"),
QStringLiteral("--param"),
QStringLiteral("bleed_mm=3"),
QStringLiteral("--param"),
QStringLiteral("mode=mirror"),
QStringLiteral("--param"),
QStringLiteral("force=true"),
QStringLiteral("--profile"),
m_defaultProfilePath,
QStringLiteral("--output"),
outputPath,
QStringLiteral("--report-file"),
reportPath,
QStringLiteral("--console-format"),
QStringLiteral("json") },
&stdOut,
&stdErr,
&exitCode));
QCOMPARE(exitCode, 0);
QVERIFY2(stdErr.trimmed().isEmpty(), qPrintable(QString::fromUtf8(stdErr)));
QVERIFY(QFile::exists(outputPath));
QVERIFY(QFile::exists(reportPath));

QFile reportFile(reportPath);
QVERIFY(reportFile.open(QIODevice::ReadOnly));
QJsonParseError parseError;
const QJsonDocument reportDocument = QJsonDocument::fromJson(reportFile.readAll(), &parseError);
QCOMPARE(parseError.error, QJsonParseError::NoError);
QVERIFY(reportDocument.isObject());
const QJsonObject report = reportDocument.object();
QCOMPARE(report.value(QStringLiteral("status")).toString(), QStringLiteral("passed"));
QCOMPARE(report.value(QStringLiteral("diff")).toObject().value(QStringLiteral("summary")).toObject().value(QStringLiteral("unexpected_structural_changes")).toInt(),
0);
}

QTEST_GUILESS_MAIN(RepairOperatorAcceptanceTest)

#if __has_include("tst_repairoperatoracceptance.moc")
Expand Down
4 changes: 4 additions & 0 deletions changes/cursor-session-10-trust-qualification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Category: internal
Audience: developers
Breaking-Change: no
Summary: Close Session 10 trust gates by fixing add-bleed repair diff classification, migrating PDFDiff onto PDFJobScheduler, adding real-path repair regressions, and freezing independent-validation evidence scaffolding for T-01 through T-03. Keep Windows_MSI and Linux_AppImage on workflow_dispatch with an exact source SHA so packaging does not run on every push or PR targeting `dev`.
6 changes: 3 additions & 3 deletions docs/0.2.0-closeout-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ own sessions prove them.
| A-01 | Candidate identity | Refreshed refs, isolated branch, exact toolchain and status | Recorded on cutover branch |
| A-02 | Architecture authority | ADRs, roadmap, CMake intent, and generated catalog agree on Quick-only product | Updated; `LoopEditor` installed as Quick |
| A-03 | Closeout mapping | Every requirement maps to implementation, test, platform, package, SHA, and issue evidence | Matrix updated; Phase 5 Sessions 01–09 terminalized |
| T-01 | Trust contract | `add-bleed` real-path reproduction and fix or typed pre-mutation rejection | Open (not Phase 5 scope) |
| T-02 | Async/cancellation | Linux/Windows governed sites, terminal cancellation, stale-result rejection | Open (not Phase 5 scope) |
| T-03 | Independent validation | Independent parser/signature validator and conversion fixture provenance | Open (not Phase 5 scope) |
| T-01 | Trust contract | `add-bleed` real-path reproduction and fix or typed pre-mutation rejection | **Candidate** — `PDFAddBleedRepair` declares `expectedChanges.images`; `UnitTestsRepairOperatorAcceptance` unicode/space-path regression; hosted PdfTool proof pending on merged SHA |
| T-02 | Async/cancellation | Linux/Windows governed sites, terminal cancellation, stale-result rejection | **Candidate** — `PDFDiff` uses `PDFJobScheduler`; `check_unmanaged_async.py` allowlist empty; Linux/Windows CI proof pending on merged SHA |
| T-03 | Independent validation | Independent parser/signature validator and conversion fixture provenance | **Partial** — `docs/evidence/session-10-trust/` manifest + schema-frozen evidence; hosted `qpdf`/`pdfsig`/`verapdf` runs required (`status: incomplete` until then) |
| R-01 | Resource envelope | 10,000-page/image-heavy/pathological workloads | **Partial (Session 11)** — fail-closed manifest/matrix frozen on `6e65be48…` in `docs/evidence/session-11-resource-envelope/` (`disposition: incomplete`). Local Windows strict run: 0 measured, identity commit enforced, `-1` preflight/cancel/recovery not promoted. Missing `image-heavy-500mb`, `ten-thousand-page` (DIV2K), Linux hosted matrix, and candidate-SHA PdfTool rebuild |
| L-01 | Lifecycle model | Seeded bounded command traces, replay, shrinking | Open (not Phase 5 scope) |
| Q-01 | Interaction boundary | Typed facades, revision/generation-fenced requests, bounded cache/scheduler | Implemented; `verify-interaction-boundary.py` |
Expand Down
8 changes: 3 additions & 5 deletions docs/JOB_SCHEDULER.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,9 @@ surface's typed result and UI lifecycle. A source audit can use the table to
reject new unmanaged long-running work and to track the remaining conversions.

`scripts/ci/check_unmanaged_async.py` is the CI guard for that boundary. It
currently reports the 13 pre-existing `QtConcurrent::run` call sites above as
known migration debt and fails on any new or multiplied unmanaged launch. The
allowlist is a containment measure, not acceptance of #238; the issue remains
open until those product paths are migrated and cancellation/stale-result
evidence is recorded on both supported desktop platforms.
reports zero legacy product `QtConcurrent::run` call sites after Session 10
(`PDFDiff` migrated onto `PDFJobScheduler`) and fails on any new or multiplied
unmanaged launch.

## Verification

Expand Down
Loading
Loading