Phase 204 — write_ms/copyms's subtables=/subtable_rows= silently dropped an unmatched name, same shape as Phase 202's ism=
Continuing the src/tables/ sweep, applying standing methodology note #12 directly: once a "sibling parameter family, one outlier skips validation" shape shows up once (Phase 202's ism= vs tsm=/tcm=/ tcell=/dysco=), check other places the SAME shape could recur. Read edit.jl/concatedit.jl in full first (no bug found — the RefEditColumn/ConcatEditColumn/EditColumn machinery, the removerows!/addrows!/_materialize!/_resolve row-index bookkeeping, and the EditColumn{T}-is-always-T=Any design all re-verified correct and mutually consistent), then found it in create.jl's write_ms: subtables= (a collection of subtable keyword names to include) is checked only via want(kw) = subtables === Colon() || kw in subtables, iterating the SOURCE's own real keyword list — a name the caller supplies that never matches any real kw is simply never true, so that subtable is silently skipped with zero error or warning; subtable_rows= (a Dict from keyword name to a row range) has the identical shape via get(subtable_rows, kw, 1:nrow(sub)) — a typo'd key is never looked up, so that subtable silently gets NO row restriction at all instead of the one the caller asked for. Live-verified both: copyms(src, dst; subtables=["SPECTRALWINDOW"]) (missing underscore) wrote ZERO subtables with no indication anything was wrong; copyms(src, dst; subtable_rows=Dict("ANTENA"=>1:1)) wrote the FULL ANTENNA subtable (2 rows) instead of the requested 1 row, again silently.
Fixed by pre-computing the source's real keyword-name set once (MeasurementSets.subtables(main0)) and validating every name in both subtables= and keys(subtable_rows) against it, clearly erroring on any mismatch — and, unlike Phase 199/202's own precedent (both of which validate after _write_table_core's mkpath(dir), leaving a stray empty directory on error), this check runs BEFORE write_ms's own mkpath(dir) entirely, so an invalid name now fails cleanly with no directory created at all. New testset "writems/copyms: an unknown subtables=/subtablerows= name errors (Phase 204)" (6 assertions: both typo'd forms error with no stray directory, valid usage still correctly restricts to exactly the requested subtable + row range). Standalone writer_tests.jl, ism_writer_tests.jl, reftable_tests.jl, edit_tests.jl, container_tests.jl, and schema_tests.jl together (covering every other subtables=/subtable_rows= caller in the test suite), no regressions.
Phase 205 — reference_copy's writable= had the same unvalidated-name shape, with a real aliasing consequence
Continuing the src/tables/ sweep, applying standing methodology note #12 for a third time in this batch. Read edit.jl/concatedit.jl in full first, looking for the same shape — no bug found there (every EditColumn/RefEditColumn/ConcatEditColumn code path, the removerows!/addrows! row-index bookkeeping, and _materialize!/ _resolve's override-vs-tsmedit split all re-verified mutually consistent; also resolved a standing question from earlier in the sweep — EditColumn{T} is always instantiated with T = Any regardless of the real column type, by design, which is why a ConcatEditColumn's per-part EditColumns never hit a type-mismatch even when parts have heterogeneous schemas). Found the actual instance in reference_copy: writable= (the set of column names that should be real independent copies rather than ForwardColumnEngine references) is checked only via c.name in w while iterating the SOURCE's real column names — a typo'd name in writable= is simply never matched, with no error, no warning, and no effect at all: the column silently stays forwarded instead of becoming the independent copy the caller explicitly asked for.
This one has a more consequential failure mode than Phase 202/204's own findings: writable= exists specifically to prevent aliasing (a forwarded column's reads track the source table forever, a writable one is a real independent snapshot), so silently ignoring the request doesn't just produce wrong metadata or an unrestricted copy — it leaves a column ALIASED when the caller explicitly asked for independence. Live-verified: reference_copy(dst, src; writable=["AA"]) (typo for "A") left A forwarded; a subsequent edit(src) changing A silently changed dst's A too — exactly the surprise writable= is supposed to prevent. Fixed by validating every name in writable against the source's real column-name set before proceeding, erroring clearly on a mismatch. New testset "engine — referencecopy's writable= is validated (Phase 205)" (6 assertions: the typo'd case errors with no stray directory, valid writable= usage still correctly isolates the requested column while a non-writable one still tracks the source). Standalone `enginetests.jl` (including the pre-existing "engine — ForwardColumnEngine / reference_copy" testset), no regressions.
Phase 206 — a final src/tables/ pass, explicitly including feature gaps this time, completed the column/getcolumn/getcell MeasurementSet convenience family
The user asked for one last src/tables/ sweep, this time explicitly covering feature gaps as well as bugs (the earlier phases in this batch stayed strictly bug-focused). Started from a gap noted but deliberately deferred during Phase 203's investigation: getcolumn(ms::MeasurementSet, sub, name) — a convenience for reading a subtable column without spelling out subtable(ms, sub) — had no precision= kwarg at all, unlike its 2-arg sibling getcolumn(t, name; precision) (which has had one since Phase 34) and unlike column/getcell's own precision= support. Fixed by threading it through, and — while at it — auditing the whole column/getcolumn/ getcell family for the SAME "one arity has a convenience, its siblings don't" asymmetry (methodology note #12's shape, applied to a whole function family rather than a single kwarg this time) found two more real gaps: getcell(ms, sub, name, row) didn't exist at all (only its getcolumn(ms, sub, name) sibling did), and neither did column(ms, sub, name) — the LAZY verb getcolumn/getcell are both themselves built on. Added both, each a thin wrapper over _pcolumn(subtable(ms, sub), name, precision), matching the existing pattern exactly. All three (column/getcolumn/getcell) already exported by name, so no export list change was needed — these are new methods on already-public functions. Investigated several other candidate gaps and ruled each out as already working or genuinely out of scope: EditColumn/RefEditColumn/ConcatEditColumn lack an explicit getindex/setindex! method for an AbstractVector{<:Integer} range, but Julia's generic AbstractArray interface already provides this via the existing Int-indexed methods (t[:TIME][1:3] and t[:TIME][1:3] = [...] both live-verified to already work correctly — not a gap); every AbstractTable subtype (Table/RefTable/ ConcatTable/GroupedTable) already has full nrow/columnnames/ columndesc/keywords/subtables coverage (no missing method found); a public convenience constructor for building a Record from pairs (rather than mutating the public fields of an empty Record()) is a plausible but speculative API-ergonomics improvement with no concrete evidence anyone's been blocked by it — left alone as out of scope for a bug/gap sweep rather than a deliberate new-API design decision; resync(::EditTable) doesn't exist, but an in-progress edit session resyncing against an external change has no obviously-safe semantic (you'd either lose pending edits or need an undefined merge policy) — concluded a deliberate non-goal, not a gap. New testset assertions added to "precision — getcolumn(ms, sub, name; precision=...) (Phase 206)" (grew from 4 to 12, covering all three of column/getcolumn/ getcell's new/fixed 3-4-arg forms against FEED.POL_RESPONSE, a real TpComplex subtable column in the sample MS). Standalone precision_tests.jl, api_tests.jl, tables_tests.jl, and reftable_tests.jl together, no regressions.
Phase 207 — src/io/ sweep: a severe concurrency bug in the cooperative-locking layer (silent data loss / crashes under ordinary Julia multi-threading)
Redirected from src/tables/ to src/io/ (aips.jl, lock.jl) per the user's direction. aips.jl re-read in full against real casacore's AipsIO.cc — two apparent discrepancies both confirmed sound design choices, not bugs (casacore's putstart double-writing magicval_p for a root object is its own length-placeholder pattern, equivalent to this package's wr_u32(w, 0) placeholder later patched by putend; casacore's getend() tracks bytes-read incrementally and throws on a mismatch, where this package's getend() unconditionally seeks to the declared end — a deliberate, more-forgiving simplification, battle- tested across 200+ phases, not a bug). One genuinely dead branch found (getend's endpos == AIPS_MAGIC check — a.ends is only ever pushed a real computed offset, confirmed via grep, never the sentinel — inert, not fixed, low priority).
The real finding is in lock.jl: the cooperative-locking layer did not actually serialize concurrent edit()/flush() calls on the SAME table from the SAME Julia process — only genuinely protected against a different OS process. Root cause: TableLock's registry reuses one TableLock (one fd) per directory within a process, but POSIX fcntl byte-range locks are scoped per process, not per-task — a second F_SETLK from the same process on a range it already holds trivially succeeds (no blocking), which fcntl was never designed to prevent. So two Julia Tasks (Threads.@spawn, genuine OS-thread parallelism, julia -t 4) both calling edit(path).flush() on the SAME table concurrently both acquired what looked like an exclusive write lock, and their bodies ran genuinely concurrently with no synchronization — live-reproduced: a raw IOError (ENOENT) on one task's atomic rename colliding with the other's temp file, and silent data loss (one task's already-committed writes clobbered by the other's full-file regeneration from its own stale pre-edit snapshot).
First fix attempt — a per-TableLock ReentrantLock (tlock) held across withlock's critical section — confirmed insufficient by live re-test. The crash and data loss still occurred, in a different form each time (which task crashed, which task's edits were lost, flipped). Root cause of that: open_lock's own registry check-then-insert was itself a TOCTOU race — the registry lookup and the later _HELD_LOCKS[key] = lk insert were two separate @lock _REG_LOCK blocks, so two tasks racing on the very first open_lock call for a directory could each see existing === nothing, each build their OWN TableLock (hence their own, independent tlock), and overwrite each other in the registry — silently defeating the tlock fix, since the two concurrent withlock calls were never actually sharing one lock object. Fixed by folding the registry check, the file-open (+ tlock allocation), and the insert into ONE atomic @lock _REG_LOCK critical section (open_lock/_open_lock_new in src/io/lock.jl) — the file I/O now runs while holding the registry lock, a short local-disk op, acceptable for correctness. As a side effect, a LOCK_SUPPORTED=false ("noop") lock is now also registered (previously it bypassed the registry entirely, so even a single-threaded process never shared a tlock across two edit sessions when the OS locking mechanism itself was unavailable — now fixed too).
Re-testing after the TOCTOU fix showed the crash and data loss gone, but a subtler form of data loss remained: even with tlock correctly serializing the flush()-time critical sections, each edit() session's readtable-based materialisation of column data happens before flush is ever called, with no locking at all — so two concurrent edit sessions still captured independent stale pre-edit snapshots before either flushed, and the session that flushed last still silently overwrote the first's committed changes to any row it hadn't itself touched, using its own stale data for the rest. Fixed by moving lock acquisition from flush to edit(path) itself — the write lock (open_lock + tlock + lock_write!) is now held for the whole session, from edit(path)'s own readtable call through flush, so a second session's edit(path) call blocks (on tlock, before it reads a single byte of table data) until the first session's flush has fully completed; the second session's snapshot is then taken after the first's writes, not concurrently with them. flush now reuses the session's own lk instead of opening a fresh withlock; the pre-existing nested-withlock pattern (e.g. write_table_files calling withlock again from inside a flush) continues to work unchanged, since tlock is task-reentrant and the registry's depth refcount already handles nested open_lock calls correctly. A new correctness requirement this introduces: since the lock is now acquired before f(t) runs in edit(f, path) (previously the lock was only ever touched inside flush, so an exception in f had nothing to release), every edit(f, ...)-style wrapper (edit(f, path), edit(f, rt::RefTable), edit(f, ct::ConcatTable), and edit(ct::ConcatTable)'s own per-part loop) needed a try/catch releasing the session lock (via a new, idempotent _release_edit_lock!, guarded by a released::Bool field on EditTable) before rethrowing — otherwise an aborted edit (the do-block body throwing) would leave the table locked for the rest of the process, hanging every subsequent edit() call on it. Live- verified with the exact julia -t 4 Threads.@spawn-two-concurrent- edit()-sessions reproduction from the investigation: zero exceptions, final data exactly correct ([1001..1010, 2011..2020]), across 6 repeated runs; also verified an aborted (throwing) do-block session releases its lock cleanly and does not deadlock a subsequent edit() on the same table. New testset "lock — edit() serializes whole concurrent sessions (Phase 207)" (test/lock_tests.jl, 7 assertions, @async/timedwait-based so it exercises the real blocking behaviour under Julia's cooperative task scheduler without needing multiple OS threads): a second session visibly blocks until the first flushes; an aborted session's lock release doesn't hang a later edit(); a 6-session stress test (each session reads-current-adds-its-own- constant-then-writes, over overlapping rows) confirms full serialization gives the commutative, order-independent correct result regardless of scheduling order. Full suite green — 4952/4952 for the pre-existing suite (no count change to it, since this phase fixes a concurrency bug rather than adding API surface), plus the 7 new assertions above. README/memory updated, merge on the user's word.
Phase 208 — one more src/io/ sweep: a second, subtler registry-key bug in the locking layer
User asked for one last sweep of src/io/ before moving on. aips.jl re-read line-by-line once more: the "multiple sequential top-level AipsIO objects sharing one stream" pattern used by StandardStMan's read_ssmindex loop (h.nrinx separate SSMIndex records read off ONE AipsIO instance) looked suspicious at first — does each one really need/get its own magic-value check, or is the whole combined byte stream really ONE object that this package's per-call a.level == 0 check would wrongly treat as h.nrinx separate root objects? Read real casacore's AipsIO::putstart (casa/IO/AipsIO.cc:483-505) directly: if (level_p == 0) { ... write magic value; } fires on every top-level putstart, not just the very first one in a physical file — confirmed against SSMBase::readIndexBuckets (which does exactly this: one shared AipsIO, a loop calling itsPtrIndex[i]->get(anMOs) nrIdx times) that casacore's own writer genuinely emits a fresh magic before each of the nrinx SSMIndex records. This package's getnexttype's if a.level == 0 check (true again after every getend returns to level 0) already matches this exactly — investigated, not a bug.
The real finding is a second, more subtle instance of Phase 207's own root-cause SHAPE (a registry key that can silently change identity): _lockkey(dir) falls back to abspath(dir) when realpath(dir) throws — which it does whenever dir doesn't exist yet. But on this machine (and any macOS system — /tmp and /var are themselves symlinks to /private/tmp//private/var), abspath of a not-yet- created path and the realpath computed once that SAME path exists are different strings whenever any path component is a symlink — live-confirmed: abspath of a fresh mktempdir()-nested path gives /var/folders/.../newtable.ms, while realpath of the identical path once created gives /private/var/folders/.../newtable.ms. Since open_lock's registry is keyed by this string, calling it once before a directory exists and once after — e.g. edit(path) (Phase 207's own new up-front lock acquisition) racing a concurrent write_table/mkpath for the same not-yet-created path, or simply opening the same brand-new directory twice across two calls that straddle its creation — silently produces two different TableLocks, with two different tlocks, for what is really one directory: exactly the kind of key-identity split Phase 207 already fixed for the check-then-insert race, but via a different mechanism (the KEY itself changing, not a TOCTOU on one fixed key). Live- reproduced directly: open_lock called on the same path before and after mkpath (no release in between, simulating two callers racing the creation) returned lk1 !== lk2, lk1.tlock !== lk2.tlock, before this fix; the same sequence gives lk1 === lk2 after. Fixed _lockkey to resolve the parent directory via realpath (almost always already real) and append the leaf name when dir itself doesn't exist yet, so the key is identical whether the call lands before or after the directory is created — only truly falls back to abspath if even the parent is missing (a multi-level mkpath, the same pathological case the old code already couldn't fully handle). New testset "lock — registry key is stable across a not-yet-created directory (Phase 208)" (test/lock_tests.jl, 5 assertions) reproduces the discrepancy deterministically via an explicit symlink (rather than relying on the platform's own temp-dir layout happening to contain one, so it's portable to Linux CI too) — confirmed to genuinely FAIL against the pre-fix code (3 assertion failures) and pass after.
While in aips.jl, also finally cleaned up the long-documented dead branch in getend (endpos == AIPS_MAGIC || seek(...), noted as inert since Phase 1/6/9 — a.ends is only ever pushed a real computed offset, never the sentinel, confirmed again via grep) — an unconditional seek(a.io, endpos) now, with a comment recording why, instead of a confusing dead condition sitting in a hot read path.
Full suite green (baseline 4959, +5 from the new testset = 4964/4964, no regressions). README/memory updated, merge on the user's word.
Phase 209 — one more src/io/ sweep: a genuine, previously-hidden off-by-one in _remove_reqid!, found via coverage instrumentation rather than another manual re-read
Three prior rounds of manually re-reading aips.jl/lock.jl (Phases 207-208) had converged on diminishing returns, so this round switched method: ran the full suite with Pkg.test(; coverage=true) and diffed which lines in src/io/*.jl were never executed by ANY test (merging .cov output across the main process and every child process test/lock_tests.jl spawns for its cross-process tests). This surfaced two genuinely untested branches directly, rather than relying on re-reading to spot them.
The real bug: _remove_reqid!'s match-finding line — i = findfirst(k -> reqid[2k+2] == mypid && reqid[2k+3] == 0, 0:nr-1) — was using findfirst's return value as if it were the matching 0-based pair index k the closure computed with. It isn't: for a UnitRange that doesn't start at 1 (0:nr-1 here), findfirst(pred, range) returns the 1-based position of the match within the range, not the matching value — confirmed live, findfirst(k -> k==1, 0:2) == 2, not 1. So i was always ONE HIGHER than the true pair index whenever there was more than one request-id entry, and the subsequent shift loop (for k in i:nr-2 ... end) either shifted the wrong span or (the common case, when the true match wasn't in the first two positions) ran zero iterations — silently zeroing the last slot and decrementing the count correctly, while leaving the actual match UNTOUCHED in the file. Live-reproduced directly: hand-populate a 3-entry request-id region ([999, mypid, 888]), call _remove_reqid!() — before the fix, the result was [999, mypid, 0] (the WRONG entry, 888, removed; mypid, the one that was supposed to check itself out, left behind); after the fix, [999, 888, 0] (the correct entry removed, the trailing one correctly shifted down).
This bug has been latent since Phase 17 (when the cooperative-lock request-id list was first added) and was masked by every existing test only ever exercising the degenerate nr == 1 case (add one entry, remove it immediately) — where the off-by-one is harmless by coincidence, since "zero the last slot" and "zero the only slot" are the same operation. The real-world consequence is narrow but genuine: whenever TWO OR MORE processes are simultaneously blocked waiting on the same table.lock (a real, if uncommon, scenario this cooperative- hand-off mechanism exists specifically to handle), a process that finishes waiting removes the WRONG pid's announcement from the file, leaving a stale/departed pid's entry behind and silently dropping a still-genuinely-waiting process's own entry — defeating the cooperative "let a real casacore AutoLocking peer see who's waiting and release early" mechanism for that waiter specifically (the request count stays numerically correct throughout, so a peer that only checks "is anyone waiting" is unaffected; only pid-level introspection, or a subsequent removal that expects to find its own now-shifted entry, would be affected). Fixed by replacing the findfirst-over-a-non-1-based-range idiom with a plain loop that searches by value directly, sidestepping the range-vs-value ambiguity entirely rather than trying to correct the index arithmetic.
Also closed the two OTHER real gaps the same coverage run surfaced, both much lower severity (pure interop completeness / defensive-path verification, not live bugs): (1) _acquire!'s SYNC_MAXWAIT_S[] "gave up waiting for a lock, proceed unlocked" fallback — a genuine, user-visible degrade-to-noop behaviour — had never been exercised by any test; added a real cross-process test (parent holds the lock indefinitely, a child with a short SYNC_MAXWAIT_S[] genuinely times out, confirmed to neither hang nor throw, and to emit the expected @warn). (2) read_array's version < 3 branch (discarding an obsolete per-axis "origin" field from an older AipsIO Array object) had zero coverage, since this package's own writer only ever emits version 3 — added a direct hand-built-bytes unit test in test/aipsio_tests.jl (alongside a version-3 case) so the reader's own claimed backward-compatibility is actually exercised, matching this file's existing "hand-built bytes mirroring real casacore" discipline.
Applied methodology note #8 ("once one function has a given bug shape, grep the WHOLE tree for the same shape") to the findfirst fix specifically: grepped every findfirst call in src/ for the findfirst(pred, <explicit numeric range>) pattern that caused this bug — found nowhere else; every other findfirst call in the codebase operates on an ordinary 1-based Vector/Array, where the returned position IS the correct index to use, so this was a genuinely isolated occurrence, not a repeated shape.
Side investigation (not src/io/, but adjacent to the standing "merged step never checks GitHub CI" workflow gap): checked the ACTUAL GitHub Actions job-level results for the Phase 207/208 merge (the workflow's overall run status shows "cancelled", which looked alarming) — found this is driven entirely by the "Julia pre" (nightly) matrix job hitting the workflow's own explicit 60-minute timeout (a pre-existing, recurring flake unrelated to any of this session's changes — it also happened on the Phase 206 merge, before any lock.jl work) while the two STABLE Linux jobs (Julia 1.10 and 1.12) both genuinely passed — confirming, for the first time with real evidence rather than an assumption, that the Phase 207/208 concurrency/locking changes (including the platform-specific Flock struct, never directly exercised on this ARM64 Mac) do work correctly on real Linux. Not investigated further or fixed (out of scope for this round; the standing workflow gap remains open).
Full suite green: 4977/4977 (baseline 4964 + 13 new assertions). One scare along the way, resolved cleanly: the first two full-suite runs both errored on Aqua's test_persistent_tasks check ("Unable to locate ChainRulesCore, a dependency of SpecialFunctions") — traced to a stale local Manifest.toml in this dev checkout, not this diff: a genuinely clean Pkg.instantiate() of unmodified main in an isolated checkout passed cleanly (4964/4964, no Aqua issue at all), and regenerating this checkout's own Manifest.toml from scratch (rm Manifest.toml; Pkg.instantiate()) then re-running also came back fully clean. Matches the standing "an unexplained test anomaly with a clean diff is very likely an environment artifact" methodology note — this is a new instance of that shape (a drifted local package resolution, not the previously-seen CASA.app DMG mount state). README/memory updated, merge on the user's word.
Phase 210 — src/tables/ sweep: a real removecolumn! gap against DyscoStMan, and a much bigger, previously-undocumented Julia array-literal type-promotion hazard in write_table
Continued the coverage-instrumented-plus-manual-reading discipline established in Phase 209, this time over src/tables/*.jl (the last full sweep of this directory was Phase 206). Ran the full suite with Pkg.test(; coverage=true) (baseline 4977/4977, unchanged — the coverage run itself found no regressions) and diffed the never-executed lines against every src/tables/ file not touched mid-run, then followed up on the two that looked like genuine reachable-but-untested code paths rather than defensive/legacy branches.
Bug 1 — removecolumn! could silently break a DyscoStMan-compressed column forever. open_dyscostman (src/datamanagers/dysco.jl) unconditionally reads column(t, "ANTENNA1")/column(t, "ANTENNA2") at open time for every DyscoStMan-bound column, regardless of normalization (AF's per-baseline scale factors genuinely need them; RF/Row do not, but the same unconditional read still runs either way). EditTable's removecolumn! had no idea about this dependency — removecolumn!(t, "ANTENNA1") on a table with any Dysco-compressed column used to succeed silently at flush time, and only broke on the next read of that column, with a bare KeyError: key "ANTENNA1" not found that names neither the Dysco column nor the real cause. Live-reproduced first (a synthetic AF-normalized Dysco DATA column, removecolumn!(t, "ANTENNA1"), then column(readtable(dir), "DATA")[1] throwing exactly that KeyError), then fixed with a new _dysco_dependents(t, antcol) helper (src/tables/edit.jl) — every remaining column of the session still bound to a DyscoStMan instance — consulted by removecolumn! before any mutation happens, for "ANTENNA1"/"ANTENNA2" only. A typo'd/rejected removal now raises a clear ErrorException naming the dependent column(s) and leaves the table completely untouched (checked before push!(t.dropcols, name), so no half-applied state); removing the Dysco column first (or a table with no Dysco column at all) is unaffected. RefEditTable's own removecolumn! (Phase 127) was re-checked and confirmed exempt — it only ever hides a column at the view level, never touching the parent's real storage, so it can never trigger this; ConcatEditTable's is an unconditional error already (Phase 130), also exempt. New regression testset in test/dysco_tests.jl (both rejections, the untouched-table check, the "remove the Dysco column first" unblock, and the no-Dysco-column no-op case).
Finding 2 — a much bigger, previously-unknown hazard: a bare [...] array literal silently corrupts a numeric column's type before write_table ever sees it. Investigating an adjacent, genuinely working but completely untested code path (_stamp_measinfo's spec isa MeasInfo branch — passing a MeasInfo object straight from measinfo(src, col) into a new table's measures= dict, e.g. to give a freshly-added column the same reference frame as an existing one; confirmed this round-trips correctly and is simply undocumented, not broken) led to trying the same pattern with a VarRefCol (per-row frame) MeasInfo, which crashed with MethodError: no method matching _ref_from_code(::MeasInfo, ::Float64) — the companion reference-code column, written as Int32[1,5,1,5], came back as Float64 on read. Isolating it further (write_table(dir, "T", ["F" => Float64[...], "F_REF" => Int32[...]]; nrow=...), with no measures= involved at all) reproduced the exact same corruption on its own: F_REF is declared TpDouble, not TpInt. The root cause is pure Julia semantics, not a write_table bug: a bare [...] array literal whose elements are structurally similar but not identically-typed (here, Pair{String,Vector{Float64}} and Pair{String,Vector{Int32}}) gets promoted by Julia's own Base.vect to one common concrete type before the literal is ever passed as an argument — confirmed completely independently of this package ([Float64[1,2], Int32[3,4]] alone promotes to Vector{Vector{Float64}}, converting the Int32 values to Float64 in the process). By the time write_table receives columns, the original Int32 vector no longer exists anywhere for it to recover — there is no way to detect or repair this after the fact from inside the function. Confirmed the exact boundary of the hazard live: it fires for any two numeric eltypes Julia can convert between (so the very common real-MS shape of an Int32 antenna-id column next to a Float64 time column in the same bracket literal, and it applies across every column in the literal, not just adjacent pairs — a 3-column literal with two Int32 columns and one Float64 column promotes both integer columns); it does not fire for a Dict(...), an explicitly-typed Pair[...]/Any[...] literal, or a columns built by push!ing into an initially-empty [] (all confirmed to preserve each column's own concrete eltype exactly); and it does not fire when one of the columns is a String vector (no numeric promotion path exists, so Julia leaves both untouched). Confirmed the package's own internal code is never exposed to this (every internal writer — _copy_table_cols, create_ms, the whole copyms/copytable path — builds its data::Vector{Any} via push! in a loop, never a bracket literal), so this is purely a call-site footgun for a package user, not a latent data-corruption bug in any committed fixture or test. Since nothing inside write_table/_write_table_core can detect or prevent it, the fix is documentation: both docstrings now carry a prominent !!! warning (mirroring the live-verified repro exactly, including the Dict/Pair[...]/Any[...]/push! safe alternatives) at the exact point columns/data is described. New regression testset in test/writer_tests.jl pinning both the hazard itself (so a future reader can trust it is real and not a stale claim if Julia's own semantics ever change) and all three documented-safe alternatives, including a _HAVE_CASACORE check that the corrupted table still opens fine in real casacore (just with the wrong declared type) — the MeasInfo-varrefcol case that surfaced this is not separately re-added as a test here since it was never actually broken; documenting the real underlying hazard is the substantive fix.
Two lower-priority leads investigated and left alone (no live fixture to verify against, same "legacy path, no ancient MS available" category as several earlier phases' findings): read_keyset (src/tables/record.jl) — the pre-TableRecord TableKeywordSet/ ScalarKeywordSet/ArrayKeywordSet decoder, genuinely never exercised by anything in this suite since this package's own writer always emits TableRecord and no available real-casacore/TaQL fixture uses the old keyword-set format either; and read_columnset's setversion == 1 legacy-ColumnSet fallback (src/tables/table.jl) — same shape. Neither was touched.
Full suite green (baseline 4977; the coverage run itself, plus a standalone run of test/dysco_tests.jl — 542/542 — and test/edit_tests.jl — 105/105 — after the fix; expect the full-suite count to land around 4977 + ~12 new assertions once merged). README + memory updated, merge on the user's word.
Phase 211 — src/datamanagers/ sweep: a reachable crash from an unvalidated blocksize=, and two genuinely-reachable-but-completely-uncovered code paths confirmed correct and pinned by new tests
Continued the coverage-instrumented sweep, this time over src/datamanagers/*.jl (10 files, ~4,500 lines — the storage-manager / virtual-engine layer; never swept as its own unit before, though individual files here had plenty of targeted attention across earlier phases). Ran Pkg.test(; coverage=true) (baseline 4995/4995, unchanged) plus extensive manual re-reading of standard.jl/incremental.jl/ arrayfile.jl/tiled.jl/forwardcol.jl/virtualtaql.jl — no new bug found there (several candidate concerns investigated and ruled out: DATAMANAGER_PATTERNS's regex dict has no overlapping prefixes so iteration-order nondeterminism can't bite; _le_index's ISM "row precedes every bucket entry" fallback was already confirmed sound in Phase 161; tsm_extend_rows!'s file-sequence-number bookkeeping looks deliberate, not broken). Then followed methodology note #14 and diffed the coverage output against every file.
Bug — an unvalidated blocksize= could crash write_table/ write_ms/create_ms with a confusing, unhelpful error. None of the three container-write entry points ever validated blocksize= (storage=:multifile/:multihdf5's companion kwarg) the way Phase 199 made them validate storage= itself. _finalize_multifile's continuation-block convergence loop divides by blocksize - 8 (cld(need, bs - 8)); live-verified blocksize=8 throws a bare DivideError: integer division error and blocksize=4 throws ArgumentError: invalid GenericMemory size (from a downstream zeros(Int64, <negative>) once the divisor goes negative) — neither error names the real cause. Worse, blocksize < 64 is wrong even where the arithmetic happens not to crash: the reader (open_multifile) always reads the fixed 64-byte header lead directly from file offset 0 in one unconditional readbytes!(io, lead, 64) call, entirely outside the block-chunking mechanism, so any blocksize smaller than that lead would silently corrupt the format at a level no amount of continuation- block bookkeeping could recover from — blocksize >= 64 is the format's real hard floor (matching the existing test suite's own smallest exercised value, 64, exactly). New _check_blocksize(blocksize) (src/datamanagers/container.jl), called at all three of _check_storage's existing call sites (_write_table_core, write_ms, create_ms, src/tables/create.jl) — same "validate before any directory is created" placement Phase 199 established for storage= itself. (An initial worry that a batched for bs in (8,16,32,64) test had actually found a genuine infinite loop, not just a crash, turned out to be a red herring: that run was sharing the CPU with the still-running coverage-instrumented full suite in the background, and an isolated, unshared re-run of the exact same blocksize=8 case threw the expected DivideError immediately — a reminder to isolate a suspicious timing result before trusting it, not just re-run it under load.)
Two genuinely-reachable, completely-uncovered code paths — confirmed correct via live reproduction, now pinned by permanent regression tests (the coverage-diff turned these up; live-verifying each was cheap and each turned out to already work, so the fix is closing the test gap, not the code):
tsm_setcell!'s:cell-kind branch (tiled.jl) — an in-place cell edit of aTiledCellStMan-bound column — had never been exercised by any test at all (not even indirectly), for either aFloat32or aBool(bit-packed) column. Live-verified both edit correctly with siblings left untouched; new testset intest/tsm_multicol_tests.jl. Writing the_HAVE_CASACOREcross-check for this test surfaced a SEPARATE, genuinely interesting finding, inCasacore.jlitself, not this package: aTiledCellStMancolumn whose every row happens to share the same cell shape (a uniform-shape cell, easy to reach for in a quick test even though it's not TiledCellStMan's real use case) makesCasacore.jl'sTables.Column.size()throwMethodError: Cannot convert (Int64,Int64,UInt64) to Tuple{Int64}— real casacore hands back a differently-shaped raw tuple for that case and the Julia wrapper'sN=1type parameter can't absorb it. Confirmed this package's OWN reader already reads such a table back correctly (the corruption, if any, would be entirely on theCasacore.jlread side) — the full initial testset (uniform per-row cell shape) failed the full-suite run this way; switched to the same varying-per-row- shape pattern the adjacent, pre-existing "TiledCellStMan writer + reader" testset already uses successfully, which sidesteps it (and is the more representative case anyway — TiledCellStMan exists specifically for per-row-varying shapes). Not something to fix in this package; noted in the new testset's own comment for the next person who hits it.af_read/af_put!'sTpStringbranches (arrayfile.jl) — theStManArrayFile(table.f<seq>i) indirect-array codec's string handling.StandardStMan's own_ssmkindalways routes a variable-shapeStringcolumn to the separate string-bucket mechanism (:indstr), never toarrayfile.jl— butIncrementalStMan's_ismkindhas no such split (every non-Dimscolumn,Stringor not, is plain:ind), so a raggedString-array column bound to ISM genuinely does reach these branches; nothing in the existing suite ever built one (the existing "ragged → indirect" ISM test covers aFloat64array column, notString). Live-verified a round-trip, a repeated-value ("store on change") case, and an in-placeedit()of one row — all correct; new testset intest/ism_writer_tests.jl.
Full suite green: the first full-suite run (before the Casacore.jl finding above) came back 5008 passed, 3 errored — a genuine, if narrow, failure this phase's own new test caused, caught by running the REAL full suite rather than trusting a standalone run that happened to have Casacore.jl unavailable and so silently skipped the exact block that broke. Fixed (varying per-row shapes, see above); re-verified every touched test file individually in an isolated Pkg.develop+ Casacore+HDF5 scratch environment — tsm_multicol_tests.jl 209/209, ism_writer_tests.jl 193/193, container_tests.jl 240/240, all with the real _HAVE_CASACORE/_HAVE_TAQL cross-checks actually running (not skipped). Final full-suite run: 5011/5011. README + memory updated, merge on the user's word.
Phase 212 — a broken @ref link from Phase 210, the same shape Phase 160 already fixed once
User-reported Documenter build error: Cannot resolve @ref for md"[dyscodependents](@ref)" in docs/src/api-2.md / No docstring found in doc for binding MeasurementSets._dysco_dependents. Root cause: Phase 210's removecolumn! docstring (src/tables/edit.jl) linked to _dysco_dependents via [dyscodependents](@ref) — but _dysco_dependents is internal (underscore-prefixed, never exported) and has no @docs entry anywhere in docs/src/*.md, so Documenter's checkdocs/cross-reference resolution has nothing to resolve the link against, even though the function genuinely does have its own docstring. The EXACT same category of bug Phase 160 already found and fixed once, in three Dysco docstrings — this one was simply written after that fix, in the same PR, and missed. Fixed by dropping the @ref link (plain inline code, $`_dysco_dependents`$, matching Phase 160's own fix). Swept the whole tree (grep -rn '\[[A-Za-z!]*\](@ref)' src/ ext/) for any other @ref link pointing at an underscore-prefixed name — none found, this was the only instance. Could not run the actual docs/make.jl locally to verify end-to-end (a pre-existing, unrelated Git_jll/Expat_jll precompile failure in this dev checkout's docs environment, already noted in an earlier session as an environment artifact independent of any source change — still present, not investigated further here); confirmed instead that the package itself still loads cleanly with the edited docstring and that no other instance of the same pattern exists. Bundled into the same phase211-sweep branch/PR since it was reported mid-phase. No test-count change (docstring-only).