Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 150 additions & 10 deletions crates/minimald/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,34 @@ async fn materialize_patches_into_home(
Ok(())
}

/// Load the persisted composition snapshot for an `Active` session
/// brought up from disk after a daemon restart. Returns `None` (with
/// a warning log) when the sidecar is missing or corrupt. The
/// launcher then falls back to its baseline set, preserving the
/// "attach still works" property at the cost of the lost loadout
/// contributions. This is the loud-fallback path: the operator sees
/// the warning instead of a silent drop.
async fn load_composition(record: &SessionRecordHandle) -> Option<Arc<Composition>> {
match record.load_composition().await {
Ok(Some(comp)) => Some(Arc::new(comp)),
Ok(None) => {
tracing::warn!(
session_id = %record.id(),
"no composition snapshot for Active session; falling back to baseline",
);
None
}
Err(e) => {
tracing::warn!(
session_id = %record.id(),
error = %e,
"failed to load composition snapshot; falling back to baseline",
);
None
}
}
}

/// The name a session's PTask hostname is registered under, doubling as the
/// session host's display name: the session's assigned name, or the project
/// directory's basename when unnamed.
Expand Down Expand Up @@ -185,8 +213,10 @@ enum SessionInner {
/// on-disk `Active` record.
Active {
/// The finalized [`Composition`] this session was created with.
/// `None` after a daemon restart — the composition isn't persisted,
/// and the launcher falls back to its baseline set in that case.
/// `None` only when the sidecar is missing or corrupt on a
/// session brought up from disk after a daemon restart —
/// [`load_composition`] logs a warning and the launcher
/// falls back to its baseline set in that case.
///
/// The launcher currently consumes only the composition's packages
/// and vars. Patches (need file-upload plumbing) and lifecycle hooks
Expand Down Expand Up @@ -387,19 +417,23 @@ impl Session {
/// Launches the actor for a session — the one path onto which every
/// session actor is spawned. The initial state machine state is derived
/// from the record alone: an `Active` record comes up ready to attach
/// (without a composition; it isn't persisted, so the launcher falls back
/// to its baseline set), a `Pending` one as an unconfigured `Draft`
/// awaiting `ConfigureLoadout`.
/// with its composition restored from the snapshot sidecar (or, if the
/// sidecar is missing or corrupt, with a logged warning and no
/// composition so the launcher falls back to its baseline set), a
/// `Pending` one as an unconfigured `Draft` awaiting `ConfigureLoadout`.
pub(crate) async fn run(conf: SessionConfig) -> Result<SessionHandle, std::io::Error> {
let obj = conf.record.object().await?;
Self::create_dirs(&obj)?;

let inner = match obj.record().status {
SessionStatus::Active => SessionInner::Active {
composition: None,
host: None,
sops: vec![],
},
SessionStatus::Active => {
let composition = load_composition(&conf.record).await;
SessionInner::Active {
composition,
host: None,
sops: vec![],
}
}
SessionStatus::Pending => SessionInner::Draft { pending: None },
// `Materializing` records are only meaningful across a
// matching in-memory composition, which is lost on
Expand Down Expand Up @@ -714,6 +748,13 @@ impl Session {
// (a session isn't attachable until then, so
// publishing the route would let something reach a
// launcher that can't materialize its patches).
//
// Persist the composition snapshot before the record
// write so a crash at any point leaves either a
// reaped session (Pending/Materializing records are
// reaped at startup) or an Active session with its
// sidecar intact.
self.record.store_composition(&composition).await?;
let mut record = object.record().clone();
record.status = SessionStatus::Materializing;
self.record.write(record.clone()).await?;
Expand Down Expand Up @@ -786,6 +827,11 @@ impl Session {
// `FinalizeSession` after patches upload — see
// [`Self::finalize`] for the transition and its
// preconditions.
//
// Persist the composition snapshot before the record write
// (same crash-safety reasoning as the Materialized fast
// path above).
self.record.store_composition(&composition).await?;
let mut record = self.record.record().await?;
record.status = SessionStatus::Materializing;
self.record.write(record.clone()).await?;
Expand Down Expand Up @@ -1991,4 +2037,98 @@ mod tests {
"reattaching should flush prior terminal state, got: {flushed:?}",
);
}

/// A session's composition persists across a daemon restart: the
/// sidecar written at composition-assembly time is read back when
/// the actor is respawned from disk, so the launcher sees the same
/// packages and vars instead of falling back to the baseline set.
/// This is the core fix for issue #849 — "session composition state
/// is in-memory only: daemon restart drops loadout packages/vars
/// for existing sessions."
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn composition_survives_actor_restart() {
let server = TestServer::new().await;
let mut client = server.connect().await;
let session_id = create_session(&mut client).await;

// The actor was spawned during `create_configured_session`
// and holds the composition in memory. Verify it's present.
let manager = server.state.sessions_manager().await;
let handle = manager
.get_session(crate::sessions::SessionKeyPredicate::Id(session_id))
.await
.unwrap()
.expect("session should resolve while actor is running");
assert!(
handle.peek_composition().await.is_some(),
"freshly configured session should hold its composition in memory"
);

// Stop the actor and evict it from the running map so the
// next `get_session` spawns a fresh actor from the on-disk
// record — simulating a daemon restart.
handle.stop().await;
manager.evict(session_id).await;

// Re-resolve: spawns a new actor from disk. The composition
// should be restored from the sidecar, not None.
let handle = manager
.get_session(crate::sessions::SessionKeyPredicate::Id(session_id))
.await
.unwrap()
.expect("session should resolve after eviction");
assert!(
handle.peek_composition().await.is_some(),
"re-spawned session should have its composition restored from \
the sidecar, not fall back to baseline"
);
}

/// When the sidecar is missing (e.g. a session that predated the
/// sidecar, or a corrupt filesystem), the actor still spawns — but
/// with no composition, so the launcher falls back to its baseline
/// set. The operator sees a warning log rather than a silent drop.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn missing_sidecar_falls_back_to_baseline() {
let server = TestServer::new().await;
let mut client = server.connect().await;
let session_id = create_session(&mut client).await;

let manager = server.state.sessions_manager().await;
let handle = manager
.get_session(crate::sessions::SessionKeyPredicate::Id(session_id))
.await
.unwrap()
.expect("session should resolve");

// Delete the sidecar to simulate a pre-sidecar session or a
// corrupt filesystem. The composition sidecar lives at
// `<session-root>/composition.json`, a sibling of `record.json`;
// derive it from the workspace path (`<root>/tree`).
let paths = handle.paths().await.expect("paths should resolve");
let composition_file = paths
.working
.parent()
.expect("workspace path has a parent")
.join(&paths::DaemonRelPath::try_new("composition.json").unwrap());
tokio::fs::remove_file(composition_file.as_utf8_path())
.await
.expect("sidecar should exist to delete");

handle.stop().await;
manager.evict(session_id).await;

// Re-resolve: spawns from disk with no sidecar. The
// composition should be None — loud fallback to baseline.
let handle = manager
.get_session(crate::sessions::SessionKeyPredicate::Id(session_id))
.await
.unwrap()
.expect("session should resolve after eviction");
assert!(
handle.peek_composition().await.is_none(),
"session with a missing sidecar should fall back to baseline \
(composition None), not hold a stale composition"
);
}
}
53 changes: 53 additions & 0 deletions crates/minimald/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ enum StoreMessage {
oneshot::Sender<Result<(), std::io::Error>>,
),
SessionDelete(DiskSessionKey, oneshot::Sender<Result<(), std::io::Error>>),
CompositionLoad(
DiskSessionKey,
oneshot::Sender<Result<Option<sessions::core::compose::Composition>, std::io::Error>>,
),
CompositionStore(
DiskSessionKey,
std::sync::Arc<sessions::core::compose::Composition>,
oneshot::Sender<Result<(), std::io::Error>>,
),
}

/// The session store actor. Mediates reading, writing, and enumerating
Expand Down Expand Up @@ -132,6 +141,12 @@ impl Store {
StoreMessage::SessionDelete(k, r) => {
let _ = r.send(self.store.delete(&k));
}
StoreMessage::CompositionLoad(k, r) => {
let _ = r.send(self.store.load_composition(&k));
}
StoreMessage::CompositionStore(k, comp, r) => {
let _ = r.send(self.store.store_composition(&k, &comp));
}
}
}
}
Expand Down Expand Up @@ -249,6 +264,44 @@ impl SessionRecordHandle {
.await;
recv.await.expect("corresponding store is dead")
}

/// Loads the persisted composition snapshot for this session, if
/// one exists. Returns `Ok(None)` when the sidecar is absent
/// (pre-sidecar session, or composition was never assembled). A
/// corrupt sidecar surfaces as an error so the caller can log it
/// and fall back to baseline.
pub async fn load_composition(
&self,
) -> Result<Option<sessions::core::compose::Composition>, std::io::Error> {
let (send, recv) = oneshot::channel();
let _ = self
.h
.sender
.send(StoreMessage::CompositionLoad(self.k.clone(), send))
.await;
recv.await.expect("corresponding store is dead")
}

/// Atomically persists the composition snapshot for this session
/// (tmp + rename). Called at composition-assembly time so a
/// restart can re-apply the exact composition that was approved at
/// `min activate` time.
pub async fn store_composition(
&self,
composition: &sessions::core::compose::Composition,
) -> Result<(), std::io::Error> {
let (send, recv) = oneshot::channel();
let _ = self
.h
.sender
.send(StoreMessage::CompositionStore(
self.k.clone(),
std::sync::Arc::new(composition.clone()),
send,
))
.await;
recv.await.expect("corresponding store is dead")
}
}

/// A non-owning handle to the [`Store`] actor.
Expand Down
16 changes: 8 additions & 8 deletions crates/sessions/docs/COMPOSITION.md
Original file line number Diff line number Diff line change
Expand Up @@ -672,14 +672,14 @@ overload:
stuck `Materializing` records.
- **`Materializing` records don't survive daemon restart.** The
`SessionInner::Active { composition, .. }` state that Phase 4a
produces is memory-only: the composition isn't persisted to the
record, only the *status* is. A `Materializing` record whose
actor is respawned from disk after a restart has no
in-memory composition to check against, so `Manager::init`
runs `reap_unresumable_records` at startup and deletes any
`Pending` or `Materializing` record it finds (with an `info!`
log). If a race lets one through, `finalize` refuses with an
`InvalidInput` fault ("session is Materializing but has no
produces is persisted to a `composition.json` sidecar alongside
`record.json` so a restart can restore it for `Active` sessions.
But a `Materializing` record means the patches upload hasn't
completed — the sidecar exists but the patches marker doesn't,
so `Manager::init` runs `reap_unresumable_records` at startup and
deletes any `Pending` or `Materializing` record it finds (with an
`info!` log). If a race lets one through, `finalize` refuses with
an `InvalidInput` fault ("session is Materializing but has no
in-memory composition") so the operator sees the problem
instead of silently attaching to an empty-home shell.
- **Patches unpack is atomic; the marker is the precondition.**
Expand Down
32 changes: 32 additions & 0 deletions crates/sessions/src/core/compose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,38 @@ impl Composition {
}
}

/// Reconstruct a [`Composition`] from a persisted
/// [`WireComposition`](crate::wire::request::WireComposition)
/// snapshot. The daemon writes the snapshot at composition-assembly
/// time and reads it back at spawn-from-disk so a restart re-applies
/// the exact composition that was approved at `min activate` time.
///
/// Fallible only on lifecycle hooks (a wire hook with no callbacks
/// is rejected); vars, patches, and packages convert infallibly via
/// their existing `From` impls.
impl TryFrom<crate::wire::request::WireComposition> for Composition {
type Error = ComposeError;

fn try_from(wire: crate::wire::request::WireComposition) -> Result<Self, Self::Error> {
let hooks: Vec<ProvenancedHook> = wire
.lifecycle_hooks
.into_iter()
.map(|h| {
h.try_into().map_err(|e| ComposeError::InvalidWireItem {
what: "lifecycle hook with no callbacks",
context: format!("{e}"),
})
})
.collect::<Result<_, _>>()?;
Ok(Self {
vars: wire.vars.into_iter().map(Into::into).collect(),
patches: wire.patches.into_iter().map(Into::into).collect(),
packages: wire.packages.into_iter().map(Into::into).collect(),
lifecycle_hooks: hooks,
})
Comment on lines +1247 to +1263

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject unsupported snapshot versions.

WireComposition.version is never checked, so an older daemon will silently interpret a future snapshot using v1 semantics rather than treating it as corrupt and falling back. That can restore the wrong loadout after restart.

Proposed fix
 fn try_from(wire: crate::wire::request::WireComposition) -> Result<Self, Self::Error> {
+    if wire.version != crate::wire::request::COMPOSITION_SNAPSHOT_VERSION {
+        return Err(ComposeError::InvalidWireItem {
+            what: "unsupported composition snapshot version",
+            context: wire.version.to_string(),
+        });
+    }
+
     let hooks: Vec<ProvenancedHook> = wire
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn try_from(wire: crate::wire::request::WireComposition) -> Result<Self, Self::Error> {
let hooks: Vec<ProvenancedHook> = wire
.lifecycle_hooks
.into_iter()
.map(|h| {
h.try_into().map_err(|e| ComposeError::InvalidWireItem {
what: "lifecycle hook with no callbacks",
context: format!("{e}"),
})
})
.collect::<Result<_, _>>()?;
Ok(Self {
vars: wire.vars.into_iter().map(Into::into).collect(),
patches: wire.patches.into_iter().map(Into::into).collect(),
packages: wire.packages.into_iter().map(Into::into).collect(),
lifecycle_hooks: hooks,
})
fn try_from(wire: crate::wire::request::WireComposition) -> Result<Self, Self::Error> {
if wire.version != crate::wire::request::COMPOSITION_SNAPSHOT_VERSION {
return Err(ComposeError::InvalidWireItem {
what: "unsupported composition snapshot version",
context: wire.version.to_string(),
});
}
let hooks: Vec<ProvenancedHook> = wire
.lifecycle_hooks
.into_iter()
.map(|h| {
h.try_into().map_err(|e| ComposeError::InvalidWireItem {
what: "lifecycle hook with no callbacks",
context: format!("{e}"),
})
})
.collect::<Result<_, _>>()?;
Ok(Self {
vars: wire.vars.into_iter().map(Into::into).collect(),
patches: wire.patches.into_iter().map(Into::into).collect(),
packages: wire.packages.into_iter().map(Into::into).collect(),
lifecycle_hooks: hooks,
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/sessions/src/core/compose.rs` around lines 1247 - 1263, Update the
WireComposition conversion in try_from to validate wire.version before
converting any fields, accepting only the currently supported snapshot version
and returning the appropriate ComposeError for unsupported versions so callers
can fall back instead of interpreting them as v1.

}
}

/// Configuration for the compose pipeline.
///
/// Defaults to symlink-safe behavior (no following) — appropriate for
Expand Down
Loading