Repository navigation
Conversation
…gistry The open writers and the HCS plate/well bookkeeping were class-level, which meant per-acquisition only because each acquisition had its own save subprocess. ZarrWriterRegistry makes that explicit and injectable, so an acquisition saving in the main process gets its own. The subprocess keeps using SaveZarrJob.default_registry; clear_writers() and finalize_all_writers() delegate to it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rrJob.submit() Both return the tensorstore write future instead of waiting on it; write_frame() and run() are now submit then wait, unchanged in behavior. Needed by the in-process runner, which must not block the camera callback on a write. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
JobRunner's interface, SaveZarrJob.submit() underneath: dispatch submits the write and returns in about a millisecond; a drain thread waits for each write, keeps the backpressure counters and reports JobResults on the output queue. Not wired to the workers yet. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A frame dispatched after the stores are sealed would open a writer nothing finalizes and leave the backpressure counters raised. dispatch() now returns False, which the worker already treats as a reason to abort. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…er; settle counters in tests A dispatch racing shutdown could land behind the sentinel and never drain. The tests also read the backpressure counters right after the result was queued, before the drain thread's finally had run. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
SaveZarrJob never gets a forked subprocess any more: MultiPointWorker gives it an InProcessZarrRunner. The TIFF formats keep the subprocess. Also seals the store at the end when multiprocessing is off, which the old in-process path never did, and seals an aborted run's stores as aborted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…later TIFF run With Zarr saved in-process, handing the pre-warmed subprocess to a Zarr run only released it and forked a replacement. Nothing is forked for Zarr now, so a process that has used tensorstore (the NDViewer, or a recording) can no longer lose a later Zarr acquisition to the tensorstore fork abort. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A dead save subprocess, or a failed Zarr write surfacing at the end, used to be waited out and the run finished as if nothing had happened, with images never written. The worker now aborts with an error in both cases. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s the run as an error Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ead can open stores run after run initialize() runs on the camera frame callback thread. It created and set an asyncio loop for that thread, and finalize()/abort() on the worker thread closed it, leaving the closed loop as the thread's current loop. Real cameras keep one read thread for their lifetime, so every store of the next Zarr acquisition failed with "Event loop is closed". Open the store and wait on pending writes with tensorstore futures' .result() instead; drop the loop bookkeeping. Spec, codecs, metadata and written bytes are unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d of terminating it The abnormal-shutdown loop in MultiPointController.close() called terminate()/join()/kill() on every live runner. InProcessZarrRunner has no terminate()/join(), so close() logged an AttributeError and the open Zarr stores were never sealed. Shut it down with aborted=True instead. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
When _finish_jobs timed out with jobs still pending it logged them as abandoned and killed the runner, but the run was still reported as completed, while the in-process Zarr runner sealed those stores as aborted. Missing data is an error: abort the run once after the loop. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- in_process_zarr_runner: the process's tensorstore user today is the NDViewer (a recording writer will be). - SaveZarrJob: it writes through its registry, with zarr_writer_info injected by whichever runner dispatches it; the missing-info error names submit()/run(). - multi_point_controller: the pre-warm comments apply to TIFF formats; a Zarr v3 run gets (None, None). - test: open zarr.json with a with block. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…selves subprocesses A bare MagicMock answers runs_in_process truthily, so close() took the new in-process branch and never called terminate(). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Shutdown can seal and clear a Zarr store while TensorStore writes remain active after the drain timeout.
1 open finding
What changed in this PR
Moves Zarr v3 saving into the acquisition process to avoid TensorStore failures in forked subprocesses.
Changes:
- Adds asynchronous in-process Zarr writes and per-acquisition writer registries.
- Updates acquisition lifecycle, backpressure, failure handling, and store sealing.
- Adds comprehensive tests and documentation.
| File | Description |
|---|---|
software/control/core/in_process_zarr_runner.py |
Adds the in-process Zarr runner. |
software/control/core/job_processing.py |
Adds writer registries and non-blocking job submission. |
software/control/core/zarr_writer.py |
Uses TensorStore futures without asyncio. |
software/control/core/multi_point_worker.py |
Routes Zarr saving in-process and propagates failures. |
software/control/core/multi_point_controller.py |
Preserves prewarmed TIFF runners and handles Zarr shutdown. |
software/docs/zarr-v3-format.md |
Documents in-process Zarr saving. |
software/tests/control/core/test_in_process_zarr_runner.py |
Tests runner lifecycle, failures, and backpressure. |
software/tests/control/core/test_zarr_writer.py |
Tests registries, futures, and cross-thread use. |
software/tests/control/test_MultiPointController.py |
Adds acquisition-level Zarr integration tests. |
software/tests/control/core/test_backpressure.py |
Updates subprocess shutdown mocks. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| self._thread.join(timeout=max(timeout_s, 1.0)) | ||
| if self._thread.is_alive(): | ||
| self._log.warning("drain thread still waiting on a write after the shutdown timeout; sealing anyway") | ||
| if self._killed or aborted: | ||
| self.registry.abort_all() |
There was a problem hiding this comment.
[Claude Code] Fixed in 79f5075 - shutdown() now returns False when a write is still in flight after the bounded join and seals the stores as aborted instead of stamping them complete; the worker treats False as an error and aborts the run. Covered by test_shutdown_with_a_write_still_in_flight_seals_aborted_and_reports_it.
- InProcessZarrRunner.shutdown() never stamps a store complete over a write it did not see finish: if the drain thread is still blocked after the bounded join, the stores are sealed aborted and shutdown() returns False, which the worker turns into an error end (Copilot review). - One failure policy: _summarize_job_result requests the abort when a save job fails and abort_on_failed_job is set; the per-FOV and end-of-run copies go. - The backpressure counter contract lives in backpressure.note_job_dispatched / note_job_completed, used by JobRunner and the in-process runner alike. - The drain thread holds the job id, not the job (and its frame), while a write is in flight; a failed submit is a concurrent.futures.Future instead of a bespoke class; isinstance(InProcessZarrRunner) replaces the duck-typed runs_in_process flag. - ZarrWriter loses the never-populated pending-futures API (wait_for_pending, pending_write_count) and write_frame is one line over submit_frame. - Tests: one Zarr v3 controller fixture and one failing-submit helper replace six copies of the same setup; runner tests reuse make_test_capture_info and gain the blocked-write shutdown case. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Problem
tensorstore aborts any
fork()ed child of a process that has already used it, on the child's first tensorstore call:Zarr v3 is written by
SaveZarrJobinside the forkedJobRunnersubprocess, and the subprocess for the next acquisition is forked at the start of the current one. So once the main process has used tensorstore, every later Zarr v3 acquisition is saved by a subprocess that dies on its first write. The main process does not notice: the backpressure wait times out per image (30 s after the 10th pending job), the run logsPending jobs for SaveZarrJob abandoned!!!and ends "normally" with no data on disk.On master today the trigger is the NDViewer (
enable_ndviewer), which opens stores with tensorstore in the main process: Zarr v3 + viewer loses the third acquisition and every one after it. #564 (Record + Z-Stack) adds a recording writer that uses tensorstore in the main process on every recording, which makes this hit every session. Measured in simulation: runs 1–2 complete, runs 3–4 lose the z-stack entirely.What changes
Zarr v3 is saved in the acquisition process; no subprocess is forked for it.
InProcessZarrRunner(new,control/core/in_process_zarr_runner.py): presents the part ofJobRunner's interface the worker uses (dispatch,output_queue,has_pending,shutdown, …) and runsSaveZarrJobhere.dispatchsubmits the tensorstore write and returns (~1 ms in the camera callback; the first frame of each store also creates it there, ~2 ms median / 17 ms max on a local SSD); a drain thread waits on each future in order, keeps the same backpressure counters, and reportsJobResults on the same output queue.shutdown(aborted=...)waits for outstanding writes and seals every store.ZarrWriterRegistry: the open writers and HCS plate/well bookkeeping were class-level onSaveZarrJob(per-acquisition only because each acquisition had its own subprocess). Now an injectable object; the in-process runner owns one per acquisition, the subprocess keepsSaveZarrJob.default_registry.ZarrWriter.submit_frame()/SaveZarrJob.submit(): the non-blocking halves ofwrite_frame()/run(), which are now submit-then-wait with unchanged behavior.ZarrWriterno longer uses a per-thread asyncio event loop (it used one only forts.open). With initialize on the camera callback thread and finalize on the worker thread, the loop state broke the second acquisition on cameras whose callback thread persists; tensorstore futures are waited on directly now.MultiPointWorker/MultiPointController.close():SaveZarrJobgets the in-process runner (and an abnormal app shutdown seals its stores instead of trying toterminate()it); the TIFF job classes keep the forked subprocess exactly as before.MultiPointController.get_prewarmed_job_runner()hands nothing to a Zarr v3 run, so the warm subprocess stays ready for a later TIFF run.What does not change
JobRunnerpath forSaveImageJob/SaveOMETiffJob: same start method, same pre-warm, same pickling.ZarrWriteris still the only Zarr writer and writes the same bytes. Themultiprocessingstart method staysfork.Behavior changes to know about
acquisition_complete: false, aborted: true. The subprocess sealed every storeacquisition_complete: trueregardless.Acquisition.USE_MULTIPROCESSING = Falsewith Zarr v3 now works and seals the store. On master that path aborted on the first image (zarr_writer_infowas never injected in the in-process fallback).main_hcs_worker.logis no longer written for Zarr v3 runs (no subprocess); theFinalized zarr writerlines are inmain_hcs.log.Measurement (why in-process, not spawn/forkserver)
Z-stack phase only, 2 channels × 11 planes of 2048×2048 uint16, simulated camera, three runs each, fresh process per run:
tensorstore compresses and writes on its own threads, so the subprocess only added a copy of every image. A
spawn/forkserversubprocess would also fix the abort but costs 1.6 s to become ready (vs 8 ms) and 224 MB idle (vs 83 MB).Tests
tests/control/core/test_in_process_zarr_runner.py(9), registry /submit()/submit_frame()tests intests/control/core/test_zarr_writer.py, and intests/control/test_MultiPointController.py: Zarr v3 multipoint saves in-process and seals; sealed with multiprocessing off; abort mid-run seals aborted; a Zarr run neither consumes nor restarts the warm subprocess; three Zarr runs in one process all sealed; a write error ends the run as an error (per-FOV and end-of-run); a dead TIFF subprocess aborts the run.Merge gate: bench on the instrument
zarr.jsonhasacquisition_complete: true; noaborting: fork()and noEvent loop is closedin the terminal. (On today's master the third run loses its data.)main_hcs.logcarries theFinalized zarr writerlines; nomain_hcs_worker.logfor the Zarr runs.🤖 Generated with Claude Code