dt-lockcheck: a tool to scan for locking issues (sharing data between threads) - #22043
dt-lockcheck: a tool to scan for locking issues (sharing data between threads)#22043kofa73 wants to merge 14 commits into
Conversation
|
@da-phil @jenshannoschwalm @ralfbrown @TurboGit do you think this is worth pursuing? |
45ed1a4 to
d201a17
Compare
|
Update: reach the callbacks, and the pointers a lock cannot protect Two blind spots in the scanner, closed in two commits. The first was a reach
1. Label the callbacks the naming conventions miss
33 of them touched The names say why it mattered: Those sit behind issues #21974, #22008 and #22068 — already-confirmed defects What changedCallbacks are now labelled from how the address is taken, which is
The first row is what recovers most of the 33. The previous code gated on a list The third row is how the Three smaller fixes came out of the same work:
A latent bug this exposedPropagation could overwrite a label that came from the API tables. An imprecise Labels from the Effect on findings72 findings to 75, none lost, no rule reassigned. The three new ones are all 2.
|
|
I just skimmed over the posts to get a rough understanding, but need to re-read for full understanding. But what I see is a essentially a linter / static-code analyis tool which detects one category of bugs in our "darktable bug ontology". Is that something we should include in our CI to catch newly introduced issues before they even get merged, or is the tool not robust enough (low FP rate) for that? Another - more general - question: would you see any benefit in running our unit-tests in a build configuration which contains the thread sanitizer, which will more broadly catch multi-threading issues (dynamic analysis)? I'm asking because I'm working on including a bunch of sensible sanitizers into our build config, so that you could easily just start a build with them and run a test-suite to check for potential issue. |
|
Yes, it's a cheap heuristic, not a match for actually running stuff. While the bots worked on updating the GUI dev-doc (PR #21912), they found a bunch of issues, but spent a lot of tokens on the analysis; at one point, Claude Code came up with this script, which is intended to be a quick pre-filter to find suspicious points, bug candidates. It has blind spots as well as false positives; the 2nd of which can be mitigated using the bundled false-positives.json. They have now finished going through the reported candidates and filed a 2 new bugs, updated 2 previously existing reports, and added a bunch of false positive exclusions. In theory, with the I'm not familiar with darktable's pipeline and with C multithreading / the thread sanitizer. I'm a Java guy. If you think it is useful, I'm all for it, it sounds like a useful addition. |
|
Are there unit tests that are exercising UI interactions? I know there are integration tests that export images and compare them with a reference/baseline, but those are unlikely to help with UI threading issues. |
I think it's going to be very useful, just had a quick run using address & undefined behaviour sanitizers on a couple of integration tests and they already found a ton of issues, didn't even use the thread sanitizer yet...
Nope, not that I'm aware but of, unfortunately, this would actually also benefit the sanitizer coverage, as GUI issues are completely out of scope for them. |
Working on it. I can't do mouse interactions but I can exercise controls, presets, styles, etc. |
|
False positives filed for all rules and rebased on master. The 73 suppressed findings (field-level) are the those we have issues about, they will go away as issues get fixed. |
A three-way review of the initial commit found thirteen defects in the scanner. None of them lost a finding on the current tree, but several were silently narrowing what the tool could ever see. Recall against the confirmed defect set goes from 39/42 fields to 40/42 at default settings; no previously reported finding is lost. Extraction: * Comments and string literals are now blanked out before anything is scanned, preserving line and column. Only `//` was stripped before, so disabled code entered the fact base as real accesses (three sites) and a commented-out enter/leave_critical_section() would have shifted the lock depth of a whole function. * `is_write()` understands member paths (`g->box.x = 1`), prefix `++`/`--`, `<<=`, `>>=`, `%=`, and an argument behind a cast. 98 member assignments were recorded as reads, which reached --format json and lemmalog. * The gui_data struct no longer needs a tag on its typedef, so liquify -- four critical sections, and previously dropped whole -- is analysed. * A declarator list shares its type, so every field after the first comma is typed. 355 of 1383 fields came out "unknown", 254 of them Gtk-typed, and widget_from_pipe cannot fire on an untyped field. This is what was hiding colorharmonizer's `auto_detect`. * Function bodies are found by brace counting rather than by a `}` in column zero, and a leading DT_OMP_DECLARE_SIMD() no longer supplies the function name. * The handler-argument scan matches its trailing separator by lookahead. Consuming it made DT_CONTROL_SIGNAL_HANDLE(SIGNAL, handler) unmatchable, which is what suppressed colormapping's `buffer`. Interface: * --rules and --why/--format validation runs before any output, so `--rules bogus --format json` exits 2 instead of printing the JSON. * Every --module name must resolve; one bad name in a list is an error rather than a silently narrowed run. * The report says how many sites it left out of a capped list, names .cc sources by their real extension, and the stderr banner reports how many sources carry no gui_data struct at all. rules.lemma gains the mutual-exclusion guard the Python if/elif cascade always had, so the two now agree field by field on all four rules.
… on pending dev-doc changes
… build integration; update docs
…s if --fail-on-stale-false-positives-only is used; update false positives list
… rebasing on master
| for ln, src in enumerate(lines): | ||
| if ln in covered: | ||
| continue | ||
| for ref in re.findall(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*(?=[,}])", src): |
There was a problem hiding this comment.
Can you re-use regex CALL_RE here?
There was a problem hiding this comment.
Not directly (only the identifier part matches, it's followed by different characters, but Claude did some cleanup:
- dead code removed (FUNC_HEAD, gui_ty, an unused index); constant regexes hoisted out of the per-line loops that were rebuilding them (CALLBACK_ARG_RE, OTHER_G, STATIC_REF_RE, LOCK_ARG); the hand-rolled _write_cache replaced with functools.lru_cache; repeated idioms collapsed into one named thing each -- fp_ident() for the (module, field, rule) triple that appeared at eight sites, _module_of() for the path->module rule, a local join() for thethread-lattice update written out twice, and reuse of CALL_RE, _as_iop_dir() and next(...) where the logic had been spelled out a second time.
- --format lemmalog deduped: it deduped over a line number the access(...) fact does not print, so the same assertion was emitted once per source line; deduping over what is actually emitted cuts the output ~37%
There was a problem hiding this comment.
I'm going to introduce a folder tools/sanitizer which also contains SW quality tooling. I wonder whether we should create a common folder for SW quality tools like dt-lockcheck and the sanitizers? Maybe tools/quality or tools/sw_quality?
It's funny that after long analysis sessions of sanitizer output files, I saw patterns and started writing deterministic static code analysis / linter scripts too, which basically just parse the instrumented build tree to find easy to spot issues almost effortless, without even running the instrumented code, which can be at a 20x runtime penality, as for the thread sanitizer.
Anyway, good job with this tool, it looks very promising!
I'm really looking forward to bring some of those tools into our CI eventually and proactively prevent bugs from even entering the code base instead of retroactively triaging and fixing them.
I also wanted to ask you if you have a special skill / instruction for your LLM models to create those nicely structured github issues? I'm planning to do the same for the sanitizer findings.
There was a problem hiding this comment.
At first, I was against tools/sw_quality ("Of course it's software..."), but it could be misunderstood as measurements of "image quality" (whatever that is), so I think tools/sw_quality is fine.
Emerging patterns: most static analysis is about patterns.
dt-lockcheck seems to have run its course for now; I think I have a few more chains to verify and possibly report, then it can transition into a build quality gate tool.
As for the issue style: I think I told them once or twice to structure the output and avoid walls of text. Since then, they are just copying the existing style (I have a copy of these reports in a local directory, and when I tell Claude to add a new report there, it checks the existing ones - it does that anyway, as I also tell it to check if the issue it's about to report is already covered by something, or should be added to an existing report, rather than raising a new one).
I'm considering to tell it to use mermaid diagrams e.g. for threading issues, instead of textual descriptions. I'll have to see if that makes the reports more readable or just "fancier".
…log output; also line-number sync in false-positive comment section
Claude, Codex and Gemini have been working on updating the dev-docs. During that work, as a side-effect, they identified a number of bugs, some of them I have filed, some of them not yet.
Today I read about https://github.com/JordyZomer/lemmalog, developed by a security researcher (see article). I asked Claude whether it would be useful to trace calls during our bug hunts. While it said
lemmalogdoes bring some advantages (more about that below), it proposed to create a Python tool to deterministically and very quickly scan for locking issues. The result is the tool on this branch. It's not a 100% accurate magical oracle, but it does report findings quickly and fairly accurately (this was measured against how many of the already found bugs it reported, as well as quickly evaluating some of the new entries in its findings to see if they are real or false positives -- investigation was not very deep, but I'll work on that later, as it's quite promising).One thing I'm considering is a permanently checked-in false-positive list, which would be invalidated when evidence (line numbers etc) change, or manually rescanned and re-evaluated every now and then). This is work in progress, I'd love to have your feedback.
I don't know whether
toolsis the right directory - please advise.The following is a Claude-generated description.
======
tools/dt-lockcheck: a static check forgui_datathread safetyState. The script works, the current
gui_dataaudit round is driven fromit, and nothing in the build depends on it: it reads
src/iop/*.cand*.ccand prints a report. What is still moving is the tuning, the checked-in
false-positive list, and how much of its output has actually been read by a
human. This section describes where all three stand today, on this branch.
The short version: 74 findings over 87 analysed modules, of which 52 name a
field that an already-filed report also names and 22 nobody has ruled on. Two
new upstream issues were filed off the back of the first review round, and that
round also overturned a defect that three independent readers had initially
written off.
What it is for
Each IOP module keeps its per-instance GUI state in a
dt_iop_<module>_gui_data_t, reached asg->field. Thegui_*callbacks andwidget handlers run on the GTK main thread;
process()and its neighbours runon a pixelpipe worker. A field both sides touch has to be accessed inside
dt_iop_gui_enter/leave_critical_section(), or under a private mutex.This is a recurring defect family and an awkward one to spot by review: the
write is in
process(), the read is in a draw callback, and the two are threecall frames apart in a 4000-line file. Consequences run from a mis-drawn overlay
to a use-after-free. Finding them by hand is slow and almost entirely
mechanical — which is the argument for a tool.
How it works
Four stages, all lexical. There is no compiler front end and no dataflow
analysis; the tool matches source text and propagates labels.
gui_datastruct and its field types,every
g->fieldaccess with its line and its mode, every critical section,and the call graph within that one file.
gtk,pipeoreither. Themodule API entry points are ground truth; static helpers inherit the label of
their callers along the in-file call graph; callbacks are labelled from how
their address is taken rather than from their name — passed to a callee
is the registrar's thread, passed to
g_idle_add/g_signal_connectisgtk,stored into a struct member or a file-scope table is
eitherbecause itescapes the file.
eitheris the interesting label:commit_params(),init_pipe(),distort_transform()and the two colorspace callbacks are allreachable from GTK-thread code, so the tool refuses to claim they are on a
worker thread, and counts them on both sides of the cross-thread test.
at most one rule.
rules, so it changes what is reported and not what is derived.
A full run over
src/ioptakes about a second and a half. Python 3.8+,standard library only, nothing to install.
What it reports today
widget_from_pipeGtk*-typed field touched from apipeoreitherfunction. GTK may only be called from the main thread, so the lock is beside the pointviolationno_lock_sharepointer_sharediscipline_gap74 reported by default (76 before the false-positive list), 133 with every
rule (135 before). Each finding names the locked sites — the module's own
evidence that the field is meant to be protected — and the unlocked sites that
are the suspected defect, with line numbers and the thread of each function.
pointer_shareis the newest rule and the odd one out: every input to it is acorrectly locked field, and what it says is that locking alone cannot fix the
shape. Its single hit tree-wide is
colorreconstruction's frozen bilateralgrid, copied out under
gui_lock, dereferenced after the release and freed bythe preview pipe — #22060. It is bounded by the shared-pointer population
(11 fields), which is what keeps it in the default set.
What the findings have been worth so far
Coverage. 52 of the 76 default-settings findings name a field that one of
the 23 reports of the ongoing
gui_dataaudit already names (#21915-#21919,#21974, #22005-#22009, #22057-#22064 and #22066-#22069). The other 24 had not
been ruled on either way.
First review round. 23 of the findings were then assessed independently and
blind by three agents (Claude, Codex, Gemini), and the disagreements debated.
21 verdicts agreed from the start; both disputes were resolved unanimously, and
one of them mattered:
toneequal'spipe_orderwas called a false positive by two of the threerounds, on the grounds that both pipes write the same value and the guarded
block is idempotent. It is neither. The comparison at
toneequal.c:1036sitsoutside the section it guards and is never re-tested inside it, so both pipes
can enter; the preview pipe publishes
luminance_valid = TRUEand the fullpipe then re-runs the stale invalidation, leaving a valid mask marked invalid.
Every GTK reader gated on that flag — histogram, cursor readout, mask
display — goes silently inert until the preview pipe happens to run again.
g->pipe_orderis initialised to 0 andiop_ordernever is, so both pipessee the mismatch on the module's first darkroom run; no reordering is
needed to reach it.
Two further issues came out of the same round, neither of them a locking
defect, both found while reading a finding: #22080 (the
channelmixerrgbΔEbuffer keeps the size of the previous colour checker — a heap out-of-bounds
read and write when switching between a 24- and a 48-patch checker) and
#22081 (a
retouchOpenCL failure path strands the auto-levels handshake).A third — the preview pipe freeing
delta_E_label_textunlocked while the GTKthread passes it to
gtk_label_set_markup(), withreload_defaults()makingthat pairing a double free — was folded into #22058, which needs a section
around the same call site.
One methodological result worth passing on. Three independent passes at
matching findings against already-filed reports gave three different answers
(55/19, 51/23, 47/27); reconciling them by hand gave 52/22. Every error was a
substring match — a field name inside a fenced code block in an unrelated
issue, or the English words "colour checker". Dedup errors of that kind are
silent: a wrongly-deduped finding is assessed by nobody and leaves no trace in
any report. Two of the three passes dropped what turned out to be the round's
second most serious defect that way. If you re-run such a comparison, do it per
finding, against the issue text only, and require the document to be about that
module's that field.
The checked-in false-positive list
tools/dt-lockcheck/false-positives.jsonrecords findings a human has read andjudged not to be defects; suppression is on by default. The risk in any such
list is that a suppression outlives the code it was true about, so every entry
carries a key, and a source change that could alter the judgement invalidates
it: the entry goes stale, the finding comes back, and the reason recorded for
it is printed alongside. An entry that matches no finding at all is named as an
orphan. The stderr banner always reports these counts, and
-qdoes notsilence it — the list may not hide its own size.
The key is the finding's facts (field type, the thread of every function
that touches it, and the
(function, lock, mode)of every access) plus theverbatim text of every access line. Each half catches what the other misses,
and this was measured rather than guessed, over 6105 finding-transitions across
80 commits touching
src/iop:function:linesitesThe file hash is not "overkill but safe": it is 34× noisier for no extra safety,
and noise is what makes people re-confirm an entry without reading it.
Nobody edits the file by hand:
./dt-lockcheck.py --confirm-false-positive rgblevels:params \ --false-positive-reason "why this is not a defect"A reason is required for a new entry, because that sentence is what gets printed
back when the entry goes stale. A bare
--confirm-false-positive rgblevelsre-confirms every stale entry in that module but deliberately cannot add
entries, so a whole module cannot be silenced with one word.
It holds two entries today —
colorharmonizer'sauto_detect(the pipeside only takes a
g_object_ref()and hands the widget togdk_threads_add_idle(), which is the correct idiom) andrgblevels'params(ownership passes through a state machine whose transitions are taken under the
lock). Nine further findings were agreed to be false positives in the review
round and have not been recorded yet; that is the next thing to land in this
file, not a claim that the rest are defects.
CI and build integration
Two mutually exclusive gates, both off by default:
--fail-on-findings— exit 1 when anything is reported. Red on this treetoday (74), and it is meant to be: it passes only once every finding is
either fixed or recorded as a false positive. Not something to wire into CI
yet.
--fail-on-stale-false-positives-only— exit 1 only when a recorded judgementhas gone stale. Green today, and the one a job or a pre-commit hook can use
now.
There is deliberately no new-findings gate. That would need a checked-in
baseline of all 74, which is a different artifact with different invalidation
needs, and it is not in this branch.
Limitations
cause we know of and asks people to confirm one by reading the code before
filing.
function: a pointer copied out of
gui_dataunder the lock and dereferencedafter the release is invisible to four of the five rules, which is exactly
what
pointer_shareexists to work around, and it does so by firing on theshape rather than by proving the escape.
only. A defect whose two halves sit in
src/iopandsrc/develop— such asexposure'seffective_exposurein exposure disagrees with itself about the exposure it applies #21974 — is out of reach byconstruction.
those defects is a best case and not a prediction for a tree it has not been
tuned on. Precision is unaffected: the findings beyond the known fields were
not known to anyone when the tool produced them.
of 76 findings land on ground somebody already confirmed, 24 are unruled, and
a first review round found the great majority of the ones it read to be real.
discipline_gapis visibly worse than the other four, which is why it is notin the default set.
these files.
Layout and usage
Every invocation mistake is an exit-2 error rather than a quiet no-op: an
unknown rule name, a
--modulethat names no source or one with nogui_datastruct,
--include-false-positiveswith a format the list never touches, amalformed list file, both gates at once.
Optional: proof trees
rules.lemmaexpresses the same rules as Datalog, forlemmalog. With
--why, each findingis followed by the proof behind it, bottoming out in the extracted facts — which
is how you see that a finding rests on a thread inference you disagree with. It
is off by default and needs nothing installed unless you ask for it. The two
implementations are checked to produce the same finding set, rule by rule and
field by field.
--format lemmalogemits the facts alone, which is the way toadd a rule of your own — the audit bookkeeping above (which findings are already
filed, which modules the rules never mention) was written as Datalog queries
rather than as a script.
Related
There are pending changes to
dev-doc/GUI_Threading.mdcovering the sameground — which callbacks run on which thread, and the locking rules for
gui_data. This tool encodes that model directly, so the two should movetogether: #21912