With 5 x 10 MB rotation the daemon log covered about three hours on the
audited machine, mostly one of a few lines repeated:
- "Failed to submit extraction ...: entity_graph_jobs channel full"
(11k lines) from GraphComponent, echoed per batch by PostIngestQueue's
"KG batch failed", plus "Entity channel full, dropping job";
- the per-document "Title+NL extraction for <hash> in N ms" info line;
- session-watcher scan/indexing warnings retried every pass.
Add yams::common::LogRateLimiter, a small lock-free helper that admits
one message per interval and reports how many it held back ("(N similar
messages suppressed)"), and use it at those sites (60 s for the queue
paths, 10 min for the session watcher, including its first-failure
warning per path). The Title+NL timing line is per-document progress
and moves to debug. The drop counters are unchanged, so status still
shows the full numbers.
Signed-off-by: trvon <me@trevon.dev>
`yams daemon status` printed "Vector Index: disabled by configuration"
whenever the daemon had not attempted vector init yet. That includes
every start where vectors are enabled but the metadata integrity check
is still running, so a slow start looked like a misconfiguration.
The daemon now publishes readiness key vector_db_disabled (only when
true, so clients that list every non-ready key do not show it) when
vectors are disabled by configuration. The renderer says "disabled / by
configuration" only in that case; otherwise it shows "pending" with what
it is waiting on (e.g. "waiting for database (Checking Integrity)"). The
detailed view's Vector DB row gets the same distinction instead of "Not
initialized".
Signed-off-by: trvon <me@trevon.dev>
~/.local/state/yams/sessions grows without bound: tool integrations
create a session per run (1,596 opencode-* and 109 mcp-grep-hot* files
out of 1,764 on one machine) and nothing ever removes them.
Add expireIdleSessions() next to the session store and run it from
RepairService's idle maintenance once a day, only while no client is
connected. A session file is deleted only when nobody has created,
opened, closed or modified it for [daemon.maintenance]
session_expiry_days (typed config, default 30, 0 disables), and it is
kept when it is:
- the current session,
- watching a directory that still exists,
- still tagged on documents (they stay reachable through `session
merge` / `discard`; without a metadata repository ownership is
unknown and nothing expires),
- not valid JSON (not ours to judge).
Tests build a store shaped like the audited one and check each rule,
plus the RepairService wiring against a real metadata repository and
that a connected client defers the scan.
Signed-off-by: trvon <me@trevon.dev>
v0.20 removed the code-symbol graph, but installs still carry its
leftovers:
- <data_dir>/grammars holds tree-sitter grammar libraries (~44 MB on
the audited machine) that `yams init` / `yams config grammar` built
and nothing loads any more.
- A yams_symbol_extractor plugin under the install prefix is refused on
every start with two warnings (the ABI host's skip plus the plugin
manager's "load failed"), and the refusal flipped the plugin host FSM
through Failed.
Remove the grammars once through metadata migration 43, so the cleanup
is recorded in schema_migrations and never repeats. It only acts on a
data directory's yams.db, only deletes libtree-sitter-* / tree-sitter-*
files directly in grammars/, removes the directory only when nothing
else is left in it, and leaves a symlinked grammars/ (and its target)
alone, so nothing outside the data directory can be touched.
The install prefix is not ours to clean, so the stale plugin is only
reported: one info line per process naming the file that can be
deleted. PluginManager recognises that refusal and skips it quietly
instead of warning and dispatching a load failure.
Signed-off-by: trvon <me@trevon.dev>
vectors.db had no reclaim path at all. On a real install it had grown to
39.7 GB with 48% of its pages on the freelist (about 19 GB of dead
space), while yams.db already had a startup auto-VACUUM with a size,
free-page-ratio and free-disk check.
Move that decision into a shared sqlite_vacuum helper so both databases
follow the same rules, and let RepairService's background loop check
vectors.db once a day (15 minutes after start, typed config
[daemon.maintenance] vector_vacuum_interval_hours, 0 disables).
VACUUM holds the vectors.db write lock for the whole rewrite, so it only
starts while the daemon is idle: no client connections, resource
pressure below Warning, no queued or in-flight embedding writes, no
VectorIndexCoordinator rebuild or bulk load, and no on-demand repair.
It runs on its own connection with file-backed temp storage and a short
busy timeout, so a live writer makes it back off instead of waiting, and
a progress handler rolls it back as soon as any of those signals change
or shutdown starts. Readers on the owner's connection are unaffected in
WAL mode. Besides the data-dir free-space rule it also requires the temp
directory to hold a copy of the live pages.
Tests cover the thresholds (including the audited 39.7 GB / 48% case),
a real vacuum that shrinks a file, abort and live-writer back-off, the
admission truth table, and RepairService vacuuming a live VectorDatabase
file only when idle, which stays writable afterwards.
Signed-off-by: trvon <me@trevon.dev>
The clean-shutdown stamp is written through yams.db.integrity.tmp and
consumed by renaming it to yams.db.integrity.claim.<pid>.<n>. Both are
removed on the normal path, but a crash between the rename and the unlink
leaves them behind, and nothing ever cleaned them up, so they accumulate in
the data directory across crashes.
sweepStaleDbIntegrityStampArtifacts() removes them conservatively:
- only the exact .tmp name and .claim.<pid>.<n> names are considered; the
live stamp and any other file are never touched;
- a file must be older than a minimum age, so an in-flight publish or
consume is never raced;
- a claim is removed only when its pid is provably not running (kill(pid, 0)
reports ESRCH / OpenProcess reports no such process); an unparsable owner
keeps the file.
It runs from RepairService::runRecoveryArtifactMaintenance() alongside the
corrupt-DB retention cleanup, so it only happens in the background when
maintenance is allowed, with stampArtifactMinAge (1h) in
RepairService::Config.
Signed-off-by: trvon <me@trevon.dev>
Corrupt-DB handling could destroy the only copy of documents:
- removeCorruptDbFiles() deleted every readable yams.db.corrupt-* after a
startup or `repair --orphans --remove-corrupt` salvage, including DBs whose
salvageFromCorruptDb() had failed or only partly copied, and DBs that were
never salvaged because their document count was <= the live DB's.
- A corrupt DB whose count was <= the live DB's was otherwise kept forever
with only a "skipping" log, with no path that ever decided it was safe to
drop.
- quarantineAndRecreate() renamed onto yams.db.corrupt-<second>, so two
quarantines in the same second overwrote the first copy.
Deletion is now decided by evidence, not by which code path ran:
corruptDbSalvageConfirmed() opens the corrupt DB read-only, attaches the
live DB and checks that every document hash in it is present in the live
DB (salvage copies documents keyed by hash). removeSalvagedCorruptDbs()
removes an artifact only when that check succeeds and the artifact is older
than a retention window (quarantine timestamp from its name, else mtime);
unreadable, unconfirmed or young artifacts are kept and reported with the
reason.
Startup salvage no longer deletes anything. RepairService owns cleanup:
runRecoveryArtifactMaintenance() runs from the background loop when
maintenanceAllowed(), every recoveryArtifactSweepInterval (6h), with
corruptDbRetention (7 days) in RepairService::Config. An explicit
`repair --orphans --remove-corrupt` waives the retention window but still
requires confirmation. yams doctor explains the retention policy next to
the artifacts it lists.
The "yams.db" / ".corrupt-" naming is centralized in db_recovery.h
(kMetadataDbFileName, kCorruptDbMarker, corruptDbPrefix(),
isCorruptDbFileName(), listCorruptDbs()) and used by salvage, recovery,
RepairService and the doctor checks instead of four hand-rolled scans.
Signed-off-by: trvon <me@trevon.dev>
When a stale-WAL checkpoint at startup (ServiceManager::recoverStaleWalIfPresent)
or the shutdown checkpoint (DatabaseManager::shutdown) failed with a
"malformed"/"corrupt" error, both paths deleted yams.db-wal and yams.db-shm.
A WAL that SQLite cannot checkpoint may still contain committed transactions
that never reached the main file, so deleting it discarded them with no copy
left for manual recovery.
Both paths now call quarantineSqliteSidecars(), which renames the sidecars
next to the database as yams.db-wal.quarantine-<UTC ts> (and -shm), never
over an earlier quarantine, and logs where each file went. SQLite no longer
treats them as sidecars, so the database still continues from its last
checkpoint, but the bytes are preserved. The WAL is moved first; if it cannot
be moved the SHM is left in place so SQLite sees a consistent pair.
yams doctor now lists quarantined sidecars alongside corrupt DB artifacts so
they are not forgotten.
Signed-off-by: trvon <me@trevon.dev>
During a search storm on a large corpus, daemon shutdown blocked in
Phase 6.7 (SearchEngineManager::clearEngine) until the 120 s watchdog
forced an exit. Dropping the engine destroys SimeonLexicalBackend, whose
destructor joins the BM25 build thread, and the build only looked at its
stop token once every 1024 documents, each needing a metadata fetch.
Stop was also requested only from that destructor, so the build had no
head start while the earlier shutdown phases ran.
SimeonLexicalBackend::requestStop() now flags the build without joining
it and makes later buildAsync() calls no-ops. The build checks the flag
(alongside the jthread token) before every document fetch in the corpus
and fragment passes and before fragment geometry. SearchEngine and
SearchEngineManager expose requestShutdown(), and ServiceManager calls
it in Phase 4, so the build thread has normally exited by the time
clearEngine() destroys the engine.
simeon's own finalize, concept-mining and PMI-learning steps take no
stop token, so a build already inside one of them still runs it to
completion; making those interruptible needs a change in the simeon
submodule.
Signed-off-by: trvon <me@trevon.dev>
A clean `yams daemon restart` never published the clean-shutdown
integrity stamp, so every start paid for a full PRAGMA quick_check
(13-27 min on a 93 GB database). Request handlers offloaded to
WorkCoordinator threads (list, grep, get, search) kept running through
Phase 5: nothing interrupted a long SQLite statement or a long result
loop, both 5 s and 30 s joins expired, the workers were abandoned, and
the stamp was disabled.
Shutdown now tells in-flight work to stop before joining:
- ConnectionPool::interruptActiveConnections() calls sqlite3_interrupt
on every leased connection under the pool mutex, which keeps each
lease open while it is interrupted. DatabaseManager exposes it for
both pools. Phase 4 interrupts once, and Phase 5 re-interrupts on each
join wait slice through a new joinWithTimeout hook, because SQLite
drops an interrupt that lands between two statements.
- ServiceManager owns a shutdown cancellation flag, set at Phase 4
before the interrupt, and publishes it as AppContext::cancellationSignal.
DocumentService::list and GrepService::grep check it between query
and hydration steps and between document batches, and return
OperationCancelled; SearchService checks it on entry and forwards it
as SearchExecutionContext::hostCancellationSignal, which the engine's
existing cancellation checks and the fanout collector now honor.
- The concept-extraction wait no longer blocks without bound when
componentTimeout is 0, and a canceled search stops waiting for the
query embedding / concept tasks. Those tasks now own copies of their
inputs instead of capturing the caller's stack, so abandoning them is
safe.
Interruption stays scoped to shutdown: the flag is only set in Phase 4
and cleared on initialize, and interrupting an idle lease does not fail
its next statement.
Also erase a connection from the leased set when its rollback throws;
it was left behind as a dangling pointer, which the interrupt walk
would have dereferenced.
Signed-off-by: trvon <me@trevon.dev>
When the Phase 5 WorkCoordinator join times out, the log only printed a
tid_hash per stuck worker, so the handler that kept the daemon from
quiescing (and from publishing the clean-shutdown integrity stamp) could
not be identified without attaching a debugger.
Give each worker a "current job" slot. WorkCoordinator::JobScope records
a static job name and start time on the calling worker, restoring the
enclosing job on exit; offload_to_worker and WorkCoordinator::post now
require a job name and run the callable inside a scope. The join timeout
detail line reports each stuck worker's job name and age, and
activeJobs() exposes the same snapshot for tests and diagnostics.
Signed-off-by: trvon <me@trevon.dev>
The TLS listener refuses a connection before the session handler runs
when the handshake fails, the peer pin is not allowed, or capacity is
exhausted; it logged that only at debug level, so the most common inbound
failure (an unenrolled peer) was still invisible. P2pListener::Options
gains an onRejected observer; P2pManager records those refusals with
stage 'transport', through the same path as later-stage failures.
Signed-off-by: trvon <me@trevon.dev>
getSnapshots, getSnapshotInfo and batchGetSnapshotInfo read only each
document's latest snapshot_id key, so a re-stored document disappeared
from every earlier snapshot's file count, and a snapshot whose documents
were all re-stored vanished from the list.
- getSnapshots lists tree_snapshots, document_snapshots and latest keys.
It deliberately skips legacy snapshot_id:<id> keys, which would be a
full-table scan on large corpora until the backfill finishes.
- batchGetSnapshotInfo counts members from document_snapshots, legacy
keys not yet moved, and latest keys, each looked up by id through an
index; label and git commit come from tree_snapshots first.
- getSnapshotInfo delegates to the batch query, removing three
near-duplicate queries.
Signed-off-by: trvon <me@trevon.dev>
P2pManager::handleInbound discarded runInboundSession's outcome, so a
rejected or failed inbound peer left no log line and no counter; the
InboundSessionStage it computed was never read.
- Count inbound sessions and failures, keep the last failure's stage,
error and time, and log a warning naming the stage.
- Expose them through P2pManager::inboundStats, the memory-sync status
(MemorySyncResponse, protobuf fields 17-21) and 'yams p2p status'
(text and --json).
Signed-off-by: trvon <me@trevon.dev>
'post-ingest success should persist extracted content' had been skipped
unconditionally since c051423e for lack of a deterministic completion
signal. It already waits on the observable result (the document's
extraction status), and post-ingest completion is fenced since #157, so
run it again.
Signed-off-by: trvon <me@trevon.dev>
Compressed GetResponse payloads were decoded only by 'yams get' (since
#214) and the MCP server, each with its own copy of the decoder. Any other
client of the daemon (library users, future commands) got the framed
bytes. In-process callers of DocumentService::retrieve defaulted to
acceptCompressed=true, so the daemon's CatRequest handler and the mobile
bindings would also have returned a frame instead of the document.
- DaemonClient::get decodes a compressed response once it is complete and
returns the document; the compression fields stay for display, and a
payload that does not decode is an error. The transfer stays compressed
on the wire.
- RetrieveDocumentRequest::acceptCompressed defaults to false; only
callers whose receiver decodes should opt in.
- Remove the now-redundant decoders from 'yams get' and the MCP get
handler.
- The integration test now requires a compressed request to deliver the
stored document, identical to a plain one.
Signed-off-by: trvon <me@trevon.dev>
Since v0.7.3 'yams get' has requested compressed transfers
(acceptCompressed), and the daemon has answered with a framed payload: a
CompressionHeader followed by a Zstandard body. Only the MCP server
decoded it; 'yams get' wrote the frame itself to stdout or -o, so users
saw the 'CNRK' header and a zstd frame instead of the document. 'yams cat'
was unaffected because it does not request compression.
- Move the frame format into compression/framed_payload
(encodeFramedPayload/decodeFramedPayload). DocumentService encodes with
it; 'yams get' and MCP decode with it.
- The decoder checks the header, body size and CRC, and the decoded size
and CRC. A payload that fails is an error; MCP used to fall back to
returning the framed bytes as content.
Verified end to end: 'yams get' output (stdout and -o) is byte-identical
to the added file while the transfer stays compressed (51.75x).
Signed-off-by: trvon <me@trevon.dev>
Every store added two metadata rows per snapshot to the document,
snapshot_id:<id> and snapshot_time:<id>, and nothing removed them.
Content-addressed documents are shared across paths and passes, so they
piled up: on one 93 GB database they were 43.1M of 44.0M metadata rows,
one file carried 52,367 snapshots, and 'yams list' timed out hydrating
2.69M rows for its 100 documents. Meanwhile findDocumentsBySnapshot read
only the latest snapshot_id, so a re-stored document dropped out of every
earlier snapshot.
- Migration 42 creates document_snapshots(document_id, snapshot_id,
snapshot_time), keyed on (document_id, snapshot_id). It only creates the
table, so startup stays fast.
- The insert transaction records membership; stores keep only the latest
snapshot_id/snapshot_time keys.
- findDocumentsBySnapshot and file history read membership, falling back
to legacy keys through one shared parser (legacySnapshotEntries).
- getMetadataForDocuments skips legacy keys via idx_metadata_doc_key: on
the 93 GB database, 0.21 s instead of 18.5 s for list's 100 documents.
- RepairService moves legacy keys in the background, 1000 snapshots per
batch, 200 ms apart, only below Warning pressure.
Signed-off-by: trvon <me@trevon.dev>
Read and write pools share the slow-holder threshold, but the warning
always said 'slow write-connection hold'. A slow read-only 'yams list'
(tag client_list, 42 s) was reported as a write hold, which pointed the
investigation at a nonexistent write. Say read or write from the pool's
readOnly config.
Signed-off-by: trvon <me@trevon.dev>
A single removal that failed returned before the watcher advanced its
snapshot, so every later pass saw the same files as changed, re-ran
addDirectory on all of them, and retried the removal forever. Removal
used force=false, and a path indexed more than once fails with 'Multiple
documents match', so one such path wedged the watcher permanently. A
live daemon re-indexed 1,793 files every 2 s this way and logged a
warning on each pass.
- Hold back only the paths that fail: a changed file whose indexing
failed stays changed, a removal that failed stays pending, and the
snapshot advances for everything else.
- Remove a deleted path with force: the file is gone, so every document
stored at it is stale.
- Warn once per failing path and retry quietly after that.
- Honor the watched directory's .gitignore (the unused gitignorePatterns
and gitignoreMtime state), and skip .git, so ignored build output is
not tracked, restatted, or removed.
Signed-off-by: trvon <me@trevon.dev>
Without a trusted clean-shutdown stamp, startup runs a full PRAGMA
quick_check before the database is ready. On a 93 GB database that took
over 11 minutes, and status showed 'Database: Opening' while the log
repeated 'still opening database', so a healthy scan looked like a hang.
Add the checking_integrity database phase, set it around the blocking
check, render it as 'Checking integrity ... full scan after unclean stop'
in 'yams daemon status', and name it in the startup watchdog log. The
check itself is unchanged: it still blocks readiness, and still recovers
confirmed corruption.
Signed-off-by: trvon <me@trevon.dev>
This reverts commit 296e7320.
296e7320 moved R2 publication from wrangler (authenticated by
CLOUDFLARE_API_TOKEN) to boto3 over R2's S3 API, which needs
R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY (and EXPERIMENTAL_* pairs). Those
secrets were never configured, so the first stable release built after
the change (v0.20.2, run 36084509789) failed at 'Publish channel package
repositories to R2' with 'Missing channel-specific Cloudflare R2 S3
authority', and the APT/RPM/Arch repositories were not updated.
Restore the wrangler path so releases publish with the existing token.
parseQueryQualifiers parsed lines:, pages:, section: and selector: into
an ExtractScope "for downstream snippet shaping", and SearchService then
used only its name/ext/mime hints: the scope itself was ignored, so
`yams search "foo lines:1-20"` silently searched for "foo" and returned
ordinary snippets.
- lines:<range> now replaces each result's snippet with those lines of
its stored content (1-based, e.g. "1-3,10"), through the same
TextBasicHandler `get` uses. It runs after snippet hydration, so
nothing overwrites it, and only when the qualifier is present.
- A result with no line-range handler (binary formats such as PDF),
content over 1 MiB, or unreadable content keeps its snippet and is
counted, never presented as shaped.
- pages:, section: and selector: have no search-time handler; they are
now reported rather than silently dropped.
- The outcome ("lines:1-20 applied to 7 of 8 results (1 without
line-range text, ...)" or "pages:1-2 is not applied to search
results") goes into searchStats["extract_scope"] and queryInfo, which
the MCP response already carries.
The logic is a header-only seam, applyExtractScope
(<yams/app/services/extract_scope.h>), tested directly for shaping,
unsupported formats, the read cap, an invalid range, pages:, and no
qualifier; plus an end-to-end SearchService test that fails before this
change: every hit for "programming lines:1-1" carries exactly its
document's first line.
Cost: a lines: query reads each result's content once (bounded by the
result limit and the 1 MiB cap); queries without the qualifier are
unchanged.
The portability allowlist entry for search_service.cpp's reviewed
getenv moves from line 509 to 510 with the new include.
Found by pipe's experimental C++ front end: ExtractScopeType values were
produced by the parser but never examined anywhere.
TopologyBuildConfig::dirtyRegionExpansion had no path from config:
TopologyManager never set it, so every incremental rebuild used the
default (PriorClusterAndNeighbors) and the NeighborsOnly and Adaptive
branches in ConnectedComponentTopologyEngine::defineDirtyRegion never ran.
It now follows the typed-config path the other topology knobs use:
[topology]
dirty_region_expansion = "neighbors_only" # or prior_cluster_and_neighbors, adaptive
ConfigResolver::resolveTopologyEnginePolicy parses it (case-insensitive;
an unknown value is logged and ignored, never guessed),
ServiceManager::configureTopologyRuntime applies it, and TopologyManager
passes it to the incremental dirty-region config and the build config.
dirtyRegionExpansionModeName / parseDirtyRegionExpansionMode give the
enum one spelling.
Adaptive currently takes every branch PriorClusterAndNeighbors takes, so
selecting it changes nothing yet. A characterization test pins that, so
a real adaptive policy has to change it deliberately.
Tests (the manager test fails with the TopologyManager line removed):
- topology_manager: an incremental rebuild seeded from a clustered
document reports "prior_cluster+neighbors" by default and "neighbors"
under neighbors_only;
- config_resolver: a recognized mode resolves; an unknown or absent one
leaves the default;
- topology_baseline: NeighborsOnly excludes prior-cluster members;
Adaptive matches the default; names round-trip.
Found by pipe's experimental C++ front end: NeighborsOnly and Adaptive
were compared in topology_baseline.cpp but never produced anywhere.
ProgressStage declares Resolving and Verifying, and the init, model and
download commands render both, but DownloadManager never emitted either:
the server probe and the integrity finalize / checksum check ran with no
progress event, so those CLI branches were unreachable and a long hash of
a large file looked like a stall.
The manager now reports Resolving before the probe and Verifying before
the digest is finalized. A new test records every stage the manager
emits for a checksummed download and requires Resolving < Connecting <
Verifying < Finalizing; it fails before this change.
Found by pipe's experimental C++ front end: both stages were matched in
CLI switches but never produced anywhere in src/.
The curl adapter returned PolicyViolation when shouldCancel() stopped a
transfer. DownloadService maps only OperationCancelled to a cancellation,
so a user's cancel reached callers as a NetworkError, and the service's
cancellation branch could never run.
The existing manager-level cancellation test used a fake adapter that
returns OperationCancelled itself, so it never exercised this path. The
new test drives the real curl adapter over a file:// URL, which runs its
write callback without a network, and fails before this change.
Found by pipe's experimental C++ front end: downloader::ErrorCode::
OperationCancelled was compared (download_service.cpp) but never produced.
archlinux/archlinux only publishes linux/amd64, so the native aarch64 Arch
lane failed with 'exec format error' before provisioning. Both Arch
Dockerfiles now take ARCH_BASE_IMAGE; the aarch64 matrix entry pins
ghcr.io/menci/archlinuxarm by digest and x86_64 keeps the official image.
package-validate.sh forwards ARCH_BASE_IMAGE to the systemd substrate.
ssh.git.trevon.dev no longer resolves (the experimental mirror run failed
with 'Could not resolve hostname'), and main already switched the mirror
remote to git.trevon.dev, where the mirror push succeeds. The policy test
still required the old host and forbade the new one, failing Tests on
every platform for main. Require git.trevon.dev, forbid the dead name,
and update the mirroring doc.
lzma_compressor.cpp targets the 7-Zip LZMA SDK (LzmaEnc.h/Alloc.h), but
the build only finds xz/liblzma, which does not ship those headers, so
every build (release logs: 'Has header "LzmaEnc.h" ... NO') compiled it
out. No build could read or write LZMA blocks, and its decoder never
bounded output by the expected size.
The policy still chose LZMA for cold/archival and large inactive data;
compression then failed ('Compressor not available') and batch stores
kept those blocks uncompressed. Select Zstandard, which already uses the
archive level for old data.
Remove the compressor, its test, the enable-lzma option and the build
flags that passed it. The LZMA algorithm id stays in the header format,
and compression.lzma_level remains accepted in config (now unused).
repairMissingEmbeddings built its database path as dataPath/'vectors.db'
with create_if_missing, so an unresolved (empty) data directory created a
stray vectors.db in the process working directory. The dispatcher passes
an empty path when the service manager has not resolved its data dir.
Return an error instead.
Tests that exercised repair or placed plugins under the unresolved data
directory now give the service manager a temp dir; the dispatcher suite
no longer leaves vectors.db or dispatcher_external_* in the working
directory.
stripXmlTags removed '<name>...</name>' pairs with a lazy backreference
std::regex; every unmatched opener rescanned the rest of the query, so a
32 KB query of '<a>x' took 4.4 s (libstdc++, -O2) and time grew 4x per
doubling. Search text comes from MCP clients and the CLI.
Index closing tags once and find each opener's first closer by binary
search, preserving the regex's left-to-right shortest-match semantics.
The suspected stack overflow did not reproduce (libc++ or libstdc++, down
to 256 KB thread stacks under ASan); the new test guards the time bound.
fuzz_fts5_query found two inputs the operator-query normalizer still
rejected at MATCH time:
- FTS5 applies implicit AND only between phrases, so '(foo OR bar) baz',
'bar (foo)' and 'NEAR(a b) c' are syntax errors; insert an explicit AND
next to a parenthesized or NEAR group.
- 'NEAR/<x>(...)' was treated as a NEAR group; FTS5 accepts only 'NEAR('.
decompress() trusted a caller-provided expected size (from the stored
block header, which has no checksum of its own) and allocated that many
bytes, up to 16 GB, before decoding. A corrupt size field could therefore
drive a huge allocation.
When the frame records its content size, the expected size must match
it. When it does not (streaming compressor output), decode incrementally
so the buffer grows with the real output and fail once it passes the
expected size.
removeScriptAndStyle re-searched <script, <style and <!-- from the cursor
on every iteration and, for an unclosed opener, scanned to the end for
its closing tag once per occurrence; convertBlockTagsToNewlines rescanned
to the end for '>' at every '<'. An ingested ~1 MB file of unclosed
'<script' openers, empty comments or bare '<' took over a minute.
Cache each next-occurrence position until the cursor passes it and
remember when a closing token is absent from the rest of the document.
The same three inputs now finish in well under a second.
EncodingDetector::detectEncoding accepted C0/C1 and F5-F7 lead bytes,
surrogates and overlong sequences as UTF-8, so such files skipped
conversion and reached the index as invalid UTF-8. It now uses the shared
well-formedness check.
convertToUtf8 consumed the unit after an unpaired high surrogate before
checking it, so UTF-16 'D800 0041' lost the 'A'. Only consume it when it
is a low surrogate.
sanitizeUtf8 and ensureValidUtf8 only checked continuation-byte bits, so
overlong encodings (E0 80 80), UTF-16 surrogates (ED A0 80) and values
above U+10FFFF (F4 90 80 80) passed through. They exist to satisfy
proto3 string fields (proto_serializer.cpp calls them for every string),
which reject exactly those sequences, so a document containing them could
make a peer fail to parse the message. Share one well-formedness check
with sanitizeUtf8Strict; the lenient variant still replaces bytes with
'?'.
Queries containing FTS5 operator syntax (a quote, uppercase AND/OR/NOT,
parentheses, column filters) were passed to MATCH almost verbatim, so
'foo "bar', '(foo OR bar', 'foo AND', 'NOT foo', 'IL-6 OR TNF' or
'ratio:2' failed with an fts5 syntax or no-such-column error instead of
returning results; MCP clients and the CLI reach this path.
Rebuild operator queries token by token: keep phrases, parentheses,
AND/OR/NOT, well-formed NEAR(...) groups and filters on real FTS columns,
quote any other non-bareword term, and fall back to the literal word
query when the structure is invalid. The existing assertion that
'hello NEAR/2 world' passes through encoded a query FTS5 rejects.
Isolated mode lost its function/calls default in v0.20 and instead
required a node type, but MCP only sets the node type through list_type,
which also switched the request to list-by-type (checked first), so
isolated:true either errored or silently listed nodes.
Default isolated mode to document nodes (with the existing
semantic_neighbor relation default: documents no other document lists as
an embedding neighbour), keep MCP list_type from overriding isolated mode,
and update the tool schema examples that still named calls edges.
storeBatch loads the previous snapshot only to find documents that left
the topology, but returned early when it could not decode it, so one
corrupt payload blocked every future topology write. On a decode error,
log and store the new snapshot without clearing dropped documents' keys;
other load errors still propagate.
Topology rebuilds now materialize member_of/overlaps/medoid_of/
subcluster_of edges in the KG. They derive from embeddings, but counted
toward kgRelationalEdgeCount, so building topology alone could push
hasRichGraphTopology() over its threshold and reopen graph-weight gates
that stay closed for corpora without real relations. Exclude them.
Migration 40 deleted every kg_edges row whose relation was calls,
implements, includes, ... and every node of a symbol type such as class or
interface. Graph ingest through MCP and IPC accepts any type and relation,
so an upgrade from v0.19 irreversibly removed user-modelled data like a
'class' node or an 'implements' edge.
Select the extractor's nodes by provenance and key shape instead
(<kind>:<qualified>@<file>[@snap:<hash>], symbol_ref:<name>, or nodes its
kg_doc_entities rows reference) and delete edges that are tagged with the
extractor or touch those nodes. The new test fails before the change.
- config_resolver: several [tuning] defaults derive from core count (e.g.
onnx_max_concurrent is 7 on a 48-thread host), so a reload is now checked
against the values observed before the override instead of != sentinel.
- service_manager: write the P2P trust files with explicit permissions so
a umask of 002 (Debian user-private groups) no longer trips the
group-writable check before the symbolic-link check under test.
~VerificationThreadPool set stop without holding queueMutex, so a worker
that had evaluated the wait predicate but not yet blocked could miss
notify_all() and join() hung forever (integrity_catch2 timed out under
parallel load; gdb showed join() waiting on workers parked in wait()).
Set the flag under the mutex. The new shutdown churn test hung in 35/40
parallel runs before the fix and passes 200/200 full-suite runs after.