• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

IJHack / QtPass / 40676746547

28 Oct 2026 11:01AM UTC coverage: 92.56% (-0.006%) from 92.566%
40676746547

push

github

web-flow
Executor: a dequeuing() slot may call back into the executor (#2082)

* Executor: a dequeuing() slot may call back into the executor

Henk's review of #2080: executeNext() emitted dequeuing() while holding a
reference to the queue's head and before marking it running. A slot that
queued more could reallocate the queue under that reference and, with
running still false, start the same head again; one that called
cancelNext() could drop the very item being started.

The head is copied and marked running before the signal goes out, so
execute() from a slot waits behind it and cancelNext() drops nothing
(-1, as for any running head). The header says what a slot may do.

Also names where "Password encryption aborted." comes from (pass's own
die in cmd_insert and cmd_edit), for whoever looks for it next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJygpMUf461yZgfAqwMJ6d
Signed-off-by: Anne Jan Brouwer <brouwer@annejan.com>

* Executor: cancelNext() is guarded while its dequeuing() is out too

CodeRabbit on #2082: cancelNext() emitted dequeuing(id, false) with
running false, so a slot's cancelNext() dropped the next item as well and
its execute() started one before the signal returned, against the
contract this PR documents.

running is set around that emit as in executeNext(). Nothing is started
afterwards: ImitatePass calls cancelNext() in a loop from its completion
handler to drop the rest of a failed transaction, and starting the next
item there would run a step of that transaction. The queue moves on when
the handler returns (onProcessFinished()).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJygpMUf461yZgfAqwMJ6d
Signed-off-by: Anne Jan Brouwer <brouwer@annejan.com>

---------

Signed-off-by: Anne Jan Brouwer <brouwer@annejan.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

5 of 5 new or added lines in 1 file covered. (100.0%)

1 existing line in 1 file now uncovered.

9617 of 10390 relevant lines covered (92.56%)

336.49 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.53
/src/nativegrep.cpp
1
// SPDX-FileCopyrightText: 2026 Anne Jan Brouwer
2
// SPDX-License-Identifier: GPL-3.0-or-later
3
#include "nativegrep.h"
4
#include "executor.h"
5
#include "pass.h"
6
#include "util.h"
7
#include <QDir>
8
#include <QElapsedTimer>
9
#include <QPointer>
10
#include <QProcess>
11
#include <QRegularExpression>
12
#include <QStringTokenizer>
13
#include <QThread>
14
#include <utility>
15

16
NativeGrep::NativeGrep(QObject *parent) : QObject(parent) {}
199 ✔
17

18
NativeGrep::~NativeGrep() {
199 ✔
19
  static constexpr int kThreadTimeoutMs = 5000;
20
  cancel();
199 ✔
21
  QElapsedTimer elapsed;
199 ✔
22
  elapsed.start();
199 ✔
23
  for (const Worker &w : std::as_const(m_workers)) {
199 ✔
UNCOV
24
    if (w.thread && w.thread->isRunning()) {
×
25
      const int remaining =
26
          kThreadTimeoutMs - static_cast<int>(elapsed.elapsed());
×
27
      if (remaining > 0)
×
28
        w.thread->wait(remaining);
×
29
    }
30
  }
31
}
199 ✔
32

33
/**
34
 * @brief Decrypt one .gpg file and return lines matching rx.
35
 */
36
auto NativeGrep::matchFile(const QProcessEnvironment &env,
412 ✔
37
                           const QString &gpgExe, const QString &filePath,
38
                           const QRegularExpression &rx,
39
                           const std::atomic_bool *cancel) -> QStringList {
40
  const QString translatedPath =
41
      Executor::translatePathForWsl(filePath, gpgExe);
412 ✔
42
  QString plaintext;
412 ✔
43
  // The QProcess overload is the one that takes the cancel flag; it polls
44
  // the flag while waiting and terminates (then kills) gpg from this thread,
45
  // the one that owns the process.
46
  QProcess gpg;
412 ✔
47
  gpg.setProcessEnvironment(env);
412 ✔
48
  const int rc = Executor::executeBlocking(
412 ✔
49
      gpg, gpgExe,
50
      QStringList{"-d", "--quiet", "--yes", "--no-encrypt-to", "--batch",
3,296 ✔
51
                  "--use-agent"} +
2,472 ✔
52
          Pass::maxOutputArgs() + QStringList{translatedPath},
1,648 ✔
53
      QString(), &plaintext, nullptr, cancel);
412 ✔
54
  if (rc != 0 || plaintext.isEmpty())
412 ✔
55
    return {};
4 ✔
56
  // Line by line over the text, never a list of every line (a compressed
57
  // entry can expand into tens of millions), and a bounded number taken.
58
  QStringList matches;
408 ✔
59
  for (QStringView line : QStringView(plaintext).tokenize(u'\n')) {
60,951 ✔
60
    if (line.endsWith(u'\r'))
61
      line.chop(1);
62
    const QStringView t = line.trimmed();
63
    if (t.isEmpty())
60,644 ✔
64
      continue;
60,540 ✔
65
    const QRegularExpressionMatch m = rx.matchView(line);
30,281 ✔
66
    if (!m.hasMatch())
30,281 ✔
67
      continue;
6 ✔
68
    if (matches.size() == kMaxMatchesPerEntry) {
30,275 ✔
69
      matches << QString(); // isMoreMarker(): not a match, not counted
101 ✔
70
      break;
71
    }
72
    if (t.size() <= kMaxPreviewLength) {
30,174 ✔
73
      matches << t.toString();
30,171 ✔
74
      continue;
30,171 ✔
75
    }
76
    // A window of the line that holds the match, a little context before
77
    // it; "…" marks what was left out on either side.
78
    const qsizetype at =
79
        qMax<qsizetype>(0, m.capturedStart() - (t.data() - line.data()));
3 ✔
80
    qsizetype start = qBound<qsizetype>(0, at - kMaxPreviewLength / 4,
6 ✔
81
                                        t.size() - kMaxPreviewLength);
3 ✔
82
    qsizetype end = start + kMaxPreviewLength;
3 ✔
83
    // Never half a surrogate pair (an emoji, CJK Extension B) at either
84
    // edge: the window takes the whole character.
85
    if (start > 0 && t[start].isLowSurrogate())
3 ✔
86
      --start;
1 ✔
87
    if (end < t.size() && t[end - 1].isHighSurrogate())
3 ✔
88
      ++end;
1 ✔
89
    QString preview = t.sliced(start, end - start).toString();
3 ✔
90
    if (start > 0)
3 ✔
91
      preview.prepend(QChar(0x2026));
2 ✔
92
    if (end < t.size())
3 ✔
93
      preview.append(QChar(0x2026));
3 ✔
94
    matches << preview;
95
  }
30,281 ✔
96
  return matches;
97
}
824 ✔
98

99
/**
100
 * @brief Walk the store, decrypt every .gpg file, collect matches.
101
 */
102
auto NativeGrep::scanStore(const QProcessEnvironment &env,
13 ✔
103
                           const QString &gpgExe, const QString &storeDir,
104
                           const QRegularExpression &rx,
105
                           const std::atomic_bool *cancel)
106
    -> QList<QPair<QString, QStringList>> {
107
  QList<QPair<QString, QStringList>> results;
13 ✔
108
  int total = 0; // matching lines, the markers not counted
109
  // Regular files only: a link or junction is not an entry, and searching
110
  // through one would decrypt files outside the store.
111
  const QStringList files =
112
      Util::regularFilesUnder(storeDir, QStringList() << "*.gpg");
39 ✔
113
  for (const QString &filePath : files) {
420 ✔
114
    if (QThread::currentThread()->isInterruptionRequested() ||
409 ✔
115
        (cancel != nullptr && cancel->load()))
64 ✔
116
      return {};
×
117
    if (total == kMaxMatches) {
409 ✔
118
      // The budget is spent and entries remain: the search stopped here.
119
      results.append({QString(), {}});
2 ✔
120
      break;
2 ✔
121
    }
122
    QStringList matches = matchFile(env, gpgExe, filePath, rx, cancel);
407 ✔
123
    if (matches.isEmpty())
407 ✔
124
      continue;
125
    const bool more = isMoreMarker(matches.constLast());
126
    if (more)
404 ✔
127
      matches.removeLast();
100 ✔
128
    // The store-wide budget: what still fits, and a marker when that cut
129
    // the entry short.
130
    const int room = kMaxMatches - total;
404 ✔
131
    if (matches.size() > room) {
404 ✔
132
      matches = matches.mid(0, room);
2 ✔
133
      matches << QString();
1 ✔
134
    } else if (more) {
403 ✔
135
      matches << QString();
100 ✔
136
    }
137
    total += static_cast<int>(matches.size()) -
404 ✔
138
             (isMoreMarker(matches.constLast()) ? 1 : 0);
404 ✔
139
    QString entry = QDir(storeDir).relativeFilePath(filePath);
404 ✔
140
    if (entry.endsWith(QLatin1String(".gpg")))
404 ✔
141
      entry.chop(4);
404 ✔
142
    results.append({entry, matches});
404 ✔
143
  }
144
  return results;
145
}
146

147
/**
148
 * @brief Start a search on a worker thread.
149
 *
150
 * Results are emitted on the owner's thread via QMetaObject::invokeMethod. A
151
 * sequence counter discards results from superseded searches; the previous
152
 * search is asked to stop but not waited for, since blocking the UI thread
153
 * while gpg decrypts would freeze the interface.
154
 */
155
void NativeGrep::search(const QString &pattern, bool caseInsensitive,
10 ✔
156
                        const QString &gpgExe, const QString &storeDir,
157
                        const QProcessEnvironment &env) {
158
  cancel();
10 ✔
159

160
  // Advance the sequence before any early return so in-flight workers from the
161
  // previous query fail the seq check and cannot publish stale results.
162
  const int seq = ++m_seq;
10 ✔
163

164
  // Use trimmed() rather than isEmpty(): a whitespace-only string is a valid
165
  // regex that matches every non-empty line, which is almost never intentional
166
  // and would decrypt the entire store.
167
  //
168
  // Both early returns post finished() via Qt::QueuedConnection so that the
169
  // signal is always delivered asynchronously after search() returns, matching
170
  // the contract of the threaded path.
171
  if (pattern.trimmed().isEmpty()) {
10 ✔
172
    QMetaObject::invokeMethod(
×
173
        this,
174
        [this, seq]() {
×
175
          if (m_seq == seq)
×
176
            emit finished({});
×
177
        },
×
178
        Qt::QueuedConnection);
179
    return;
1 ✔
180
  }
181

182
  const QRegularExpression rx(
183
      pattern, caseInsensitive ? QRegularExpression::CaseInsensitiveOption
184
                               : QRegularExpression::PatternOptions{});
20 ✔
185
  if (!rx.isValid()) {
10 ✔
186
    QMetaObject::invokeMethod(
1 ✔
187
        this,
188
        [this, seq]() {
1 ✔
189
          if (m_seq == seq)
1 ✔
190
            emit finished({});
2 ✔
191
        },
1 ✔
192
        Qt::QueuedConnection);
193
    return;
194
  }
195
  QPointer<NativeGrep> self(this);
196

197
  auto emitResults = [self, seq](QList<QPair<QString, QStringList>> results) {
18 ✔
198
    if (!self)
9 ✔
199
      return;
200
    QMetaObject::invokeMethod(
9 ✔
201
        self,
202
        [self, seq, results = std::move(results)]() {
27 ✔
203
          if (self && self->m_seq == seq)
18 ✔
204
            emit self->finished(results);
9 ✔
205
        },
9 ✔
206
        Qt::QueuedConnection);
207
  };
9 ✔
208

209
  auto cancelFlag = std::make_shared<std::atomic_bool>(false);
9 ✔
210
  QThread *thread = QThread::create([gpgExe, storeDir, env, rx, cancelFlag,
18 ✔
211
                                     emitResults = std::move(emitResults)]() {
212
    std::move(emitResults)(
9 ✔
213
        scanStore(env, gpgExe, storeDir, rx, cancelFlag.get()));
9 ✔
214
  });
9 ✔
215

216
  m_workers.append({thread, cancelFlag});
18 ✔
217
  connect(thread, &QThread::finished, thread, &QObject::deleteLater);
9 ✔
218
  connect(thread, &QThread::finished, this, [this, thread]() {
9 ✔
219
    m_workers.removeIf(
9 ✔
220
        [thread](const Worker &w) { return w.thread == thread; });
9 ✔
221
  });
222
  thread->start();
9 ✔
223
}
19 ✔
224

225
void NativeGrep::cancel() {
408 ✔
226
  for (const Worker &w : std::as_const(m_workers)) {
409 ✔
227
    w.cancel->store(true);
228
    if (w.thread && w.thread->isRunning())
1 ✔
229
      w.thread->requestInterruption();
1 ✔
230
  }
231
}
408 ✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc