Fix GRDBResultSet spinning on a stale row when a query cursor errors mid-iteration#4667
Merged
Merged
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
Fixes an edge-case in the DataModel’s GRDB cursor wrapper where a mid-iteration cursor error could leave a stale cached row and cause while resultSet.next() loops to spin indefinitely, potentially hanging features that iterate database results.
Changes:
- Update
GRDBResultSet.next()to stop swallowing cursor errors while leaving the previous row cached; it now clears the cached row and logs the cursor error. - Add
GRDBResultSetTestsregression coverage for a throwing cursor, loop termination, and normal cursor exhaustion. - Add a release-note entry to
CHANGELOG.mddescribing the potential hang fix.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| Modules/Tests/PocketCastsDataModelTests/GRDBResultSetTests.swift | Adds regression tests that reproduce the stale-row infinite loop and verify correct termination behavior. |
| Modules/Sources/PocketCastsDataModel/Private/DB/GRDB/GRDBResultSet.swift | Fixes next() error handling by clearing stale state and logging the error so callers don’t spin. |
| CHANGELOG.md | Documents the fix as a potential hang prevention in transient DB error cases. |
Comment on lines
+24
to
+28
| // Reset `row` before stepping so a cursor error (e.g. SQLITE_BUSY / | ||
| // SQLITE_IOERR) surfaces as end-of-results instead of leaving the | ||
| // previous row in place, which would make `while next()` loops spin. | ||
| do { | ||
| row = try rowCursor.next() |
Comment on lines
+39
to
+45
| // No ORDER BY: a sequential table scan returns rows in rowid | ||
| // (insertion) order and evaluates `explodeOnTwo` lazily per step, so | ||
| // row 1 is emitted before the function throws while stepping onto | ||
| // row 2. An ORDER BY on the computed column would instead force a | ||
| // full up-front sort pass and throw before the first row. | ||
| let cursor = try Row.fetchCursor(db, sql: "SELECT explodeOnTwo(value) AS value FROM items") | ||
| try body(GRDBResultSet(rowCursor: cursor)) |
`next()` used `try? row = rowCursor.next()`, which swallowed a mid-iteration cursor error (SQLITE_BUSY/IOERR) without resetting `row`. The previously fetched row stayed non-nil, so `next()` kept returning true and `while resultSet.next()` callers (EndOfYear/AutoAdd) spun forever on the last row. Reset `row` to nil on error so a cursor failure surfaces as end-of-results. Adds GRDBResultSetTests covering the throwing-cursor case, loop termination, and normal exhaustion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5af7e6a to
bf4e1a1
Compare
`bookmarkCount()` discarded next()'s result and read column 0
unconditionally. On a cursor error (SQLITE_BUSY/IOERR) next() returns
false with a nil row, so the read would crash on the implicitly
unwrapped Row. Guard the read and fall back to 0, matching the
defer { close() } pattern used elsewhere in this file.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kean
commented
Jul 2, 2026
| count = resultSet.long(forColumnIndex: 0) | ||
| resultSet.close() | ||
| defer { resultSet.close() } | ||
| if resultSet.next() { |
Contributor
Author
There was a problem hiding this comment.
This was the only place where row values are read unconditionally (without checking if next succeeded)
Collaborator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
GRDBResultSet.next()usedtry? row = rowCursor.next(), which swallowed a mid-iteration cursor error (e.g.SQLITE_BUSY/SQLITE_IOERR) without resetting the cachedrow. The previous row stayed non-nil, sonext()kept returningtrueandwhile resultSet.next()callers (End of Year, Auto Add, and other queries) could re-process the last row and spin unbounded.This resets
rowtonilon a cursor error so the failure surfaces as end-of-results, and logs the error so the resulting partial read is diagnosable instead of silent. AddsGRDBResultSetTestscovering the throwing-cursor case, loop termination, and normal exhaustion.To test
This is an internal data-layer robustness fix; it's exercised via unit tests:
PocketCastsDataModelTests/GRDBResultSetTests— all three tests pass.GRDBResultSet.next()totry? row = rowCursor.next()and re-run:testWhileLoopTerminatesWhenCursorThrowshits the infinite-loop guard (101 iterations) andtestNextReturnsFalseWhenCursorThrowsMidIterationfails, confirming the tests catch the bug.PocketCastsDataModelTestssuite has no new failures.Checklist
CHANGELOG.mdif necessary.