diff --git a/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.json b/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.json index 561cf706..1081aecb 100644 --- a/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.json +++ b/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.json @@ -69,25 +69,25 @@ "sha256": "ec6585496208972e183d3a5bcd1f67bae4d89cc8e6a797d9c6443d8e0754502b" }, { - "bytes": 37562, + "bytes": 46758, "license": "MIT", "path": "crates/labcolors-core/src/program/attachment.rs", "role": "point_attachment_source", - "sha256": "b1bb2f4c25e748ba7c3711454e968bca24ed934f18f00ccdca4a1a2c572bceb7" + "sha256": "721fa5852a400184908a2751f2caab782d6a88cb16bf6e64d5ff0f4d4867fc74" }, { - "bytes": 17406, + "bytes": 25241, "license": "MIT", "path": "crates/labcolors-core/src/program/attachment/support.rs", "role": "point_attachment_test_support", - "sha256": "df2350e853302ffb636abf01d7c1301331fc76f4708ca00d076ea91dac0b14bb" + "sha256": "2ea5dc94f3a101428947ad34dc64cb9fbc7a5a7b666b6795ab837540f98aa7e1" }, { - "bytes": 31748, + "bytes": 54584, "license": "MIT", "path": "crates/labcolors-core/src/program/attachment/tests.rs", "role": "point_attachment_tests", - "sha256": "6ea55c62333a803929fd4b96245362148a555ca2ac5261721a6fad6de9bf7eac" + "sha256": "1a45dafe529c0855b7cabc054b94d096c661e81243d2a80d78f740bcb595d105" }, { "bytes": 162073, diff --git a/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.sha256 b/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.sha256 index a70c5a6e..5602f685 100644 --- a/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.sha256 +++ b/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.sha256 @@ -1 +1 @@ -369e1cf175ac10ab96ca1f6cb82e131e4c24fc43c698a0d853ccabdebb0386d5 receipt-v1.json +644195947c48b841dd319e7586700986ddf4fd4553d593d093c4e109a3002fe3 receipt-v1.json diff --git a/crates/labcolors-core/src/program/attachment.rs b/crates/labcolors-core/src/program/attachment.rs index c5d443d3..2a2f38a5 100644 --- a/crates/labcolors-core/src/program/attachment.rs +++ b/crates/labcolors-core/src/program/attachment.rs @@ -4,7 +4,7 @@ //! compiled owner. Поэтому runtime-update не принимает независимые owner, //! Session, stamp или sink handle, которые клиент мог бы перепутать. -use core::{iter::FusedIterator, mem}; +use core::{fmt, iter::FusedIterator, mem, num::NonZeroU64}; use crate::appearance::EncodedPointPaintV1; use crate::program_session::{ @@ -105,6 +105,26 @@ impl AttachedPointPresentationV1 { } } +/// Непередаваемый compiler-side permit полного terminal scope. +/// +/// Значение создаётся только после точной output→sink и +/// output→presentation bijection, удерживает exact owner generation и +/// поглощается единственным host admission. Content identity сама по себе не +/// является этим полномочием. +pub(crate) struct BoundPointSinkScopePermitV1<'a, SinkOutputId> { + _owner: &'a ProgramOwnerLeaseV1, + emissions: &'a [AttachedPointEmissionV1], + _presentations: &'a [AttachedPointPresentationV1], +} + +impl BoundPointSinkScopePermitV1<'_, SinkOutputId> { + pub(crate) fn output_scope( + &self, + ) -> impl ExactSizeIterator + FusedIterator + '_ { + self.emissions.iter().map(|emission| emission.sink_output()) + } +} + /// Один элемент полного сертифицированного point-снимка для sink. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct PointSinkPatchEntryV1 { @@ -112,6 +132,71 @@ pub(crate) struct PointSinkPatchEntryV1 { paint: EncodedPointPaintV1, } +/// Номинальная локальная для процесса эпоха одной неизменяемой host-привязки. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PointSinkBindingEpochV1(NonZeroU64); + +impl PointSinkBindingEpochV1 { + pub(crate) const fn new(value: NonZeroU64) -> Self { + Self(value) + } +} + +/// Двухсловный CAS-token одной допущенной инкарнации sink. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PointSinkStampV1 { + sequence: u64, + binding_epoch: PointSinkBindingEpochV1, +} + +impl PointSinkStampV1 { + pub(crate) const fn new(sequence: u64, binding_epoch: PointSinkBindingEpochV1) -> Self { + Self { + sequence, + binding_epoch, + } + } + + pub(crate) const fn sequence(self) -> u64 { + self.sequence + } + + pub(crate) const fn binding_epoch(self) -> PointSinkBindingEpochV1 { + self.binding_epoch + } + + const fn checked_successor(self) -> Option { + match self.sequence.checked_add(1) { + Some(sequence) => Some(Self::new(sequence, self.binding_epoch)), + None => None, + } + } +} + +/// Единственный конструируемый Core переход stamp для меняющего снимок intent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PointSinkMutationStampV1 { + expected: PointSinkStampV1, + desired: PointSinkStampV1, +} + +impl PointSinkMutationStampV1 { + const fn new(expected: PointSinkStampV1) -> Option { + match expected.checked_successor() { + Some(desired) => Some(Self { expected, desired }), + None => None, + } + } + + pub(crate) const fn expected(self) -> PointSinkStampV1 { + self.expected + } + + pub(crate) const fn desired(self) -> PointSinkStampV1 { + self.desired + } +} + impl PointSinkPatchEntryV1 { pub(crate) const fn output(self) -> OutputSlotIdV1 { self.emission.output() @@ -133,17 +218,19 @@ struct AttachedRenderPatchEntryV1 { } /// Единственные три intent, принимаемые терминальным point sink. -pub(crate) enum PointSinkIntentV1<'a, SinkOutputId, Stamp> { +pub(crate) enum PointSinkIntentV1<'a, SinkOutputId> { SetAll { revision: u64, + stamp: PointSinkMutationStampV1, patch: &'a [PointSinkPatchEntryV1], }, RevokeAll { revision: u64, + stamp: PointSinkMutationStampV1, }, ConfirmExact { revision: u64, - published_stamp: &'a Stamp, + published_stamp: PointSinkStampV1, }, } @@ -154,12 +241,8 @@ pub(crate) enum PointSinkIntentV1<'a, SinkOutputId, Stamp> { /// scope одной атомарной публикацией. Любой отказ сохраняет прежние наблюдаемые /// scope→value snapshot, revision и равный по [`Eq`] Stamp. pub(crate) trait PreparedPointSinkWriteV1 { - type Stamp: Copy + Eq; type Error; - /// Stamp точного снимка, который опубликует успешный install. - fn proposed_stamp(&self) -> Self::Stamp; - /// Единственная fallible-операция после parsing, allocations и CAS setup. /// /// `Ok` означает, что весь scope уже опубликован атомарно. `Err` означает, @@ -176,18 +259,99 @@ pub(crate) trait PreparedPointSinkWriteV1 { fn finish_after_session(self); } -/// Линейное владение одним точным физическим point-sink scope. -pub(crate) trait LinearPointSinkLeaseV1: sink_private::Sealed { +/// Сырой linear lease, который ещё не создавал Lab-output в host scope. +/// +/// Только успешный admission атомарно устанавливает persistent closed state и +/// превращает lease в [`ClosedPointSinkLeaseV1`]. Ошибка сохраняет прежний host +/// state и возвращает тот же lease: fallible cleanup никогда не пересекает +/// границу [`Attachment`]. +pub(crate) trait UnboundPointSinkLeaseV1: sink_private::Sealed + Sized { + type OutputId: Copy + Eq; + type Closed: ClosedPointSinkLeaseV1; + type AdmissionError; + + /// Scope зарезервирован lease, но ещё не является Lab-output authority. + fn owned_output_scope(&self) -> &[Self::OutputId]; + + /// Последняя fallible-операция создания Attachment. + /// + /// Permit минтится только после полной compiler-backed bijection и всех + /// allocations. Успех обязан атомарно установить closed state, связать + /// новый process-local epoch со всем immutable host binding и вернуть + /// lease, чей Drop можно закрыть без ошибки. Ошибка не меняет host state. + fn try_admit_closed( + self, + scope: BoundPointSinkScopePermitV1<'_, Self::OutputId>, + ) -> Result, PointSinkAdmissionFailureV1>; +} + +/// Атомарный результат допуска: закрытый lease и первый CAS-token одной эпохи. +pub(crate) struct ClosedPointSinkAdmissionV1 +where + L: ClosedPointSinkLeaseV1, +{ + sink: L, + initial_stamp: PointSinkStampV1, +} + +impl ClosedPointSinkAdmissionV1 +where + L: ClosedPointSinkLeaseV1, +{ + fn new(sink: L) -> Self { + let initial_stamp = PointSinkStampV1::new(0, sink.binding_epoch()); + Self { + sink, + initial_stamp, + } + } + + const fn initial_stamp(&self) -> PointSinkStampV1 { + self.initial_stamp + } + + fn into_parts(self) -> (L, PointSinkStampV1) { + (self.sink, self.initial_stamp) + } +} + +/// Owning-отказ host admission; исходный unbound lease остаётся retryable. +pub(crate) struct PointSinkAdmissionFailureV1 +where + L: UnboundPointSinkLeaseV1, +{ + cause: L::AdmissionError, + sink: L, +} + +impl PointSinkAdmissionFailureV1 +where + L: UnboundPointSinkLeaseV1, +{ + pub(crate) const fn new(cause: L::AdmissionError, sink: L) -> Self { + Self { cause, sink } + } + + pub(crate) fn into_parts(self) -> (L::AdmissionError, L) { + (self.cause, self.sink) + } +} + +/// Линейное владение admission-bound физическим point-sink scope. +/// +/// Сам typestate является единственным closed-absence + infallible-release +/// capability. Его Stamp обязан включать process-local binding epoch, который +/// меняется при любом изменении realm, host root, owned scope, codec release, +/// capability set или atomic primitive. Эти host-факты не становятся Core DTO. +pub(crate) trait ClosedPointSinkLeaseV1: sink_private::Sealed { type OutputId: Copy + Eq; - type Stamp: Copy + Eq; type Error; - type Prepared<'lease>: PreparedPointSinkWriteV1 + type Prepared<'lease>: PreparedPointSinkWriteV1 where Self: 'lease; - /// Точный scope, которым эксклюзивно владеет lease, в каноническом порядке - /// выходов скомпилированной Program. - fn owned_output_scope(&self) -> &[Self::OutputId]; + /// Неизменяемая эпоха полномочия tombstone, захваченная атомарным допуском. + fn binding_epoch(&self) -> PointSinkBindingEpochV1; /// Готовит полный снимок, не сохраняя borrowed-данные patch. /// @@ -196,14 +360,14 @@ pub(crate) trait LinearPointSinkLeaseV1: sink_private::Sealed { /// допустимую часть нового снимка. fn prepare<'lease>( &'lease mut self, - intent: PointSinkIntentV1<'_, Self::OutputId, Self::Stamp>, + intent: PointSinkIntentV1<'_, Self::OutputId>, ) -> Result, Self::Error>; /// Атомарно отзывает полный scope lease перед его освобождением. /// /// Реализация обязана быть infallible и allocation-free, даже если ни один /// снимок ещё не публиковался. - fn revoke_all_before_release(&mut self, published_stamp: Option<&Self::Stamp>); + fn close_before_release(&mut self); } /// Cold-ошибка создания Attachment; sink ещё ничего не опубликовал. @@ -252,10 +416,9 @@ pub(crate) enum AttachmentCreateErrorV1 { DuplicateSinkScopeOutput { sink_output: SinkOutputId, }, - SinkScopeMismatch { - ordinal: usize, - binding: SinkOutputId, - owned: SinkOutputId, + UnownedSinkOutput { + output: OutputSlotIdV1, + sink_output: SinkOutputId, }, InvalidPointBinding { authored_index: usize, @@ -264,17 +427,79 @@ pub(crate) enum AttachmentCreateErrorV1 { InternalInvariant, } +/// Точная причина cold attach failure; lease хранится один раз во внешнем +/// owning-контейнере и не дублируется по вариантам. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum AttachmentCreateCauseV1 { + Contract(AttachmentCreateErrorV1), + SinkAdmission(SinkAdmissionError), +} + +/// Cold failure сохраняет тот же unbound lease для исправления и retry. +pub(crate) struct AttachmentCreateFailureV1 +where + L: UnboundPointSinkLeaseV1, +{ + cause: AttachmentCreateCauseV1, + sink: L, +} + +impl fmt::Debug for AttachmentCreateFailureV1 +where + L: UnboundPointSinkLeaseV1, + L::OutputId: fmt::Debug, + L::AdmissionError: fmt::Debug, +{ + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AttachmentCreateFailureV1") + .field("cause", &self.cause) + .finish_non_exhaustive() + } +} + +impl AttachmentCreateFailureV1 +where + L: UnboundPointSinkLeaseV1, +{ + const fn contract(cause: AttachmentCreateErrorV1, sink: L) -> Self { + Self { + cause: AttachmentCreateCauseV1::Contract(cause), + sink, + } + } + + const fn sink_admission(cause: L::AdmissionError, sink: L) -> Self { + Self { + cause: AttachmentCreateCauseV1::SinkAdmission(cause), + sink, + } + } + + pub(crate) const fn cause(&self) -> &AttachmentCreateCauseV1 { + &self.cause + } + + pub(crate) fn into_sink(self) -> L { + self.sink + } + + pub(crate) fn into_parts(self) -> (AttachmentCreateCauseV1, L) { + (self.cause, self.sink) + } +} + /// Закрытый отказ уже скомпилированной терминальной транзакции. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum AttachmentInvariantV1 { EmptyIdempotentHead, - MissingPublishedStamp, + MissingCommittedRevision, PublishedRevisionMismatch, OutputCountMismatch, OutputIdentityMismatch, PaintIdentityMismatch, ScratchCapacityLost, - ConfirmStampMismatch, + SinkStampExhausted, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -286,12 +511,8 @@ pub(crate) enum AttachmentUpdateErrorV1 { } type AttachmentUpdateResultV1<'a, L> = Result< - AttachmentCommitV1< - 'a, - ::OutputId, - ::Stamp, - >, - AttachmentUpdateErrorV1<::Error>, + AttachmentCommitV1<'a, ::OutputId>, + AttachmentUpdateErrorV1<::Error>, >; /// Prospective sink-смысл одного полностью вычисленного перехода Session. @@ -333,11 +554,7 @@ fn prepared_disposition<'prepared>( } } -struct PublishedAttachmentStampV1 { - revision: u64, - sink: Stamp, -} - +#[derive(Clone, Copy)] enum PreparedPatchActionV1 { SetAll { revision: u64 }, RevokeAll { revision: u64 }, @@ -354,41 +571,39 @@ impl PreparedPatchActionV1 { } } -/// Borrowed exact stamp снимка, принадлежащего одному Attachment. -pub(crate) struct AttachedPublishedStampV1<'a, Stamp> { - inner: &'a PublishedAttachmentStampV1, +/// Компактное заимствованное представление exact stamp одного Attachment. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct AttachedPublishedStampV1<'a> { + revision: u64, + sink: &'a PointSinkStampV1, } -impl Copy for AttachedPublishedStampV1<'_, Stamp> {} - -impl Clone for AttachedPublishedStampV1<'_, Stamp> { - fn clone(&self) -> Self { - *self +impl<'a> AttachedPublishedStampV1<'a> { + pub(crate) const fn revision(self) -> u64 { + self.revision } -} -impl AttachedPublishedStampV1<'_, Stamp> { - pub(crate) const fn revision(self) -> u64 { - self.inner.revision + pub(crate) const fn sink_stamp(self) -> PointSinkStampV1 { + *self.sink } } /// Один элемент final render-authority после commit sink и Session. -pub(crate) struct AttachedRenderOutputV1<'a, SinkOutputId, Stamp> { +pub(crate) struct AttachedRenderOutputV1<'a, SinkOutputId> { certificate: VerifiedCertificateV1<'a>, patch: AttachedRenderPatchEntryV1, - published_stamp: AttachedPublishedStampV1<'a, Stamp>, + published_stamp: AttachedPublishedStampV1<'a>, } -impl Copy for AttachedRenderOutputV1<'_, SinkOutputId, Stamp> {} +impl Copy for AttachedRenderOutputV1<'_, SinkOutputId> {} -impl Clone for AttachedRenderOutputV1<'_, SinkOutputId, Stamp> { +impl Clone for AttachedRenderOutputV1<'_, SinkOutputId> { fn clone(&self) -> Self { *self } } -impl<'a, SinkOutputId: Copy, Stamp> AttachedRenderOutputV1<'a, SinkOutputId, Stamp> { +impl<'a, SinkOutputId: Copy> AttachedRenderOutputV1<'a, SinkOutputId> { pub(crate) const fn certificate(self) -> VerifiedCertificateV1<'a> { self.certificate } @@ -413,32 +628,33 @@ impl<'a, SinkOutputId: Copy, Stamp> AttachedRenderOutputV1<'a, SinkOutputId, Sta self.patch.presentation.sink_output() } - pub(crate) const fn published_stamp(self) -> AttachedPublishedStampV1<'a, Stamp> { + pub(crate) const fn published_stamp(self) -> AttachedPublishedStampV1<'a> { self.published_stamp } } /// Точный post-commit view; historical evidence и render authority не смешаны. -pub(crate) struct AttachmentCommitV1<'a, SinkOutputId, Stamp> { +pub(crate) struct AttachmentCommitV1<'a, SinkOutputId> { evidence: EvidenceViewV1<'a>, committed_render_patch: &'a [AttachedRenderPatchEntryV1], - published_stamp: &'a PublishedAttachmentStampV1, + committed_revision: u64, + committed_sink_stamp: &'a PointSinkStampV1, } -impl Copy for AttachmentCommitV1<'_, SinkOutputId, Stamp> {} +impl Copy for AttachmentCommitV1<'_, SinkOutputId> {} -impl Clone for AttachmentCommitV1<'_, SinkOutputId, Stamp> { +impl Clone for AttachmentCommitV1<'_, SinkOutputId> { fn clone(&self) -> Self { *self } } -impl<'a, SinkOutputId: Copy, Stamp> AttachmentCommitV1<'a, SinkOutputId, Stamp> { +impl<'a, SinkOutputId: Copy> AttachmentCommitV1<'a, SinkOutputId> { pub(crate) const fn evidence(self) -> EvidenceViewV1<'a> { self.evidence } - pub(crate) fn render_outputs(self) -> AttachedRenderOutputsV1<'a, SinkOutputId, Stamp> { + pub(crate) fn render_outputs(self) -> AttachedRenderOutputsV1<'a, SinkOutputId> { let certificate = match self.evidence.state() { SessionState::Ready { current } => Some(VerifiedCertificateV1 { inner: current }), SessionState::Waiting | SessionState::Stale { .. } | SessionState::Failed { .. } => { @@ -449,22 +665,23 @@ impl<'a, SinkOutputId: Copy, Stamp> AttachmentCommitV1<'a, SinkOutputId, Stamp> certificate, committed_render_patch: self.committed_render_patch, published_stamp: AttachedPublishedStampV1 { - inner: self.published_stamp, + revision: self.committed_revision, + sink: self.committed_sink_stamp, }, index: 0, } } } -pub(crate) struct AttachedRenderOutputsV1<'a, SinkOutputId, Stamp> { +pub(crate) struct AttachedRenderOutputsV1<'a, SinkOutputId> { certificate: Option>, committed_render_patch: &'a [AttachedRenderPatchEntryV1], - published_stamp: AttachedPublishedStampV1<'a, Stamp>, + published_stamp: AttachedPublishedStampV1<'a>, index: usize, } -impl<'a, SinkOutputId: Copy, Stamp> Iterator for AttachedRenderOutputsV1<'a, SinkOutputId, Stamp> { - type Item = AttachedRenderOutputV1<'a, SinkOutputId, Stamp>; +impl<'a, SinkOutputId: Copy> Iterator for AttachedRenderOutputsV1<'a, SinkOutputId> { + type Item = AttachedRenderOutputV1<'a, SinkOutputId>; fn next(&mut self) -> Option { let certificate = self.certificate?; @@ -487,16 +704,13 @@ impl<'a, SinkOutputId: Copy, Stamp> Iterator for AttachedRenderOutputsV1<'a, Sin } } -impl ExactSizeIterator - for AttachedRenderOutputsV1<'_, SinkOutputId, Stamp> -{ -} -impl FusedIterator for AttachedRenderOutputsV1<'_, SinkOutputId, Stamp> {} +impl ExactSizeIterator for AttachedRenderOutputsV1<'_, SinkOutputId> {} +impl FusedIterator for AttachedRenderOutputsV1<'_, SinkOutputId> {} /// Владеет одной Session, одним exact Program pin и одним linear writer. pub(crate) struct Attachment where - L: LinearPointSinkLeaseV1, + L: ClosedPointSinkLeaseV1, { // Порядок полей задаёт освобождение после `Drop::drop`: writer, Session, // инертные снимки и последней — точная Program generation. @@ -510,38 +724,24 @@ where scratch_sink_patch: Vec>, committed_render_patch: Vec>, scratch_render_patch: Vec>, - published_stamp: Option>, + expected_sink_stamp: PointSinkStampV1, + committed_revision: Option, // Вытеснённые Session evidence и transaction owner после install только // переносятся сюда и освобождаются до следующего prepare. retired_session: Option, _owner_pin: ProgramOwnerLeaseV1, } -struct UnpublishedSinkGuardV1<'a, L: LinearPointSinkLeaseV1> { - sink: &'a mut L, - armed: bool, -} - -impl<'a, L: LinearPointSinkLeaseV1> UnpublishedSinkGuardV1<'a, L> { - const fn new(sink: &'a mut L) -> Self { - Self { sink, armed: true } - } - - fn sink(&self) -> &L { - self.sink - } - - fn disarm(mut self) { - self.armed = false; - } -} - -impl Drop for UnpublishedSinkGuardV1<'_, L> { - fn drop(&mut self) { - if self.armed { - self.sink.revoke_all_before_release(None); - } - } +/// Все fallible Core-части cold attach, завершённые до host admission. +struct PreparedAttachmentColdV1 { + session: SessionV1, + emissions: Vec>, + presentations: Vec>, + committed_sink_patch: Vec>, + scratch_sink_patch: Vec>, + committed_render_patch: Vec>, + scratch_render_patch: Vec>, + owner_pin: ProgramOwnerLeaseV1, } impl OwnerV1 { @@ -552,34 +752,81 @@ impl OwnerV1 { authored_emissions: &[AuthoredPointEmissionBindingV1], authored_presentations: &[AuthoredPointPresentationBindingV1], sink: L, - ) -> Result, AttachmentCreateErrorV1> + ) -> Result, AttachmentCreateFailureV1> where - L: LinearPointSinkLeaseV1, + L: UnboundPointSinkLeaseV1, { - Attachment::try_new( + let prepared = match PreparedAttachmentColdV1::try_new( self, stream_id, authored_emissions, authored_presentations, - sink, - ) + &sink, + ) { + Ok(prepared) => prepared, + Err(cause) => return Err(AttachmentCreateFailureV1::contract(cause, sink)), + }; + let permit = BoundPointSinkScopePermitV1 { + _owner: &prepared.owner_pin, + emissions: &prepared.emissions, + _presentations: &prepared.presentations, + }; + let admission = match sink.try_admit_closed(permit) { + Ok(admission) => admission, + Err(failure) => { + let (cause, sink) = failure.into_parts(); + return Err(AttachmentCreateFailureV1::sink_admission(cause, sink)); + } + }; + // Возвращаемый `Self`, а не `Result`, типом закрывает fallible-границу. + Ok(Attachment::from_closed_admission(prepared, admission)) } } impl Attachment where - L: LinearPointSinkLeaseV1, + L: ClosedPointSinkLeaseV1, +{ + fn from_closed_admission( + prepared: PreparedAttachmentColdV1, + admission: ClosedPointSinkAdmissionV1, + ) -> Self { + // POST_ADMISSION_TAIL_START_V1 + let (sink, initial_sink_stamp) = admission.into_parts(); + let attachment = Self { + sink, + session: prepared.session, + emissions: prepared.emissions, + presentations: prepared.presentations, + committed_sink_patch: prepared.committed_sink_patch, + scratch_sink_patch: prepared.scratch_sink_patch, + committed_render_patch: prepared.committed_render_patch, + scratch_render_patch: prepared.scratch_render_patch, + expected_sink_stamp: initial_sink_stamp, + committed_revision: None, + retired_session: None, + _owner_pin: prepared.owner_pin, + }; + // POST_ADMISSION_TAIL_END_V1 + attachment + } +} + +impl PreparedAttachmentColdV1 +where + SinkOutputId: Copy + Eq, { - /// Атомарно связывает authored IDs и pin той же exact compiled generation. - fn try_new( + /// Связывает authored IDs и pin той же exact compiled generation до host admission. + fn try_new( owner: &OwnerV1, stream_id: u32, - authored_emissions: &[AuthoredPointEmissionBindingV1], + authored_emissions: &[AuthoredPointEmissionBindingV1], authored_presentations: &[AuthoredPointPresentationBindingV1], - sink: L, - ) -> Result> { - let mut sink = sink; - let sink_guard = UnpublishedSinkGuardV1::new(&mut sink); + sink: &L, + ) -> Result> + where + L: UnboundPointSinkLeaseV1, + { let expected_outputs = owner.compiled.output_count(); if authored_emissions.len() != expected_outputs { return Err(AttachmentCreateErrorV1::EmissionBindingCount { @@ -588,7 +835,7 @@ where }); } - let mut emissions: Vec> = Vec::new(); + let mut emissions: Vec> = Vec::new(); emissions .try_reserve_exact(expected_outputs) .map_err(|_| AttachmentCreateErrorV1::ResourceExhausted)?; @@ -633,7 +880,7 @@ where } } - let owned_scope = sink_guard.sink().owned_output_scope(); + let owned_scope = sink.owned_output_scope(); if owned_scope.len() != emissions.len() { return Err(AttachmentCreateErrorV1::SinkScopeCount { expected: emissions.len(), @@ -739,16 +986,11 @@ where if presentation_index != presentations.len() { return Err(AttachmentCreateErrorV1::InternalInvariant); } - for (ordinal, (binding, owned)) in emissions - .iter() - .zip(owned_scope.iter().copied()) - .enumerate() - { - if binding.sink_output != owned { - return Err(AttachmentCreateErrorV1::SinkScopeMismatch { - ordinal, - binding: binding.sink_output, - owned, + for binding in &emissions { + if !owned_scope.contains(&binding.sink_output) { + return Err(AttachmentCreateErrorV1::UnownedSinkOutput { + output: binding.output, + sink_output: binding.sink_output, }); } } @@ -774,9 +1016,7 @@ where .instantiate(stream_id) .map_err(AttachmentCreateErrorV1::Instantiate)?; let owner_pin = owner.compiled.pin_owner(); - sink_guard.disarm(); Ok(Self { - sink, session, emissions, presentations, @@ -784,12 +1024,15 @@ where scratch_sink_patch, committed_render_patch, scratch_render_patch, - published_stamp: None, - retired_session: None, - _owner_pin: owner_pin, + owner_pin, }) } +} +impl Attachment +where + L: ClosedPointSinkLeaseV1, +{ /// Готовит, атомарно устанавливает и infallibly публикует целый update. pub(crate) fn update(&mut self, update: UpdateV1<'_>) -> AttachmentUpdateResultV1<'_, L> { drop(self.retired_session.take()); @@ -800,22 +1043,21 @@ where let disposition = prepared_disposition(&transition) .map_err(AttachmentUpdateErrorV1::InternalInvariant)?; - let confirmed_stamp = match &disposition { + match &disposition { PreparedDispositionV1::ConfirmExact { revision } => { - let published = self.published_stamp.as_ref().ok_or( - AttachmentUpdateErrorV1::InternalInvariant( - AttachmentInvariantV1::MissingPublishedStamp, - ), - )?; - if published.revision != *revision { + let published_revision = + self.committed_revision + .ok_or(AttachmentUpdateErrorV1::InternalInvariant( + AttachmentInvariantV1::MissingCommittedRevision, + ))?; + if published_revision != *revision { return Err(AttachmentUpdateErrorV1::InternalInvariant( AttachmentInvariantV1::PublishedRevisionMismatch, )); } - Some(&published.sink) } - PreparedDispositionV1::SetAll { .. } | PreparedDispositionV1::RevokeAll { .. } => None, - }; + PreparedDispositionV1::SetAll { .. } | PreparedDispositionV1::RevokeAll { .. } => {} + } let action = match disposition { PreparedDispositionV1::SetAll { revision, outputs } => { @@ -839,22 +1081,40 @@ where } }; - let intent = match &action { - PreparedPatchActionV1::SetAll { revision } => PointSinkIntentV1::SetAll { - revision: *revision, - patch: &self.scratch_sink_patch, - }, - PreparedPatchActionV1::RevokeAll { revision } => PointSinkIntentV1::RevokeAll { - revision: *revision, - }, - PreparedPatchActionV1::ConfirmExact { revision } => PointSinkIntentV1::ConfirmExact { - revision: *revision, - published_stamp: confirmed_stamp.ok_or( + let (intent, desired_sink_stamp) = match action { + PreparedPatchActionV1::SetAll { revision } => { + let stamp = PointSinkMutationStampV1::new(self.expected_sink_stamp).ok_or( AttachmentUpdateErrorV1::InternalInvariant( - AttachmentInvariantV1::MissingPublishedStamp, + AttachmentInvariantV1::SinkStampExhausted, ), - )?, - }, + )?; + ( + PointSinkIntentV1::SetAll { + revision, + stamp, + patch: &self.scratch_sink_patch, + }, + stamp.desired(), + ) + } + PreparedPatchActionV1::RevokeAll { revision } => { + let stamp = PointSinkMutationStampV1::new(self.expected_sink_stamp).ok_or( + AttachmentUpdateErrorV1::InternalInvariant( + AttachmentInvariantV1::SinkStampExhausted, + ), + )?; + ( + PointSinkIntentV1::RevokeAll { revision, stamp }, + stamp.desired(), + ) + } + PreparedPatchActionV1::ConfirmExact { revision } => ( + PointSinkIntentV1::ConfirmExact { + revision, + published_stamp: self.expected_sink_stamp, + }, + self.expected_sink_stamp, + ), }; let sink_prepared = self .sink @@ -862,20 +1122,6 @@ where .map_err(AttachmentUpdateErrorV1::SinkPrepare)?; let mut prepared: PreparedAttachmentUpdateV1<'_, '_, L> = PreparedAttachmentUpdateV1::new(transition, sink_prepared); - let next_stamp = PublishedAttachmentStampV1 { - revision: action.revision(), - sink: prepared.proposed_stamp(), - }; - if matches!(&action, PreparedPatchActionV1::ConfirmExact { .. }) { - let expected = confirmed_stamp.ok_or(AttachmentUpdateErrorV1::InternalInvariant( - AttachmentInvariantV1::MissingPublishedStamp, - ))?; - if next_stamp.sink != *expected { - return Err(AttachmentUpdateErrorV1::InternalInvariant( - AttachmentInvariantV1::ConfirmStampMismatch, - )); - } - } prepared .try_install() @@ -894,13 +1140,15 @@ where } PreparedPatchActionV1::ConfirmExact { .. } => {} } - let published_stamp = self.published_stamp.insert(next_stamp); + self.expected_sink_stamp = desired_sink_stamp; + self.committed_revision = Some(action.revision()); installed_sink.finish_after_session(); Ok(AttachmentCommitV1 { evidence: self.session.evidence(), committed_render_patch: &self.committed_render_patch, - published_stamp, + committed_revision: action.revision(), + committed_sink_stamp: &self.expected_sink_stamp, }) } @@ -912,12 +1160,11 @@ where impl Drop for Attachment where - L: LinearPointSinkLeaseV1, + L: ClosedPointSinkLeaseV1, { fn drop(&mut self) { - self.sink - .revoke_all_before_release(self.published_stamp.as_ref().map(|stamp| &stamp.sink)); - self.published_stamp = None; + self.sink.close_before_release(); + self.committed_revision = None; self.committed_sink_patch.clear(); self.committed_render_patch.clear(); } @@ -978,7 +1225,7 @@ fn stage_complete_patches( /// Общий token: abort всегда уничтожает evidence до освобождения Busy. struct PreparedAttachmentUpdateV1<'session, 'sink, L> where - L: LinearPointSinkLeaseV1 + 'sink, + L: ClosedPointSinkLeaseV1 + 'sink, { // Порядок объявления и есть abort-протокол: prospective evidence // уничтожается, пока sink ещё удерживает Busy. @@ -988,7 +1235,7 @@ where impl<'session, 'sink, L> PreparedAttachmentUpdateV1<'session, 'sink, L> where - L: LinearPointSinkLeaseV1 + 'sink, + L: ClosedPointSinkLeaseV1 + 'sink, { fn new( transition: CorePreparedSessionTransitionV1<'session>, @@ -997,10 +1244,6 @@ where Self { transition, sink } } - fn proposed_stamp(&self) -> L::Stamp { - self.sink.proposed_stamp() - } - fn try_install(&mut self) -> Result<(), L::Error> { self.sink.try_install() } diff --git a/crates/labcolors-core/src/program/attachment/support.rs b/crates/labcolors-core/src/program/attachment/support.rs index 0380a54b..155c0b35 100644 --- a/crates/labcolors-core/src/program/attachment/support.rs +++ b/crates/labcolors-core/src/program/attachment/support.rs @@ -1,5 +1,6 @@ use std::{ cell::{Cell, RefCell}, + num::NonZeroU64, rc::Rc, sync::atomic::{AtomicU64, Ordering}, }; @@ -19,39 +20,106 @@ impl TestSinkOutputIdV1 { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct TestPublishedStampV1 { - sequence: u64, - epoch: u64, -} - // Stamp должен оставаться Copy, поэтому test sink получает неповторимую эпоху // из монотонного issuer-а, а не владеет Rc и не выводит identity из адреса. static NEXT_TEST_SINK_EPOCH: AtomicU64 = AtomicU64::new(1); -impl TestPublishedStampV1 { - fn next(&self) -> Result { - Ok(Self { - sequence: self - .sequence - .checked_add(1) - .ok_or(InMemoryPointSinkErrorV1::StampExhausted)?, - epoch: self.epoch, - }) +impl PointSinkStampV1 { + const fn rebound(self, epoch: PointSinkBindingEpochV1) -> Self { + Self::new(self.sequence(), epoch) } } +fn next_test_sink_epoch() -> Option { + NEXT_TEST_SINK_EPOCH + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + current.checked_add(1) + }) + .ok() + .and_then(NonZeroU64::new) + .map(PointSinkBindingEpochV1::new) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum InMemoryPointSinkErrorV1 { Busy, + BindingDrift, StampMismatch, RejectedPrepare, RejectedInstall, RejectedInstallAfterSwap, - StampExhausted, ResourceExhausted, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct TestHostBindingV1 { + generation: u64, + realm: u64, + root: u64, + scope: u64, + codec: u64, + capabilities: u64, + tombstone: u64, +} + +impl TestHostBindingV1 { + const INITIAL: Self = Self { + generation: 1, + realm: 1, + root: 2, + scope: 3, + codec: 4, + capabilities: 5, + tombstone: 6, + }; + + fn drifted(mut self, axis: TestHostBindingAxisV1) -> Self { + let value = match axis { + TestHostBindingAxisV1::Realm => &mut self.realm, + TestHostBindingAxisV1::Root => &mut self.root, + TestHostBindingAxisV1::Scope => &mut self.scope, + TestHostBindingAxisV1::Codec => &mut self.codec, + TestHostBindingAxisV1::Capabilities => &mut self.capabilities, + TestHostBindingAxisV1::Tombstone => &mut self.tombstone, + }; + *value = value.wrapping_add(1); + self.generation = self + .generation + .checked_add(1) + .unwrap_or_else(|| unreachable!("test host generation exhausted")); + self + } + + fn restored_facts(self) -> Self { + Self { + generation: self + .generation + .checked_add(1) + .unwrap_or_else(|| unreachable!("test host generation exhausted")), + ..Self::INITIAL + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TestHostBindingAxisV1 { + Realm, + Root, + Scope, + Codec, + Capabilities, + Tombstone, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum InMemoryPointSinkAdmissionErrorV1 { + RejectedBeforeInstall, + RejectedAfterInstall, + ScopeChanged, + HostStateChanged, + EpochExhausted, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct TestSnapshotEntryV1 { output: OutputSlotIdV1, @@ -80,26 +148,36 @@ pub(crate) struct TestIntentCountsV1 { pub(crate) confirm_exact: usize, } +enum TestHostLayerV1 { + AmbientExposed, + Closed, + Published(Vec), +} + struct TestSinkStateV1 { - snapshot: Vec, + // `layer` остаётся admission-bound resource; host drift создаёт отдельный + // foreign scope, которым старый closed lease никогда не владеет. + layer: TestHostLayerV1, + foreign_layer: Option, revision: Option, - stamp: TestPublishedStampV1, + stamp: Option, reject_next_install: bool, counts: TestIntentCountsV1, revoke_count: usize, sequence: u64, revoke_sequence: Option, lease_drop_sequence: Option, - revoke_saw_exact_stamp: bool, } struct TestSinkSharedV1 { state: RefCell, + host_binding: Cell, + reject_next_admission: Cell, + reject_next_admission_after_install: Cell, busy: Cell, reject_next_prepare: Cell, rejected_prepare_saw_busy: Cell, reject_next_install_after_swap: Cell, - misreport_next_confirm_proposed_stamp: Cell, panic_on_retirement_drop: Cell, retirement_drop_count: Cell, measure_terminal_tail: Cell, @@ -108,6 +186,13 @@ struct TestSinkSharedV1 { pub(crate) struct InMemoryPointSinkLeaseV1 { owned_scope: Vec, shared: Rc, +} + +pub(crate) struct ClosedInMemoryPointSinkLeaseV1 { + _owned_scope: Vec, + shared: Rc, + bound_host: TestHostBindingV1, + binding_epoch: PointSinkBindingEpochV1, retired: Option, } @@ -118,17 +203,87 @@ pub(crate) struct InMemoryPointSinkProbeV1 { impl InMemoryPointSinkProbeV1 { pub(crate) fn snapshot(&self) -> Vec { - self.shared.state.borrow().snapshot.clone() + match &self.shared.state.borrow().layer { + TestHostLayerV1::Published(snapshot) => snapshot.clone(), + TestHostLayerV1::AmbientExposed | TestHostLayerV1::Closed => Vec::new(), + } } pub(crate) fn revision(&self) -> Option { self.shared.state.borrow().revision } - pub(crate) fn stamp(&self) -> TestPublishedStampV1 { + pub(crate) fn stamp(&self) -> PointSinkStampV1 { + self.shared + .state + .borrow() + .stamp + .unwrap_or_else(|| unreachable!("stamp существует только после admission")) + } + + pub(crate) fn admitted_stamp(&self) -> Option { self.shared.state.borrow().stamp } + pub(crate) fn force_stamp_sequence(&self, sequence: u64) { + let mut state = self.shared.state.borrow_mut(); + let stamp = state + .stamp + .unwrap_or_else(|| unreachable!("stamp существует только после admission")); + state.stamp = Some(PointSinkStampV1::new(sequence, stamp.binding_epoch())); + } + + pub(crate) fn drift_host_binding(&self, axis: TestHostBindingAxisV1) { + self.replace_host_binding(self.shared.host_binding.get().drifted(axis)); + } + + pub(crate) fn restore_host_binding(&self) { + self.replace_host_binding(self.shared.host_binding.get().restored_facts()); + } + + fn replace_host_binding(&self, binding: TestHostBindingV1) { + self.shared.host_binding.set(binding); + let epoch = next_test_sink_epoch() + .unwrap_or_else(|| unreachable!("test sink epoch exhausted during host mutation")); + let mut state = self.shared.state.borrow_mut(); + if let Some(stamp) = state.stamp.as_mut() { + *stamp = stamp.rebound(epoch); + } + state + .foreign_layer + .get_or_insert(TestHostLayerV1::AmbientExposed); + } + + pub(crate) fn ambient_fallback_is_exposed(&self) -> bool { + matches!( + &self.shared.state.borrow().layer, + TestHostLayerV1::AmbientExposed + ) + } + + pub(crate) fn is_closed(&self) -> bool { + matches!(&self.shared.state.borrow().layer, TestHostLayerV1::Closed) + } + + pub(crate) fn foreign_scope_is_untouched(&self) -> bool { + matches!( + &self.shared.state.borrow().foreign_layer, + Some(TestHostLayerV1::AmbientExposed) + ) + } + + pub(crate) fn lease_was_dropped(&self) -> bool { + self.shared.state.borrow().lease_drop_sequence.is_some() + } + + pub(crate) fn reject_next_admission(&self) { + self.shared.reject_next_admission.set(true); + } + + pub(crate) fn reject_next_admission_after_install(&self) { + self.shared.reject_next_admission_after_install.set(true); + } + pub(crate) fn intent_counts(&self) -> TestIntentCountsV1 { self.shared.state.borrow().counts } @@ -153,10 +308,6 @@ impl InMemoryPointSinkProbeV1 { self.shared.rejected_prepare_saw_busy.get() } - pub(crate) fn misreport_next_confirm_proposed_stamp(&self) { - self.shared.misreport_next_confirm_proposed_stamp.set(true); - } - pub(crate) fn revoke_count(&self) -> usize { self.shared.state.borrow().revoke_count } @@ -169,10 +320,6 @@ impl InMemoryPointSinkProbeV1 { ) } - pub(crate) fn revoke_saw_exact_stamp(&self) -> bool { - self.shared.state.borrow().revoke_saw_exact_stamp - } - pub(crate) fn panic_on_next_retirement_drop(&self) { self.shared.panic_on_retirement_drop.set(true); } @@ -192,29 +339,26 @@ impl InMemoryPointSinkProbeV1 { pub(crate) fn in_memory_point_sink( owned_scope: &[u32], ) -> (InMemoryPointSinkLeaseV1, InMemoryPointSinkProbeV1) { - let epoch = NEXT_TEST_SINK_EPOCH - .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { - current.checked_add(1) - }) - .unwrap_or_else(|_| panic!("test sink epoch space exhausted")); let shared = Rc::new(TestSinkSharedV1 { state: RefCell::new(TestSinkStateV1 { - snapshot: Vec::new(), + layer: TestHostLayerV1::AmbientExposed, + foreign_layer: None, revision: None, - stamp: TestPublishedStampV1 { sequence: 0, epoch }, + stamp: None, reject_next_install: false, counts: TestIntentCountsV1::default(), revoke_count: 0, sequence: 0, revoke_sequence: None, lease_drop_sequence: None, - revoke_saw_exact_stamp: false, }), + host_binding: Cell::new(TestHostBindingV1::INITIAL), + reject_next_admission: Cell::new(false), + reject_next_admission_after_install: Cell::new(false), busy: Cell::new(false), reject_next_prepare: Cell::new(false), rejected_prepare_saw_busy: Cell::new(false), reject_next_install_after_swap: Cell::new(false), - misreport_next_confirm_proposed_stamp: Cell::new(false), panic_on_retirement_drop: Cell::new(false), retirement_drop_count: Cell::new(0), measure_terminal_tail: Cell::new(false), @@ -227,7 +371,6 @@ pub(crate) fn in_memory_point_sink( .map(TestSinkOutputIdV1::new) .collect(), shared: Rc::clone(&shared), - retired: None, }, InMemoryPointSinkProbeV1 { shared }, ) @@ -256,34 +399,127 @@ pub(crate) const fn authored_presentation( } impl sink_private::Sealed for InMemoryPointSinkLeaseV1 {} +impl sink_private::Sealed for ClosedInMemoryPointSinkLeaseV1 {} -impl LinearPointSinkLeaseV1 for InMemoryPointSinkLeaseV1 { +impl UnboundPointSinkLeaseV1 for InMemoryPointSinkLeaseV1 { type OutputId = TestSinkOutputIdV1; - type Stamp = TestPublishedStampV1; - type Error = InMemoryPointSinkErrorV1; - type Prepared<'lease> = InMemoryPreparedPointSinkWriteV1<'lease>; + type Closed = ClosedInMemoryPointSinkLeaseV1; + type AdmissionError = InMemoryPointSinkAdmissionErrorV1; fn owned_output_scope(&self) -> &[Self::OutputId] { &self.owned_scope } + fn try_admit_closed( + self, + scope: BoundPointSinkScopePermitV1<'_, Self::OutputId>, + ) -> Result, PointSinkAdmissionFailureV1> { + let mut output_scope = scope.output_scope(); + if output_scope.len() != self.owned_scope.len() + || output_scope.any(|output| !self.owned_scope.contains(&output)) + { + return Err(PointSinkAdmissionFailureV1::new( + InMemoryPointSinkAdmissionErrorV1::ScopeChanged, + self, + )); + } + if self.shared.reject_next_admission.replace(false) { + return Err(PointSinkAdmissionFailureV1::new( + InMemoryPointSinkAdmissionErrorV1::RejectedBeforeInstall, + self, + )); + } + { + let state = self.shared.state.borrow(); + if !matches!(&state.layer, TestHostLayerV1::AmbientExposed) + || state.stamp.is_some() + || state.revision.is_some() + { + drop(state); + return Err(PointSinkAdmissionFailureV1::new( + InMemoryPointSinkAdmissionErrorV1::HostStateChanged, + self, + )); + } + } + + // Test adapter моделирует один atomic host install: любой отказ после + // swap восстанавливает побитово прежний unbound state. + self.shared.state.borrow_mut().layer = TestHostLayerV1::Closed; + if self + .shared + .reject_next_admission_after_install + .replace(false) + { + self.shared.state.borrow_mut().layer = TestHostLayerV1::AmbientExposed; + return Err(PointSinkAdmissionFailureV1::new( + InMemoryPointSinkAdmissionErrorV1::RejectedAfterInstall, + self, + )); + } + let epoch = match next_test_sink_epoch() { + Some(epoch) => epoch, + None => { + self.shared.state.borrow_mut().layer = TestHostLayerV1::AmbientExposed; + return Err(PointSinkAdmissionFailureV1::new( + InMemoryPointSinkAdmissionErrorV1::EpochExhausted, + self, + )); + } + }; + let shared = Rc::clone(&self.shared); + let closed = ClosedInMemoryPointSinkLeaseV1 { + _owned_scope: self.owned_scope, + bound_host: self.shared.host_binding.get(), + binding_epoch: epoch, + shared: self.shared, + retired: None, + }; + let admission = ClosedPointSinkAdmissionV1::new(closed); + shared.state.borrow_mut().stamp = Some(admission.initial_stamp()); + Ok(admission) + } +} + +impl ClosedPointSinkLeaseV1 for ClosedInMemoryPointSinkLeaseV1 { + type OutputId = TestSinkOutputIdV1; + type Error = InMemoryPointSinkErrorV1; + type Prepared<'lease> = InMemoryPreparedPointSinkWriteV1<'lease>; + + fn binding_epoch(&self) -> PointSinkBindingEpochV1 { + self.binding_epoch + } + fn prepare<'lease>( &'lease mut self, - intent: PointSinkIntentV1<'_, Self::OutputId, Self::Stamp>, + intent: PointSinkIntentV1<'_, Self::OutputId>, ) -> Result, Self::Error> { + if self.shared.host_binding.get() != self.bound_host { + return Err(InMemoryPointSinkErrorV1::BindingDrift); + } // Retirement предыдущего install завершается до Busy и до любых // изменений нового физического снимка. drop(self.retired.take()); let (base_stamp, current_revision, busy) = { let state = self.shared.state.borrow(); - (state.stamp, state.revision, self.shared.busy.get()) + let stamp = state + .stamp + .unwrap_or_else(|| unreachable!("closed lease всегда имеет stamp")); + (stamp, state.revision, self.shared.busy.get()) }; if busy { return Err(InMemoryPointSinkErrorV1::Busy); } - let (staging, proposed, intent_kind) = match intent { - PointSinkIntentV1::SetAll { revision, patch } => { + let (staging, desired, intent_kind) = match intent { + PointSinkIntentV1::SetAll { + revision, + stamp, + patch, + } => { + if stamp.expected() != base_stamp { + return Err(InMemoryPointSinkErrorV1::StampMismatch); + } let mut snapshot = Vec::new(); snapshot .try_reserve_exact(patch.len()) @@ -294,38 +530,37 @@ impl LinearPointSinkLeaseV1 for InMemoryPointSinkLeaseV1 { paint: entry.paint(), })); ( - TestStagingV1::SetAll { revision, snapshot }, - base_stamp.next()?, + TestStagingV1::SetAll { + revision, + layer: TestHostLayerV1::Published(snapshot), + }, + stamp.desired(), TestIntentKindV1::SetAll, ) } - PointSinkIntentV1::RevokeAll { revision } => ( - TestStagingV1::RevokeAll { - revision, - retired: Vec::new(), - }, - base_stamp.next()?, - TestIntentKindV1::RevokeAll, - ), + PointSinkIntentV1::RevokeAll { revision, stamp } => { + if stamp.expected() != base_stamp { + return Err(InMemoryPointSinkErrorV1::StampMismatch); + } + ( + TestStagingV1::RevokeAll { + revision, + layer: TestHostLayerV1::Closed, + }, + stamp.desired(), + TestIntentKindV1::RevokeAll, + ) + } PointSinkIntentV1::ConfirmExact { revision, published_stamp, } => { - if published_stamp != &base_stamp || current_revision != Some(revision) { + if published_stamp != base_stamp || current_revision != Some(revision) { return Err(InMemoryPointSinkErrorV1::StampMismatch); } - let proposed = if self - .shared - .misreport_next_confirm_proposed_stamp - .replace(false) - { - published_stamp.next()? - } else { - *published_stamp - }; ( TestStagingV1::ConfirmExact { revision }, - proposed, + published_stamp, TestIntentKindV1::ConfirmExact, ) } @@ -333,7 +568,7 @@ impl LinearPointSinkLeaseV1 for InMemoryPointSinkLeaseV1 { { let mut state = self.shared.state.borrow_mut(); - if self.shared.busy.get() || state.stamp != base_stamp { + if self.shared.busy.get() || state.stamp != Some(base_stamp) { return Err(InMemoryPointSinkErrorV1::Busy); } self.shared.busy.set(true); @@ -357,7 +592,7 @@ impl LinearPointSinkLeaseV1 for InMemoryPointSinkLeaseV1 { Ok(InMemoryPreparedPointSinkWriteV1 { lease: self, base_stamp: Some(base_stamp), - proposed: Some(proposed), + desired: Some(desired), staging: Some(staging), retired_stamp: None, retirement_probe, @@ -365,10 +600,9 @@ impl LinearPointSinkLeaseV1 for InMemoryPointSinkLeaseV1 { }) } - fn revoke_all_before_release(&mut self, published_stamp: Option<&Self::Stamp>) { + fn close_before_release(&mut self) { let mut state = self.shared.state.borrow_mut(); - state.revoke_saw_exact_stamp = published_stamp.is_none_or(|stamp| stamp == &state.stamp); - state.snapshot.clear(); + state.layer = TestHostLayerV1::Closed; state.revision = None; state.revoke_count += 1; state.sequence = state.sequence.saturating_add(1); @@ -376,7 +610,7 @@ impl LinearPointSinkLeaseV1 for InMemoryPointSinkLeaseV1 { } } -impl Drop for InMemoryPointSinkLeaseV1 { +impl Drop for ClosedInMemoryPointSinkLeaseV1 { fn drop(&mut self) { let mut state = self.shared.state.borrow_mut(); state.sequence = state.sequence.saturating_add(1); @@ -394,11 +628,11 @@ enum TestIntentKindV1 { enum TestStagingV1 { SetAll { revision: u64, - snapshot: Vec, + layer: TestHostLayerV1, }, RevokeAll { revision: u64, - retired: Vec, + layer: TestHostLayerV1, }, ConfirmExact { revision: u64, @@ -424,27 +658,21 @@ impl Drop for TestSinkRetirementV1 { } pub(crate) struct InMemoryPreparedPointSinkWriteV1<'lease> { - lease: &'lease mut InMemoryPointSinkLeaseV1, - base_stamp: Option, - proposed: Option, + lease: &'lease mut ClosedInMemoryPointSinkLeaseV1, + base_stamp: Option, + desired: Option, staging: Option, - retired_stamp: Option, + retired_stamp: Option, retirement_probe: Option>, finished: bool, } impl PreparedPointSinkWriteV1 for InMemoryPreparedPointSinkWriteV1<'_> { - type Stamp = TestPublishedStampV1; type Error = InMemoryPointSinkErrorV1; - fn proposed_stamp(&self) -> Self::Stamp { - self.proposed - .unwrap_or_else(|| unreachable!("proposed stamp читается до install")) - } - fn try_install(&mut self) -> Result<(), Self::Error> { - let proposed = match self.proposed.take() { - Some(proposed) => proposed, + let desired = match self.desired.take() { + Some(desired) => desired, None => return Err(InMemoryPointSinkErrorV1::StampMismatch), }; if self.retired_stamp.is_some() { @@ -459,7 +687,7 @@ impl PreparedPointSinkWriteV1 for InMemoryPreparedPointSinkWriteV1<'_> { state.reject_next_install = false; return Err(InMemoryPointSinkErrorV1::RejectedInstall); } - if self.base_stamp.as_ref() != Some(&state.stamp) { + if self.base_stamp.as_ref() != state.stamp.as_ref() { return Err(InMemoryPointSinkErrorV1::StampMismatch); } @@ -476,15 +704,17 @@ impl PreparedPointSinkWriteV1 for InMemoryPreparedPointSinkWriteV1<'_> { } }; match staging { - TestStagingV1::SetAll { snapshot, .. } => { - mem::swap(&mut state.snapshot, snapshot); - } - TestStagingV1::RevokeAll { retired, .. } => { - mem::swap(&mut state.snapshot, retired); + TestStagingV1::SetAll { layer, .. } | TestStagingV1::RevokeAll { layer, .. } => { + mem::swap(&mut state.layer, layer); } TestStagingV1::ConfirmExact { .. } => {} } - self.retired_stamp = Some(mem::replace(&mut state.stamp, proposed)); + self.retired_stamp = Some( + state + .stamp + .replace(desired) + .unwrap_or_else(|| unreachable!("closed state всегда имеет stamp")), + ); state.revision = Some(revision); if self .lease @@ -493,11 +723,8 @@ impl PreparedPointSinkWriteV1 for InMemoryPreparedPointSinkWriteV1<'_> { .replace(false) { match staging { - TestStagingV1::SetAll { snapshot, .. } => { - mem::swap(&mut state.snapshot, snapshot); - } - TestStagingV1::RevokeAll { retired, .. } => { - mem::swap(&mut state.snapshot, retired); + TestStagingV1::SetAll { layer, .. } | TestStagingV1::RevokeAll { layer, .. } => { + mem::swap(&mut state.layer, layer); } TestStagingV1::ConfirmExact { .. } => {} } @@ -505,7 +732,12 @@ impl PreparedPointSinkWriteV1 for InMemoryPreparedPointSinkWriteV1<'_> { .retired_stamp .take() .unwrap_or_else(|| unreachable!("install уже перенёс прежний stamp")); - self.proposed = Some(mem::replace(&mut state.stamp, retired_stamp)); + self.desired = Some( + state + .stamp + .replace(retired_stamp) + .unwrap_or_else(|| unreachable!("install уже записал desired stamp")), + ); state.revision = prior_revision; return Err(InMemoryPointSinkErrorV1::RejectedInstallAfterSwap); } diff --git a/crates/labcolors-core/src/program/attachment/tests.rs b/crates/labcolors-core/src/program/attachment/tests.rs index 9cfc54de..bddee5e9 100644 --- a/crates/labcolors-core/src/program/attachment/tests.rs +++ b/crates/labcolors-core/src/program/attachment/tests.rs @@ -1,6 +1,6 @@ use super::support::{ - InMemoryPointSinkErrorV1, TestPublishedStampV1, authored_emission, authored_presentation, - in_memory_point_sink, + InMemoryPointSinkAdmissionErrorV1, InMemoryPointSinkErrorV1, TestHostBindingAxisV1, + authored_emission, authored_presentation, in_memory_point_sink, }; use super::*; use crate::Srgb8; @@ -8,6 +8,7 @@ use crate::program::{ AppearanceContextV1, ConstraintIdV1, DraftV1, PaintIdV1, ScenarioV1, SourceIdV1, StateKindV1, SurfaceIdV1, SurfaceInputPortIdV1, SurroundV1, TargetIdV1, }; +use proptest::prelude::*; const SOURCE: SourceIdV1 = SourceIdV1::new(1); const TARGET: TargetIdV1 = TargetIdV1::new(2); @@ -24,48 +25,659 @@ const OUTPUT_B: OutputSlotIdV1 = OutputSlotIdV1::new(13); #[test] fn terminal_stamp_is_a_fixed_two_word_copy_value() { const fn assert_copy() {} - fn assert_prepared_stamp_is_copy() { - assert_copy::(); - } - fn assert_lease_stamp_is_copy() { - assert_copy::(); - } - - assert_copy::(); - assert_prepared_stamp_is_copy::>(); - assert_lease_stamp_is_copy::(); + assert_copy::(); assert_eq!( - core::mem::size_of::(), + core::mem::size_of::(), core::mem::size_of::<[u64; 2]>() ); } #[test] fn a_stale_copy_stamp_cannot_cross_a_sequential_sink_epoch() { - let (mut first, first_probe) = in_memory_point_sink(&[900]); - let mut prepared = first - .prepare(PointSinkIntentV1::RevokeAll { revision: 1 }) - .unwrap(); - prepared.try_install().unwrap(); - prepared.finish_after_session(); + let owner = owner( + Srgb8::new([12, 34, 56]), + Srgb8::new([12, 34, 56]), + false, + &[OUTPUT_A], + ); + let emissions = [authored_emission(OUTPUT_A.value(), 900)]; + let presentations = [authored_presentation( + OUTPUT_A.value(), + ROOT.value(), + INNER.value(), + )]; + let unknown = UpdateV1::Unknown { + revision: 1, + reason_id: 77, + }; + + let (first, first_probe) = in_memory_point_sink(&[900]); + let mut first = owner.attach(1, &emissions, &presentations, first).unwrap(); + first.update(unknown).unwrap(); let stale = first_probe.stamp(); - drop(first_probe); drop(first); - let (mut second, second_probe) = in_memory_point_sink(&[900]); - let mut prepared = second - .prepare(PointSinkIntentV1::RevokeAll { revision: 1 }) - .unwrap(); - prepared.try_install().unwrap(); - prepared.finish_after_session(); + let (second, second_probe) = in_memory_point_sink(&[900]); + let mut second = owner.attach(1, &emissions, &presentations, second).unwrap(); + second.update(unknown).unwrap(); assert_ne!(second_probe.stamp(), stale); assert!(matches!( - second.prepare(PointSinkIntentV1::ConfirmExact { + second.sink.prepare(PointSinkIntentV1::ConfirmExact { + revision: 1, + published_stamp: stale, + }), + Err(InMemoryPointSinkErrorV1::StampMismatch) + )); +} + +#[test] +fn cold_attach_failure_preserves_the_same_unbound_lease_for_retry() { + let owner = owner( + Srgb8::new([12, 34, 56]), + Srgb8::new([12, 34, 56]), + false, + &[OUTPUT_A], + ); + let (sink, probe) = in_memory_point_sink(&[900]); + let emissions = [authored_emission(OUTPUT_A.value(), 900)]; + let presentations = [authored_presentation( + OUTPUT_A.value(), + ROOT.value(), + INNER.value(), + )]; + + let failure = match owner.attach(1, &emissions, &[], sink) { + Ok(_) => panic!("incomplete presentation binding must fail"), + Err(failure) => failure, + }; + assert!(matches!( + failure.cause(), + &AttachmentCreateCauseV1::Contract(AttachmentCreateErrorV1::EmptyPresentations) + )); + assert!(probe.ambient_fallback_is_exposed()); + assert!(!probe.lease_was_dropped()); + + let sink = failure.into_sink(); + let attachment = owner.attach(1, &emissions, &presentations, sink).unwrap(); + assert!(probe.is_closed()); + assert!(!probe.ambient_fallback_is_exposed()); + + attachment.dispose(); + assert!(probe.is_closed()); + assert!(!probe.ambient_fallback_is_exposed()); +} + +#[test] +fn create_failure_debug_reports_the_typed_cause_without_sink_internals() { + let owner = owner( + Srgb8::new([12, 34, 56]), + Srgb8::new([12, 34, 56]), + false, + &[OUTPUT_A], + ); + let emissions = [authored_emission(OUTPUT_A.value(), 900)]; + let presentations = [authored_presentation( + OUTPUT_A.value(), + ROOT.value(), + INNER.value(), + )]; + + let (sink, _) = in_memory_point_sink(&[900]); + let contract = match owner.attach(1, &emissions, &[], sink) { + Ok(_) => panic!("contract failure was expected"), + Err(failure) => failure, + }; + let contract_debug = format!("{contract:?}"); + assert!(contract_debug.contains("AttachmentCreateFailureV1")); + assert!(contract_debug.contains("Contract(EmptyPresentations)")); + assert!(!contract_debug.contains("owned_scope")); + assert!(!contract_debug.contains("TestSinkSharedV1")); + + let (sink, probe) = in_memory_point_sink(&[900]); + probe.reject_next_admission(); + let admission = match owner.attach(2, &emissions, &presentations, sink) { + Ok(_) => panic!("admission failure was expected"), + Err(failure) => failure, + }; + let admission_debug = format!("{admission:?}"); + assert!(admission_debug.contains("AttachmentCreateFailureV1")); + assert!(admission_debug.contains("SinkAdmission(RejectedBeforeInstall)")); + assert!(!admission_debug.contains("owned_scope")); + assert!(!admission_debug.contains("TestSinkSharedV1")); +} + +#[test] +fn failed_closed_admission_is_atomic_and_mints_epoch_only_after_install() { + let owner = owner( + Srgb8::new([12, 34, 56]), + Srgb8::new([12, 34, 56]), + false, + &[OUTPUT_A], + ); + let (sink, probe) = in_memory_point_sink(&[900]); + let emissions = [authored_emission(OUTPUT_A.value(), 900)]; + let presentations = [authored_presentation( + OUTPUT_A.value(), + ROOT.value(), + INNER.value(), + )]; + + probe.reject_next_admission(); + let failure = match owner.attach(2, &emissions, &presentations, sink) { + Ok(_) => panic!("pre-install admission fault must return the lease"), + Err(failure) => failure, + }; + assert!(matches!( + failure.cause(), + &AttachmentCreateCauseV1::SinkAdmission( + InMemoryPointSinkAdmissionErrorV1::RejectedBeforeInstall + ) + )); + assert!(probe.ambient_fallback_is_exposed()); + assert_eq!(probe.admitted_stamp(), None); + + let sink = failure.into_sink(); + probe.reject_next_admission_after_install(); + let failure = match owner.attach(2, &emissions, &presentations, sink) { + Ok(_) => panic!("post-install fault must roll the tombstone back"), + Err(failure) => failure, + }; + assert!(matches!( + failure.cause(), + &AttachmentCreateCauseV1::SinkAdmission( + InMemoryPointSinkAdmissionErrorV1::RejectedAfterInstall + ) + )); + assert!(probe.ambient_fallback_is_exposed()); + assert_eq!(probe.admitted_stamp(), None); + + let attachment = owner + .attach(2, &emissions, &presentations, failure.into_sink()) + .unwrap(); + let admitted = probe.stamp(); + assert_eq!(admitted.sequence(), 0); + assert!(probe.is_closed()); + drop(attachment); + assert!(probe.is_closed()); +} + +#[test] +fn initial_unknown_and_violation_keep_the_host_scope_closed() { + let emissions = [authored_emission(OUTPUT_A.value(), 900)]; + let presentations = [authored_presentation( + OUTPUT_A.value(), + ROOT.value(), + INNER.value(), + )]; + let unknown = UpdateV1::Unknown { + revision: 1, + reason_id: 77, + }; + + let pass_owner = owner( + Srgb8::new([0, 0, 0]), + Srgb8::new([0, 0, 0]), + false, + &[OUTPUT_A], + ); + let (sink, unknown_probe) = in_memory_point_sink(&[900]); + let mut attachment = pass_owner + .attach(3, &emissions, &presentations, sink) + .unwrap(); + assert!(unknown_probe.is_closed()); + attachment.update(unknown).unwrap(); + assert!(unknown_probe.is_closed()); + assert!(!unknown_probe.ambient_fallback_is_exposed()); + + let conflict_owner = owner( + Srgb8::new([255, 0, 0]), + Srgb8::new([0, 0, 0]), + false, + &[OUTPUT_A], + ); + let (sink, violation_probe) = in_memory_point_sink(&[900]); + let mut conflict = conflict_owner + .attach(4, &emissions, &presentations, sink) + .unwrap(); + let values = [Srgb8::new([0, 0, 0])]; + let scenarios = [ScenarioV1::new(1, &values)]; + conflict.update(observed(1, &scenarios)).unwrap(); + assert!(violation_probe.is_closed()); + assert!(!violation_probe.ambient_fallback_is_exposed()); +} + +#[test] +fn every_host_binding_axis_is_checked_before_sink_mutation() { + let owner = owner( + Srgb8::new([12, 34, 56]), + Srgb8::new([12, 34, 56]), + false, + &[OUTPUT_A], + ); + let emissions = [authored_emission(OUTPUT_A.value(), 900)]; + let presentations = [authored_presentation( + OUTPUT_A.value(), + ROOT.value(), + INNER.value(), + )]; + for (index, axis) in [ + TestHostBindingAxisV1::Realm, + TestHostBindingAxisV1::Root, + TestHostBindingAxisV1::Scope, + TestHostBindingAxisV1::Codec, + TestHostBindingAxisV1::Capabilities, + TestHostBindingAxisV1::Tombstone, + ] + .into_iter() + .enumerate() + { + let (sink, probe) = in_memory_point_sink(&[900]); + let mut attachment = owner + .attach(5 + index as u32, &emissions, &presentations, sink) + .unwrap(); + let initial_stamp = probe.stamp(); + probe.drift_host_binding(axis); + let drifted_stamp = probe.stamp(); + assert_ne!(drifted_stamp, initial_stamp, "axis {axis:?}"); + assert!(matches!( + attachment.update(UpdateV1::Unknown { + revision: 1, + reason_id: 77, + }), + Err(AttachmentUpdateErrorV1::SinkPrepare( + InMemoryPointSinkErrorV1::BindingDrift + )) + )); + assert_eq!(probe.stamp(), drifted_stamp, "axis {axis:?}"); + assert!(probe.is_closed(), "axis {axis:?}"); + assert_eq!(probe.intent_counts(), Default::default(), "axis {axis:?}"); + assert!(matches!( + attachment.session.evidence().observation_head(), + super::super::ObservationHeadV1::Empty + )); + // Generation монотонна: восстановление сырых host-фактов не может + // воскресить полномочие уже привязанного closed lease. + probe.restore_host_binding(); + let restored_facts_stamp = probe.stamp(); + assert_ne!(restored_facts_stamp, drifted_stamp, "axis {axis:?}"); + assert!(matches!( + attachment.update(UpdateV1::Unknown { + revision: 1, + reason_id: 77, + }), + Err(AttachmentUpdateErrorV1::SinkPrepare( + InMemoryPointSinkErrorV1::BindingDrift + )) + )); + assert_eq!(probe.stamp(), restored_facts_stamp, "axis {axis:?}"); + assert_eq!(probe.intent_counts(), Default::default(), "axis {axis:?}"); + drop(attachment); + assert!(probe.is_closed(), "axis {axis:?}"); + assert!(probe.foreign_scope_is_untouched(), "axis {axis:?}"); + } +} + +#[test] +fn every_sink_intent_cas_checks_the_same_current_binding_stamp() { + let owner = owner( + Srgb8::new([12, 34, 56]), + Srgb8::new([12, 34, 56]), + false, + &[OUTPUT_A], + ); + let emissions = [authored_emission(OUTPUT_A.value(), 900)]; + let presentations = [authored_presentation( + OUTPUT_A.value(), + ROOT.value(), + INNER.value(), + )]; + let (sink, probe) = in_memory_point_sink(&[900]); + let mut attachment = owner.attach(6, &emissions, &presentations, sink).unwrap(); + let values = [Srgb8::new([0, 0, 0])]; + let scenarios = [ScenarioV1::new(1, &values)]; + attachment.update(observed(1, &scenarios)).unwrap(); + let baseline_stamp = probe.stamp(); + let baseline_revision = probe.revision(); + let baseline_snapshot = probe.snapshot(); + let baseline_counts = probe.intent_counts(); + let (foreign, foreign_probe) = in_memory_point_sink(&[900]); + let foreign = owner + .attach(7, &emissions, &presentations, foreign) + .unwrap(); + let foreign_stamp = foreign_probe.stamp(); + let foreign_transition = PointSinkMutationStampV1::new(foreign_stamp).unwrap(); + assert_ne!(foreign_stamp, probe.stamp()); + + assert!(matches!( + attachment.sink.prepare(PointSinkIntentV1::SetAll { + revision: 1, + stamp: foreign_transition, + patch: &[], + }), + Err(InMemoryPointSinkErrorV1::StampMismatch) + )); + assert!(matches!( + attachment.sink.prepare(PointSinkIntentV1::RevokeAll { revision: 1, - published_stamp: &stale, + stamp: foreign_transition, }), Err(InMemoryPointSinkErrorV1::StampMismatch) )); + assert!(matches!( + attachment.sink.prepare(PointSinkIntentV1::ConfirmExact { + revision: 1, + published_stamp: foreign_stamp, + }), + Err(InMemoryPointSinkErrorV1::StampMismatch) + )); + assert_eq!(probe.stamp(), baseline_stamp); + assert_eq!(probe.revision(), baseline_revision); + assert_eq!(probe.snapshot(), baseline_snapshot); + assert_eq!(probe.intent_counts(), baseline_counts); + assert!(!probe.is_busy()); + + drop(foreign); + drop(attachment); + assert!(probe.is_closed()); + assert!(foreign_probe.is_closed()); +} + +#[test] +fn core_mints_the_exact_successor_for_every_mutating_intent() { + let owner = owner( + Srgb8::new([12, 34, 56]), + Srgb8::new([12, 34, 56]), + false, + &[OUTPUT_A], + ); + let emissions = [authored_emission(OUTPUT_A.value(), 900)]; + let presentations = [authored_presentation( + OUTPUT_A.value(), + ROOT.value(), + INNER.value(), + )]; + let (sink, probe) = in_memory_point_sink(&[900]); + let mut attachment = owner.attach(8, &emissions, &presentations, sink).unwrap(); + let values = [Srgb8::new([0, 0, 0])]; + let scenarios = [ScenarioV1::new(1, &values)]; + + let admission_stamp = probe.stamp(); + attachment.update(observed(1, &scenarios)).unwrap(); + let published_stamp = probe.stamp(); + assert_eq!( + published_stamp.sequence(), + admission_stamp.sequence().checked_add(1).unwrap() + ); + assert_eq!( + published_stamp.binding_epoch(), + admission_stamp.binding_epoch() + ); + + attachment + .update(UpdateV1::Unknown { + revision: 2, + reason_id: 77, + }) + .unwrap(); + let revoked_stamp = probe.stamp(); + assert_eq!( + revoked_stamp.sequence(), + published_stamp.sequence().checked_add(1).unwrap() + ); + assert_eq!( + revoked_stamp.binding_epoch(), + admission_stamp.binding_epoch() + ); + + attachment + .update(UpdateV1::Unknown { + revision: 2, + reason_id: 77, + }) + .unwrap(); + assert_eq!(probe.stamp(), revoked_stamp); +} + +#[test] +fn exhausted_stamp_fails_before_sink_prepare_or_session_commit() { + let owner = owner( + Srgb8::new([12, 34, 56]), + Srgb8::new([12, 34, 56]), + false, + &[OUTPUT_A], + ); + let emissions = [authored_emission(OUTPUT_A.value(), 900)]; + let presentations = [authored_presentation( + OUTPUT_A.value(), + ROOT.value(), + INNER.value(), + )]; + let (sink, probe) = in_memory_point_sink(&[900]); + let mut attachment = owner.attach(8, &emissions, &presentations, sink).unwrap(); + let exhausted = PointSinkStampV1::new(u64::MAX, probe.stamp().binding_epoch()); + probe.force_stamp_sequence(u64::MAX); + attachment.expected_sink_stamp = exhausted; + let values = [Srgb8::new([0, 0, 0])]; + let scenarios = [ScenarioV1::new(1, &values)]; + + assert!(matches!( + attachment.update(observed(1, &scenarios)), + Err(AttachmentUpdateErrorV1::InternalInvariant( + AttachmentInvariantV1::SinkStampExhausted + )) + )); + assert_eq!(probe.stamp(), exhausted); + assert_eq!(probe.intent_counts(), Default::default()); + assert!(probe.is_closed()); + assert!(matches!( + attachment.session.evidence().observation_head(), + super::super::ObservationHeadV1::Empty + )); +} + +#[test] +fn closed_revoke_is_confirmable_from_one_expected_stamp_source_of_truth() { + let owner = owner( + Srgb8::new([12, 34, 56]), + Srgb8::new([12, 34, 56]), + false, + &[OUTPUT_A], + ); + let (sink, probe) = in_memory_point_sink(&[900]); + let emissions = [authored_emission(OUTPUT_A.value(), 900)]; + let presentations = [authored_presentation( + OUTPUT_A.value(), + ROOT.value(), + INNER.value(), + )]; + let mut attachment = owner.attach(8, &emissions, &presentations, sink).unwrap(); + let admission_stamp = attachment.expected_sink_stamp; + assert_eq!(attachment.committed_revision, None); + + let unknown = UpdateV1::Unknown { + revision: 1, + reason_id: 77, + }; + attachment.update(unknown).unwrap(); + let revoked_stamp = attachment.expected_sink_stamp; + assert_ne!(revoked_stamp, admission_stamp); + assert_eq!(attachment.committed_revision, Some(1)); + assert!(probe.is_closed()); + assert_eq!(probe.intent_counts().revoke_all, 1); + + attachment.update(unknown).unwrap(); + assert_eq!(attachment.expected_sink_stamp, revoked_stamp); + assert_eq!(attachment.committed_revision, Some(1)); + assert_eq!(probe.intent_counts().revoke_all, 1); + assert_eq!(probe.intent_counts().confirm_exact, 1); + + let values = [Srgb8::new([0, 0, 0])]; + let scenarios = [ScenarioV1::new(1, &values)]; + let committed = attachment.update(observed(2, &scenarios)).unwrap(); + let output = committed.render_outputs().next().unwrap(); + assert_eq!(output.published_stamp().revision(), 2); + assert_eq!(output.published_stamp().sink_stamp(), probe.stamp()); + assert_eq!(attachment.committed_revision, Some(2)); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ModelSnapshotV1 { + Closed, + Published, +} + +proptest! { + #[test] + fn admitted_state_machine_never_exposes_ambient_fallback( + actions in prop::collection::vec(0_u8..11, 0..64), + ) { + let owner = owner( + Srgb8::new([12, 34, 56]), + Srgb8::new([12, 34, 56]), + false, + &[OUTPUT_A], + ); + let (sink, probe) = in_memory_point_sink(&[900]); + let emissions = [authored_emission(OUTPUT_A.value(), 900)]; + let presentations = [authored_presentation( + OUTPUT_A.value(), + ROOT.value(), + INNER.value(), + )]; + let mut attachment = owner + .attach(9, &emissions, &presentations, sink) + .unwrap(); + let values = [Srgb8::new([0, 0, 0])]; + let scenarios = [ScenarioV1::new(1, &values)]; + let mut revision = 0_u64; + let mut model = ModelSnapshotV1::Closed; + let mut binding_valid = true; + + for action in actions { + if !binding_valid { + let stamp = probe.stamp(); + let counts = probe.intent_counts(); + let result = attachment.update(UpdateV1::Unknown { + revision: revision + 1, + reason_id: 77, + }); + prop_assert!(matches!( + result, + Err(AttachmentUpdateErrorV1::SinkPrepare( + InMemoryPointSinkErrorV1::BindingDrift + )) + )); + prop_assert_eq!(probe.stamp(), stamp); + prop_assert_eq!(probe.intent_counts(), counts); + prop_assert!(!probe.ambient_fallback_is_exposed()); + continue; + } + match action { + 0 => { + revision += 1; + attachment.update(observed(revision, &scenarios)).unwrap(); + model = ModelSnapshotV1::Published; + } + 1 => { + revision += 1; + attachment.update(UpdateV1::Unknown { + revision, + reason_id: 77, + }).unwrap(); + model = ModelSnapshotV1::Closed; + } + 2 => { + probe.reject_next_prepare(); + let result = attachment.update(observed(revision + 1, &scenarios)); + prop_assert!(matches!( + result, + Err(AttachmentUpdateErrorV1::SinkPrepare( + InMemoryPointSinkErrorV1::RejectedPrepare + )) + )); + } + 3 => { + probe.reject_next_install(); + let result = attachment.update(UpdateV1::Unknown { + revision: revision + 1, + reason_id: 77, + }); + prop_assert!(matches!( + result, + Err(AttachmentUpdateErrorV1::SinkInstall( + InMemoryPointSinkErrorV1::RejectedInstall + )) + )); + } + 4 => { + if revision == 0 { + revision = 1; + attachment.update(UpdateV1::Unknown { + revision, + reason_id: 77, + }).unwrap(); + model = ModelSnapshotV1::Closed; + } + match model { + ModelSnapshotV1::Closed => { + attachment.update(UpdateV1::Unknown { + revision, + reason_id: 77, + }).unwrap(); + } + ModelSnapshotV1::Published => { + attachment.update(observed(revision, &scenarios)).unwrap(); + } + } + } + 5..=10 => { + let axis = match action { + 5 => TestHostBindingAxisV1::Realm, + 6 => TestHostBindingAxisV1::Root, + 7 => TestHostBindingAxisV1::Scope, + 8 => TestHostBindingAxisV1::Codec, + 9 => TestHostBindingAxisV1::Capabilities, + _ => TestHostBindingAxisV1::Tombstone, + }; + probe.drift_host_binding(axis); + let result = attachment.update(UpdateV1::Unknown { + revision: revision + 1, + reason_id: 77, + }); + prop_assert!(matches!( + result, + Err(AttachmentUpdateErrorV1::SinkPrepare( + InMemoryPointSinkErrorV1::BindingDrift + )) + )); + binding_valid = false; + } + _ => unreachable!("стратегия генерирует только действия 0..=10"), + } + + prop_assert!(!probe.ambient_fallback_is_exposed()); + if binding_valid { + prop_assert_eq!(probe.stamp(), attachment.expected_sink_stamp); + } else { + prop_assert_ne!(probe.stamp(), attachment.expected_sink_stamp); + } + prop_assert_eq!(attachment.committed_revision, (revision != 0).then_some(revision)); + match model { + ModelSnapshotV1::Closed => { + prop_assert!(probe.is_closed()); + prop_assert!(probe.snapshot().is_empty()); + } + ModelSnapshotV1::Published => prop_assert_eq!(probe.snapshot().len(), 1), + } + } + + drop(attachment); + prop_assert!(probe.is_closed()); + prop_assert!(!probe.ambient_fallback_is_exposed()); + if !binding_valid { + prop_assert!(probe.foreign_scope_is_untouched()); + } + } } #[test] @@ -170,8 +782,26 @@ fn observed<'a>(revision: u64, scenarios: &'a [ScenarioV1<'a>]) -> UpdateV1<'a> } } +fn contract_error( + result: Result, AttachmentCreateFailureV1>, +) -> AttachmentCreateErrorV1 +where + L: UnboundPointSinkLeaseV1, +{ + let failure = match result { + Ok(_) => panic!("cold contract error was expected"), + Err(failure) => failure, + }; + match failure.into_parts().0 { + AttachmentCreateCauseV1::Contract(cause) => cause, + AttachmentCreateCauseV1::SinkAdmission(_) => { + panic!("contract test reached host admission") + } + } +} + #[test] -fn attach_rejects_missing_extra_duplicate_and_reordered_sink_scope() { +fn attach_rejects_missing_extra_duplicate_and_accepts_reordered_sink_scope() { let owner = owner( Srgb8::new([0, 0, 0]), Srgb8::new([0, 0, 0]), @@ -189,36 +819,36 @@ fn attach_rejects_missing_extra_duplicate_and_reordered_sink_scope() { let (missing, missing_probe) = in_memory_point_sink(&[900]); assert!(matches!( - owner.attach(1, &emissions, &presentations, missing), - Err(AttachmentCreateErrorV1::SinkScopeCount { + contract_error(owner.attach(1, &emissions, &presentations, missing)), + AttachmentCreateErrorV1::SinkScopeCount { expected: 2, actual: 1 - }) + } )); - assert_eq!(missing_probe.revoke_count(), 1); - assert!(missing_probe.revoke_saw_exact_stamp()); - assert!(missing_probe.revoked_before_lease_drop()); + assert_eq!(missing_probe.revoke_count(), 0); + assert!(missing_probe.ambient_fallback_is_exposed()); let (extra, _) = in_memory_point_sink(&[900, 901, 902]); assert!(matches!( - owner.attach(1, &emissions, &presentations, extra), - Err(AttachmentCreateErrorV1::SinkScopeCount { + contract_error(owner.attach(1, &emissions, &presentations, extra)), + AttachmentCreateErrorV1::SinkScopeCount { expected: 2, actual: 3 - }) + } )); let (duplicate, _) = in_memory_point_sink(&[900, 900]); assert!(matches!( - owner.attach(1, &emissions, &presentations, duplicate), - Err(AttachmentCreateErrorV1::DuplicateSinkScopeOutput { .. }) + contract_error(owner.attach(1, &emissions, &presentations, duplicate)), + AttachmentCreateErrorV1::DuplicateSinkScopeOutput { .. } )); - let (reordered, _) = in_memory_point_sink(&[901, 900]); - assert!(matches!( - owner.attach(1, &emissions, &presentations, reordered), - Err(AttachmentCreateErrorV1::SinkScopeMismatch { ordinal: 0, .. }) - )); + let (reordered, reordered_probe) = in_memory_point_sink(&[901, 900]); + let reordered = owner + .attach(1, &emissions, &presentations, reordered) + .unwrap(); + assert!(reordered_probe.is_closed()); + reordered.dispose(); let duplicate_emission = [ authored_emission(OUTPUT_A.value(), 900), @@ -226,8 +856,8 @@ fn attach_rejects_missing_extra_duplicate_and_reordered_sink_scope() { ]; let (sink, _) = in_memory_point_sink(&[900, 901]); assert!(matches!( - owner.attach(1, &duplicate_emission, &presentations, sink), - Err(AttachmentCreateErrorV1::SinkOutputAliased { .. }) + contract_error(owner.attach(1, &duplicate_emission, &presentations, sink)), + AttachmentCreateErrorV1::SinkOutputAliased { .. } )); } @@ -250,14 +880,14 @@ fn attach_requires_exact_bijection_over_compiled_presentations() { ]; let (sink, probe) = in_memory_point_sink(&[900, 901]); assert!(matches!( - owner.attach(2, &emissions, &omitted_terminal, sink), - Err(AttachmentCreateErrorV1::PresentationCount { + contract_error(owner.attach(2, &emissions, &omitted_terminal, sink)), + AttachmentCreateErrorV1::PresentationCount { expected: 3, actual: 2 - }) + } )); - assert_eq!(probe.revoke_count(), 1); - assert!(probe.revoked_before_lease_drop()); + assert_eq!(probe.revoke_count(), 0); + assert!(probe.ambient_fallback_is_exposed()); } #[test] @@ -278,13 +908,13 @@ fn alias_outputs_cannot_claim_the_same_compiled_presentation() { ]; let (sink, _) = in_memory_point_sink(&[900, 901]); assert!(matches!( - owner.attach(3, &emissions, &duplicate_actual_target, sink), - Err(AttachmentCreateErrorV1::DuplicatePresentation { + contract_error(owner.attach(3, &emissions, &duplicate_actual_target, sink)), + AttachmentCreateErrorV1::DuplicatePresentation { root: ROOT, occurrence: INNER, first_output: OUTPUT_A, second_output: OUTPUT_B, - }) + } )); } @@ -307,8 +937,8 @@ fn every_emission_requires_at_least_one_distinct_compiled_presentation() { let (sink, _) = in_memory_point_sink(&[900, 901]); assert!(matches!( - owner.attach(4, &emissions, &only_output_a, sink), - Err(AttachmentCreateErrorV1::MissingOutputPresentation { output: OUTPUT_B }) + contract_error(owner.attach(4, &emissions, &only_output_a, sink)), + AttachmentCreateErrorV1::MissingOutputPresentation { output: OUTPUT_B } )); } @@ -331,8 +961,8 @@ fn duplicate_emission_output_has_its_exact_typed_error() { let (sink, _) = in_memory_point_sink(&[900, 901]); assert!(matches!( - owner.attach(5, &duplicate_output, &presentations, sink), - Err(AttachmentCreateErrorV1::DuplicateEmissionOutput { output: OUTPUT_A }) + contract_error(owner.attach(5, &duplicate_output, &presentations, sink)), + AttachmentCreateErrorV1::DuplicateEmissionOutput { output: OUTPUT_A } )); } @@ -390,50 +1020,6 @@ fn verified_snapshot_mints_attached_render_output_and_exact_confirm_only_for_ide assert_eq!(probe.revision(), Some(2)); } -#[test] -fn confirm_exact_rejects_a_sink_that_misreports_its_proposed_stamp() { - let owner = owner( - Srgb8::new([12, 34, 56]), - Srgb8::new([12, 34, 56]), - false, - &[OUTPUT_A], - ); - let (sink, probe) = in_memory_point_sink(&[900]); - let emissions = [authored_emission(OUTPUT_A.value(), 900)]; - let presentations = [authored_presentation( - OUTPUT_A.value(), - ROOT.value(), - INNER.value(), - )]; - let mut attachment = owner.attach(6, &emissions, &presentations, sink).unwrap(); - let values = [Srgb8::new([0, 0, 0])]; - let scenarios = [ScenarioV1::new(44, &values)]; - attachment.update(observed(1, &scenarios)).unwrap(); - let prior_snapshot = probe.snapshot(); - - probe.misreport_next_confirm_proposed_stamp(); - assert!(matches!( - attachment.update(observed(1, &scenarios)), - Err(AttachmentUpdateErrorV1::InternalInvariant( - AttachmentInvariantV1::ConfirmStampMismatch - )) - )); - assert_eq!(probe.snapshot(), prior_snapshot); - assert_eq!(probe.revision(), Some(1)); - assert!(!probe.is_busy()); - match attachment.session.evidence().observation_head() { - super::super::ObservationHeadV1::Observed { stream, revision } => { - assert_eq!(stream.value(), 6); - assert_eq!(revision, 1); - } - _ => panic!("rejected confirm must preserve the prior observed head"), - } - - let committed = attachment.update(observed(1, &scenarios)).unwrap(); - assert_eq!(committed.render_outputs().len(), 1); - assert_eq!(probe.intent_counts().confirm_exact, 2); -} - #[test] fn one_emission_fans_out_to_every_distinct_attached_presentation() { let owner = owner( @@ -497,6 +1083,8 @@ fn unknown_and_known_violation_revoke_the_complete_snapshot() { assert_eq!(committed.evidence().kind(), StateKindV1::Stale); assert_eq!(committed.render_outputs().len(), 0); assert!(probe.snapshot().is_empty()); + assert!(probe.is_closed()); + assert!(!probe.ambient_fallback_is_exposed()); assert_eq!(probe.intent_counts().revoke_all, 1); attachment.update(unknown).unwrap(); assert_eq!(probe.intent_counts().confirm_exact, 1); @@ -515,6 +1103,8 @@ fn unknown_and_known_violation_revoke_the_complete_snapshot() { assert_eq!(committed.evidence().kind(), StateKindV1::Failed); assert_eq!(committed.render_outputs().len(), 0); assert!(conflict_probe.snapshot().is_empty()); + assert!(conflict_probe.is_closed()); + assert!(!conflict_probe.ambient_fallback_is_exposed()); assert_eq!(conflict_probe.intent_counts().revoke_all, 1); } @@ -701,6 +1291,48 @@ fn source_guards_keep_the_post_install_tail_destructor_free() { let attachment_source = include_str!("../attachment.rs"); let support_source = include_str!("support.rs"); + let cold_prepare = attachment_source + .find("PreparedAttachmentColdV1::try_new(") + .expect("all fallible Core preparation must precede host admission"); + let admission = attachment_source + .find("sink.try_admit_closed(permit)") + .expect("Attachment must cross one closed admission seam"); + assert!(cold_prepare < admission); + let post_admission_function = attachment_source + .split("fn from_closed_admission(") + .nth(1) + .expect("post-admission построение должно иметь отдельную типизированную функцию"); + let post_admission_signature = post_admission_function + .split("// POST_ADMISSION_TAIL_START_V1") + .next() + .expect("маркер начала post-admission tail должен следовать после сигнатуры"); + assert!(post_admission_signature.contains("-> Self")); + let post_admission = post_admission_function + .split("// POST_ADMISSION_TAIL_START_V1") + .nth(1) + .expect("маркер начала post-admission tail обязателен") + .split("// POST_ADMISSION_TAIL_END_V1") + .next() + .expect("маркер конца post-admission tail обязателен"); + for forbidden in [ + "try_reserve", + ".map_err", + ".instantiate(", + ".unwrap(", + ".expect(", + "panic!", + "drop(", + ] { + assert!( + !post_admission.contains(forbidden), + "post-admission построение содержит fallible/destructive operation: {forbidden}", + ); + } + assert!(post_admission.contains("admission.into_parts()")); + assert!(!post_admission.contains(".current_stamp(")); + assert!(attachment_source.contains("fn close_before_release(&mut self);")); + assert!(attachment_source.contains("self.sink.close_before_release();")); + assert!( attachment_source.contains("transition.commit_deferred()"), "Attachment must publish through the deferred Session commit seam", @@ -728,9 +1360,9 @@ fn source_guards_keep_the_post_install_tail_destructor_free() { .split("fn prepare<'lease>(") .nth(1) .expect("test sink must implement prepare") - .split("fn revoke_all_before_release") + .split("fn close_before_release") .next() - .expect("prepare body must precede revoke implementation"); + .expect("prepare body must precede close implementation"); assert!( sink_prepare .find("drop(self.retired.take())") @@ -806,6 +1438,8 @@ fn dispose_revokes_before_hostile_retirement_destructor_runs() { assert_eq!(probe.retirement_drop_count(), 1); assert_eq!(probe.revoke_count(), 1); assert!(probe.snapshot().is_empty()); + assert!(probe.is_closed()); + assert!(!probe.ambient_fallback_is_exposed()); assert!(probe.revoked_before_lease_drop()); } @@ -891,7 +1525,8 @@ fn dispose_revokes_before_lease_session_and_owner_pin_release() { attachment.dispose(); assert!(probe.snapshot().is_empty()); + assert!(probe.is_closed()); + assert!(!probe.ambient_fallback_is_exposed()); assert_eq!(probe.revoke_count(), 1); - assert!(probe.revoke_saw_exact_stamp()); assert!(probe.revoked_before_lease_drop()); }