Verify typed startup failures across factory adapter and protocol
This commit is contained in:
@@ -101,3 +101,186 @@ impl PendingRuntimeRestore {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
//! Connect the typed factory seam to the real Tokio adapter and protocol codec.
|
||||||
|
//! The external startup action is a deterministic failure, not a real domain
|
||||||
|
//! authorization decision. Resource disposition remains the existing backend result.
|
||||||
|
use super::*;
|
||||||
|
use crate::execution::WorkerExecutionContext;
|
||||||
|
use crate::identity::WorkerId;
|
||||||
|
use protocol::startup_failure::*;
|
||||||
|
|
||||||
|
struct TypedFailingFactory;
|
||||||
|
fn primary() -> StartupFailureAttachment {
|
||||||
|
worker::startup_failure::runtime_startup_attachment(RuntimeStartupCause {
|
||||||
|
stage: StartupStage::Feature,
|
||||||
|
reason: RuntimeStartupReason::FeatureFailed,
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
fn failure() -> RuntimeRestoreFailure {
|
||||||
|
let mut failure = RuntimeRestoreFailure::unproven(
|
||||||
|
RuntimeRestoreStage::Controller,
|
||||||
|
"factory failed after receiving primary",
|
||||||
|
)
|
||||||
|
.with_startup_failure(primary());
|
||||||
|
failure.cleanup = RuntimeRestoreCleanup::Failed(RuntimeRestoreCleanupFailure::Workdir);
|
||||||
|
failure.cleanup_failure = Some(RuntimeRestoreCleanupFailure::Workdir);
|
||||||
|
failure.io_kind = Some(std::io::ErrorKind::PermissionDenied);
|
||||||
|
failure
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl RuntimeWorkerFactory for TypedFailingFactory {
|
||||||
|
async fn spawn_controller(
|
||||||
|
&self,
|
||||||
|
_: WorkerExecutionSpawnRequest,
|
||||||
|
) -> Result<RuntimeWorkerController, RuntimeRestoreFailure> {
|
||||||
|
Err(failure())
|
||||||
|
}
|
||||||
|
async fn preflight_restore(
|
||||||
|
&self,
|
||||||
|
_: &WorkerExecutionRestoreRequest,
|
||||||
|
) -> Result<(), RuntimeRestoreFailure> {
|
||||||
|
Err(failure())
|
||||||
|
}
|
||||||
|
async fn prepare_restore(
|
||||||
|
&self,
|
||||||
|
_: WorkerExecutionRestoreRequest,
|
||||||
|
) -> Result<Box<dyn RuntimeRestoreCandidate>, RuntimeRestoreFailure> {
|
||||||
|
panic!("typed preflight rejection must not prepare live work")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request() -> WorkerExecutionSpawnRequest {
|
||||||
|
let worker_ref = WorkerRef::new(WorkerId::now_v7());
|
||||||
|
// No factory reads this archive: it is explicit launch input, not a retained approval.
|
||||||
|
let archive = crate::profile_archive::ProfileSourceArchive::build(
|
||||||
|
crate::profile_archive::ProfileSourceArchiveInput {
|
||||||
|
id: "profile-source-archive:typed-startup".into(),
|
||||||
|
entrypoints: BTreeMap::from([(
|
||||||
|
"builtin:companion".into(),
|
||||||
|
"profiles/default.dcdl".into(),
|
||||||
|
)]),
|
||||||
|
imports: BTreeMap::new(),
|
||||||
|
sources: BTreeMap::from([("profiles/default.dcdl".into(), "{}".into())]),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let request = crate::catalog::CreateWorkerRequest {
|
||||||
|
worker_id: worker_ref.worker_id,
|
||||||
|
create_fingerprint: "typed-startup-failure".into(),
|
||||||
|
profile: crate::catalog::ProfileSelector::Builtin("builtin:companion".into()),
|
||||||
|
display_name: None,
|
||||||
|
profile_source: crate::catalog::ProfileSourceArchiveSource::Embedded { archive },
|
||||||
|
config_bundle: None,
|
||||||
|
initial_input: None,
|
||||||
|
workdir_attachment_requests: Vec::new(),
|
||||||
|
workdir_attachments: Vec::new(),
|
||||||
|
worker_observation_enabled: false,
|
||||||
|
worker_observation_grants: Vec::new(),
|
||||||
|
workspace_api: None,
|
||||||
|
memory_settings: None,
|
||||||
|
plugin_execution: None,
|
||||||
|
subjektiv_attached: false,
|
||||||
|
backend_job: None,
|
||||||
|
};
|
||||||
|
#[cfg(feature = "ws-server")]
|
||||||
|
let context = WorkerExecutionContext::new(
|
||||||
|
worker_ref.clone(),
|
||||||
|
Arc::new(|_, _| unreachable!("failed startup must not publish a Controller")),
|
||||||
|
);
|
||||||
|
#[cfg(not(feature = "ws-server"))]
|
||||||
|
let context = WorkerExecutionContext::new(worker_ref.clone());
|
||||||
|
WorkerExecutionSpawnRequest {
|
||||||
|
worker_ref,
|
||||||
|
request,
|
||||||
|
workspace_scope: None,
|
||||||
|
context,
|
||||||
|
workdir_attachments: BTreeMap::new(),
|
||||||
|
config_bundle: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_primary(result: &WorkerExecutionResult) {
|
||||||
|
assert_eq!(result.startup_failure.as_ref(), Some(&primary()));
|
||||||
|
let Some(error_contract::DecodeFailure::Known(received)) =
|
||||||
|
result.startup_failure::<RuntimeStartupCause>()
|
||||||
|
else {
|
||||||
|
panic!("typed primary missing")
|
||||||
|
};
|
||||||
|
assert_eq!(received.facts().cause().stage, StartupStage::Feature);
|
||||||
|
assert_eq!(
|
||||||
|
received.facts().cause().reason,
|
||||||
|
RuntimeStartupReason::FeatureFailed
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
received.facts().context().operations[0].operation,
|
||||||
|
"worker_startup"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.secondary_failures
|
||||||
|
.contains(&StartupSecondaryFailure::Cleanup {
|
||||||
|
failure: StartupCleanupFailure::Workdir
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result.startup_io_kind,
|
||||||
|
Some(StartupIoKind::PermissionDenied)
|
||||||
|
);
|
||||||
|
assert!(!result.is_accepted());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typed_factory_failure_survives_adapter_and_protocol_without_changing_disposition() {
|
||||||
|
let backend = WorkerRuntimeExecutionBackend::new(TypedFailingFactory).unwrap();
|
||||||
|
let WorkerExecutionSpawnResult::Errored(result) = backend.spawn_worker(request()) else {
|
||||||
|
panic!("cause attachment must not reclassify the existing spawn result")
|
||||||
|
};
|
||||||
|
assert_primary(&result);
|
||||||
|
let transferred: WorkerExecutionResult =
|
||||||
|
serde_json::from_slice(&serde_json::to_vec(&result).unwrap()).unwrap();
|
||||||
|
assert_eq!(transferred, result);
|
||||||
|
assert_primary(&transferred);
|
||||||
|
assert_eq!(
|
||||||
|
transferred.startup_resources.unwrap().cleanup,
|
||||||
|
StartupCleanup::Failed(StartupCleanupFailure::Workdir)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typed_preflight_failure_survives_adapter_without_starting_live_preparation() {
|
||||||
|
let backend = WorkerRuntimeExecutionBackend::new(TypedFailingFactory).unwrap();
|
||||||
|
let spawn = request();
|
||||||
|
let request = WorkerExecutionRestoreRequest {
|
||||||
|
source_repair_possible: false,
|
||||||
|
operation_id: crate::execution::WorkerLifecycleOperationId::new(),
|
||||||
|
worker_ref: spawn.worker_ref,
|
||||||
|
request: spawn.request,
|
||||||
|
workspace_scope: None,
|
||||||
|
context: spawn.context,
|
||||||
|
previous_workdir_attachments: Vec::new(),
|
||||||
|
logical_workdir_attachments: Vec::new(),
|
||||||
|
workdir_attachments: BTreeMap::new(),
|
||||||
|
deferred_workdir_sessions: BTreeMap::new(),
|
||||||
|
config_bundle: None,
|
||||||
|
};
|
||||||
|
let result = backend.preflight_restore(&request).unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
result.outcome,
|
||||||
|
crate::execution::WorkerExecutionOutcome::Rejected
|
||||||
|
);
|
||||||
|
assert_primary(&result);
|
||||||
|
let failure = backend
|
||||||
|
.run_on_adapter_runtime(async move {
|
||||||
|
Ok(prepare_restore_operation(&TypedFailingFactory, request)
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.unwrap())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(failure.startup_failure(), &primary());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user