Repository navigation
refactor(server): let the S3 backend own its proxy read slot and route storage I/O through the backend - #1159
Merged
Deeds67 merged 3 commits intoOct 6, 2026
Conversation
Deeds67
force-pushed
the
refactor/storage-s3-stream-lifecycle
branch
from
October 6, 2026 06:51
7cb56e7 to
23d1949
Compare
…torage I/O through the backend The proxy read slot used to be freed only if sendFile destroyed the stream on every exit path, an obligation the StorageBackend interface never stated. A throw between the res.destroyed check and pipe (setting a header) left the stream undestroyed and its slot held. - ServeOptions gains `signal`, aborted when the response closes. sendFile hands it to the handler through an AsyncLocalStorage, and serveFromBackend passes it on. - The S3 adapter aborts GetObject on it, skips a read whose client left while it waited for a slot, and returns a stream that releases its slot on end, error, destroy, abort and idle. The idle watchdog and S3_STREAM_IDLE_TIMEOUT_MS move from utils/file.ts into the backend. - sendFile keeps only the generic rule, registered before any header is set: destroy the source when the response closes. - StorageBackend gains readAll; the disk adapter's exists, readAll and delete go through StorageRepository, the calls they replace in upstream code paths. - BaseService.backendFor resolves the backend for a path. ensureLocalFile and getProbeInput lose their redundant isAbsolute branches; the sidecar check, the sidecar download, file deletion and ML image reads stop branching on path shape; notification and ML share readAll instead of hand-written buffering. - The MinIO integration spec moves to the pinned Chainguard image, since MinIO withdrew its own.
…am edge cases
Review follow-up.
- StorageBackend.exists takes an optional { readable }. Disk checks existence
by default, so the storage migration still fails loudly on a source that
exists but cannot be read instead of skipping it as missing; the sidecar check
asks for readable, keeping upstream's R_OK. A storage-migration test pins it.
- The S3 proxy read rejects a request whose client already left before it
queues for a slot, not only after getting one.
- A disk backend with no media location refuses a relative key instead of
resolving it against the working directory.
- Tests for sendFile dropping an AbortError once the client left and still
reporting one while the response is open, and for an idle destroy never
becoming an unhandled 'error' on a stream that is only piped.
- Comments on what supplies that error listener, on the ML repository's
dependency on StorageService bootstrap, and on what getResponseSignal returns
outside sendFile.
Once the S3 body has ended into the slot-holding Transform, pipeline drops its listeners, and pipe() adds none to its source. The thumbnail endpoint resolves its stream before sendFile, so no response signal is attached either. A client that stops reading but keeps the socket open for the idle window then gets an 'error' with no listener: an uncaught exception that exits the worker. Listen for it on the Transform; the error stays on stream.errored and the slot is still released on 'close'.
Deeds67
force-pushed
the
refactor/storage-s3-stream-lifecycle
branch
from
October 6, 2026 07:22
23d1949 to
b818070
Compare
Deeds67
added a commit
that referenced
this pull request
Oct 6, 2026
…e storage I/O through the backend (#1159) * refactor(server): let the S3 backend own its proxy read slot, route storage I/O through the backend The proxy read slot used to be freed only if sendFile destroyed the stream on every exit path, an obligation the StorageBackend interface never stated. A throw between the res.destroyed check and pipe (setting a header) left the stream undestroyed and its slot held. - ServeOptions gains `signal`, aborted when the response closes. sendFile hands it to the handler through an AsyncLocalStorage, and serveFromBackend passes it on. - The S3 adapter aborts GetObject on it, skips a read whose client left while it waited for a slot, and returns a stream that releases its slot on end, error, destroy, abort and idle. The idle watchdog and S3_STREAM_IDLE_TIMEOUT_MS move from utils/file.ts into the backend. - sendFile keeps only the generic rule, registered before any header is set: destroy the source when the response closes. - StorageBackend gains readAll; the disk adapter's exists, readAll and delete go through StorageRepository, the calls they replace in upstream code paths. - BaseService.backendFor resolves the backend for a path. ensureLocalFile and getProbeInput lose their redundant isAbsolute branches; the sidecar check, the sidecar download, file deletion and ML image reads stop branching on path shape; notification and ML share readAll instead of hand-written buffering. - The MinIO integration spec moves to the pinned Chainguard image, since MinIO withdrew its own. * refactor(server): keep disk exists semantics per caller, pin the stream edge cases Review follow-up. - StorageBackend.exists takes an optional { readable }. Disk checks existence by default, so the storage migration still fails loudly on a source that exists but cannot be read instead of skipping it as missing; the sidecar check asks for readable, keeping upstream's R_OK. A storage-migration test pins it. - The S3 proxy read rejects a request whose client already left before it queues for a slot, not only after getting one. - A disk backend with no media location refuses a relative key instead of resolving it against the working directory. - Tests for sendFile dropping an AbortError once the client left and still reporting one while the response is open, and for an idle destroy never becoming an unhandled 'error' on a stream that is only piped. - Comments on what supplies that error listener, on the ML repository's dependency on StorageService bootstrap, and on what getResponseSignal returns outside sendFile. * fix(server): keep an idle S3 proxy destroy from crashing the process Once the S3 body has ended into the slot-holding Transform, pipeline drops its listeners, and pipe() adds none to its source. The thumbnail endpoint resolves its stream before sendFile, so no response signal is attached either. A client that stops reading but keeps the socket open for the idle window then gets an 'error' with no listener: an uncaught exception that exits the worker. Listen for it on the Transform; the error stays on stream.errored and the slot is still released on 'close'. --------- Co-authored-by: Pierre Marais <pierremarais67@gmail.com>
Deeds67
added a commit
that referenced
this pull request
Oct 6, 2026
…-10-06 sync report Claude-Session: https://claude.ai/code/session_01XGvBrUkF5gBoiamBysaRbe
Deeds67
added a commit
that referenced
this pull request
Oct 6, 2026
…e storage I/O through the backend (#1159) * refactor(server): let the S3 backend own its proxy read slot, route storage I/O through the backend The proxy read slot used to be freed only if sendFile destroyed the stream on every exit path, an obligation the StorageBackend interface never stated. A throw between the res.destroyed check and pipe (setting a header) left the stream undestroyed and its slot held. - ServeOptions gains `signal`, aborted when the response closes. sendFile hands it to the handler through an AsyncLocalStorage, and serveFromBackend passes it on. - The S3 adapter aborts GetObject on it, skips a read whose client left while it waited for a slot, and returns a stream that releases its slot on end, error, destroy, abort and idle. The idle watchdog and S3_STREAM_IDLE_TIMEOUT_MS move from utils/file.ts into the backend. - sendFile keeps only the generic rule, registered before any header is set: destroy the source when the response closes. - StorageBackend gains readAll; the disk adapter's exists, readAll and delete go through StorageRepository, the calls they replace in upstream code paths. - BaseService.backendFor resolves the backend for a path. ensureLocalFile and getProbeInput lose their redundant isAbsolute branches; the sidecar check, the sidecar download, file deletion and ML image reads stop branching on path shape; notification and ML share readAll instead of hand-written buffering. - The MinIO integration spec moves to the pinned Chainguard image, since MinIO withdrew its own. * refactor(server): keep disk exists semantics per caller, pin the stream edge cases Review follow-up. - StorageBackend.exists takes an optional { readable }. Disk checks existence by default, so the storage migration still fails loudly on a source that exists but cannot be read instead of skipping it as missing; the sidecar check asks for readable, keeping upstream's R_OK. A storage-migration test pins it. - The S3 proxy read rejects a request whose client already left before it queues for a slot, not only after getting one. - A disk backend with no media location refuses a relative key instead of resolving it against the working directory. - Tests for sendFile dropping an AbortError once the client left and still reporting one while the response is open, and for an idle destroy never becoming an unhandled 'error' on a stream that is only piped. - Comments on what supplies that error listener, on the ML repository's dependency on StorageService bootstrap, and on what getResponseSignal returns outside sendFile. * fix(server): keep an idle S3 proxy destroy from crashing the process Once the S3 body has ended into the slot-holding Transform, pipeline drops its listeners, and pipe() adds none to its source. The thumbnail endpoint resolves its stream before sendFile, so no response signal is attached either. A client that stops reading but keeps the socket open for the idle window then gets an 'error' with no listener: an uncaught exception that exits the worker. Listen for it on the Transform; the error stays on stream.errored and the slot is still released on 'close'. --------- Co-authored-by: Pierre Marais <pierremarais67@gmail.com>
Deeds67
added a commit
that referenced
this pull request
Oct 6, 2026
…-10-06 sync report Claude-Session: https://claude.ai/code/session_01XGvBrUkF5gBoiamBysaRbe
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.
Summary
The S3 proxy read slot has leaked three times, and each fix landed in
utils/file.tsrather than the backend: #497 (reverted in #499), #1104 (the idle timer) and #1106 (the client gone before the stream exists). The slot was freed only if the HTTP layer destroyed the stream on every exit path, which theStorageBackendinterface never said. One leak was still open: a throw insendFileafter the stream existed but beforepipeleft the stream undestroyed and its slot held. The S3 adapter now owns the slot from acquire to release, sosendFileonly has to destroy a source it stops piping. Services also stop branching on path shape where the backend can do the work.How the slot lifetime works
sendFilecreates an AbortController that aborts when the response closes, before the handler runs, and passes its signal to the handler through an AsyncLocalStorage (getResponseSignal()).serveFromBackendforwards it asServeOptions.signal. In proxy mode the S3 adapter rejects a read whose client already left, both before queueing for a slot and after getting one. It passes the signal to GetObject, and wraps the body in a Transform that runs the idle watchdog, joined to the body withpipelineand to the signal withaddAbortSignal. The slot is released on the Transform'sclose, which follows end, error, consumer destroy, abort and idle alike.sendFileregisters "destroy the source when the response closes" before setting any header, so a throw beforepipeis covered as well: its error response closes the response.Changes
backends/s3-storage.backend.ts): owns the slot as above.S3_STREAM_IDLE_TIMEOUT_MSand the zombied-socket explanation moved here fromutils/file.ts. AddsreadAll.utils/file.ts): the response signal and its AsyncLocalStorage. The S3 idle timer is gone. An AbortError that follows the client leaving is no longer logged or answered.interfaces/storage-backend.interface.ts):ServeOptions.signal,readAll, and an optional{ readable }onexists.backends/disk-storage.backend.ts):exists,readAllanddeletego through StorageRepository, the calls they replace in upstream code paths, so upstream specs that mock the repository still apply. A relative key with no media location now throws instead of resolving against the working directory.BaseService.backendFor(key)picks the backend for a path. Absolute paths get a disk backend over the service's own StorageRepository; relative keys go throughStorageService.resolveBackendForKey.ensureLocalFile,getProbeInput,serveFromBackend, the metadata sidecar check and sidecar download, andStorageService.handleDeleteFilesuse it.readAllinstead of two hand-written read-into-a-buffer loops.file.spec.tstos3-storage.backend.spec.ts, which asserts that active slots return to 0. The ML and metadata specs dropped their disk-versus-S3 routing pairs. The MinIO integration spec gained two tests and now uses the same pinned Chainguard image as the storage-migration e2e, since MinIO withdrew its own images.isAbsolute sites
ensureLocalFiledownloadToTempalready returns the absolute path with a no-op cleanupgetProbeInputgetReadableUrlalready returns the absolute pathhandleMetadataExtractionsidecar downloadensureLocalFilehandles both path shapeshandleSidecarCheckbackend.exists(candidate, { readable: true })keeps upstream's R_OK check on diskgetFormDatareadAllon either backend; disk reads throughreadFileas upstream doeshandleDeleteFilesdeleteis upstream'sstorageRepository.unlinkbackendForhandleAssetMigrationhandlePersonMigrationmoveAsset(2)handleSidecarWritedownloadArchivereadAllensureFolders, unlike an S3 putcopySidecarcopyFile; collapsing would change it to a stream copyBehaviour changes
sendFilebetween getting the stream and piping it no longer leaves the slot held.Unable to send fileand answered with a 404 on a dead response. An AbortError while the response is still open is still logged and answered.deletenow goes throughStorageRepository.unlink, which logs and ignores a file that is already gone. Its callers,handleDeleteFilesand the storage migration's source delete, already caught that error and logged a warning, so the outcome is the same.Testing
pnpm test -- --run: 201 files passed, 6445 tests passed.pnpm lint(zero warnings) andpnpm check: clean.DOCKER_HOST=unix:///run/user/1002/podman/podman.sock TESTCONTAINERS_RYUK_DISABLED=true IMMICH_TEST_DOCKER=true npx vitest --config test/vitest.config.mjs --run src/backends/s3-storage.backend.integration.spec.ts): 14/14 passed locally. CI never runs this spec, becauseIMMICH_TEST_DOCKERis not set in any workflow.releaseon close, no wait-time abort check, no pre-queue abort check, noaddAbortSignal, no SDKabortSignal,sendFileregistering the destroy-on-close after the headers, a re-arm that always fires,pipeinstead ofpipeline(the no-unhandled-error test), the AbortError suppression removed or ignoring whether the response closed, diskexistsalways requiring readability (the storage-migration test), and the relative-key guard removed.Follow-ups
StorageServiceand the remaining lazy imports with an injected provider. Deferred because specs touch the static state 88 times and spy onresolveBackendForKeyabout 50 times, so the change would mostly be spec rewrites across upstream-derived spec files.backendForalready cuts the lazy imports from 5 to 3.s3-storage.backend.integration.spec.tsin CI withIMMICH_TEST_DOCKER=true. It is the only test that releases slots against a real HTTP body, and the storage-migration e2e already pulls the same pinned MinIO image.