Skip to content

refactor(mobile): iOS introduce ImageRequest base class with unified cancellation and finish helpers#27489

Closed
LeLunZ wants to merge 12 commits intomainfrom
refactor/ios-image-request
Closed

refactor(mobile): iOS introduce ImageRequest base class with unified cancellation and finish helpers#27489
LeLunZ wants to merge 12 commits intomainfrom
refactor/ios-image-request

Conversation

@LeLunZ
Copy link
Copy Markdown
Collaborator

@LeLunZ LeLunZ commented Apr 3, 2026

Description

After PR #27486, LocalImageRequest and RemoteImageRequest each still duplicated the same boilerplate: an isCancelled flag, a stored callback, a cancel() method, and identical malloc/vImage_Buffer blocks in their respective ImageApiImpl.

This PR adds the ImageRequest base class with:

  • Thread-safe cancellation, and guarantee that it is only cancelled once
  • cancel(): sets isCancelled, nil's the callback, calls onCancel(), then fires cancelledResult
  • onCancel(): empty hook for subclasses to cancel their underlying work (operation, URL task).
  • finish(with:): completes the request with any result.
  • finish(encoding:): allocates a raw pointer from Data, frees it if cancelled, otherwise completes with pointer + length
  • finish(cgImage:format:): takes format as inout so the caller's static var is passed by reference — only one copy occurs when forwarding to vImage_Buffer. Frees the buffer if cancelled

The Remote and Local ImageRequest implement the Base class.

Behaviour changes that this PR includes:
the pigeon callback is now called directly when setting isCancelled to true. Not when the thread runs and sees that isCancelled is true and terminates the operation.
I think we can already communicate to flutter that the operation is cancelled and we don't have to wait on the thread to actually return or?

How Has This Been Tested?

  • on an iOS simulator

Checklist:

  • I have carefully read CONTRIBUTING.md
  • I have performed a self-review of my own code
  • I have made corresponding changes to the documentation if applicable
  • I have no unrelated changes in the PR.
  • I have confirmed that any new dependencies are strictly necessary.
  • I have written tests for new code (if applicable)
  • I have followed naming conventions/patterns in the surrounding code
  • All code in src/services/ uses repositories implementations for database calls, filesystem operations, etc.
  • All code in src/repositories/ is pretty basic/simple and does not have any immich specific logic (that belongs in src/services/)

Please describe to which degree, if any, an LLM was used in creating this pull request.

/

@LeLunZ LeLunZ requested a review from mertalev April 3, 2026 14:48
@LeLunZ LeLunZ requested a review from shenlong-tanwen as a code owner April 3, 2026 14:48
@immich-push-o-matic
Copy link
Copy Markdown

immich-push-o-matic Bot commented Apr 3, 2026

Label error. Requires exactly 1 of: changelog:.*. Found: 📱mobile. A maintainer will add the required label.

Comment on lines +9 to +11
var isCancelled: Bool {
lock.withLock { _isCancelled }
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mertalev back to your review questions in the other PR:

yes this does need a lock.
The swift docs say that the swift memory model has a conflict if all three conditions are met:

The accesses aren't both reads, and aren't both atomic. They access the same location in memory. Their durations overlap.

_isCancelled is written in cancel() (potentially from the Dart/main thread) and read in isCancelled (from the OperationQueue worker thread).
That's a concurrent write + read on the same memory address.

Swift also clarifies that plain variable access is not atomic:

An access is atomic if it’s a call to an atomic operation on Atomic or AtomicLazyReference, or if it uses only C atomic operations; otherwise it’s nonatomic. For a list of C atomic functions, see the stdatomic(3) man page.

So, a plain Bool write/read is nonatomic, without the lock we have two nonatomic, overlapping, one read, a written...means undefined behaviour.
This was actually a "bug" that was in the pre existing code.

lock.withLock { _isCancelled }
}

init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
Copy link
Copy Markdown
Collaborator Author

@LeLunZ LeLunZ Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mertalev Regarding this being nullable, thats how the pigeon communication is typed. null values means cancellation.
Thats also the same on main. See here

@LeLunZ LeLunZ changed the title refactor(mobile): introduce ImageRequest base class with unified cancellation and finish helpers refactor(mobile): iOS introduce ImageRequest base class with unified cancellation and finish helpers Apr 3, 2026
@LeLunZ LeLunZ force-pushed the refactor/ios-image-request branch from 2875ac9 to 656c7c2 Compare April 5, 2026 19:31
@LeLunZ LeLunZ force-pushed the refactor/ios-image-request branch from 828dd8a to 44d0653 Compare April 5, 2026 20:14
Base automatically changed from refactor/ios-image-registry to main April 5, 2026 22:55
@mertalev
Copy link
Copy Markdown
Member

mertalev commented Apr 6, 2026

I'm not sure about this PR. It seems to be bundling a lot of distinct responsibilities into ImageRequest which makes the behaviors unclear. It's not obvious that cancel() calls and nils the callback, that finish does the same, or that isCancelled being true implies the callback was invoked. It's even less obvious that only one of them will execute that callback. This is actually a memory leak in the code: it's possible for cancel to nil out the callback after finish checks isCancelled and the buffer is never freed in this case.

@LeLunZ LeLunZ force-pushed the refactor/ios-image-request branch from 79897a8 to d5e23c0 Compare April 6, 2026 02:18
@LeLunZ
Copy link
Copy Markdown
Collaborator Author

LeLunZ commented Apr 6, 2026

@mertalev Thx for pointing out the issues. I fixed the memory issue, but more importantly I moved the image processing stuff back into the Impl files.
Tthe Base class now only does thread safe cancellation and callback delivery.
And the finish method now actually returns if it finished or not. Now it's easier to see what's happening.

Do you think it's better now?


My idea behind the callback was:

  • I wanted that by design the callback is only run once in the class
  • If we get a cancellation the callback should be run right away, and not wait on something else

I can also remove the callback clearing, and we run it again always in the BlockOperations before returning from our worker threads. But that would also mean, when cancelling we would have to check if the current block operation exists and is not yet running, because then we would have to call the callback then there too.


And now that I have already touched the code again, I also moved #27524 into here.

@LeLunZ
Copy link
Copy Markdown
Collaborator Author

LeLunZ commented Apr 9, 2026

Closing this in favour of #27672

@LeLunZ LeLunZ closed this Apr 9, 2026
@LeLunZ LeLunZ deleted the refactor/ios-image-request branch April 10, 2026 11:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants