Notify superseded plugin processes and support SDK draining
This commit is contained in:
+9
-4
@@ -70,7 +70,7 @@ Rust plugins use the SDK `sausage-plugin`. Its source is at
|
|||||||
`notes.rpc.discover` sends it `rpc.discover`.
|
`notes.rpc.discover` sends it `rpc.discover`.
|
||||||
- `core.<method>` is a method of the core (section 9).
|
- `core.<method>` is a method of the core (section 9).
|
||||||
- Method names that start with `rpc.` are reserved: `rpc.discover`,
|
- Method names that start with `rpc.` are reserved: `rpc.discover`,
|
||||||
`rpc.retired`, and `rpc.endpoint`.
|
`rpc.superseded`, `rpc.retired`, and `rpc.endpoint`.
|
||||||
|
|
||||||
### 3.3 What a call does
|
### 3.3 What a call does
|
||||||
|
|
||||||
@@ -153,9 +153,14 @@ A process is an instance of one plugin version. When a new version becomes
|
|||||||
active (section 10.3):
|
active (section 10.3):
|
||||||
|
|
||||||
1. New calls get the new version at once.
|
1. New calls get the new version at once.
|
||||||
2. Each process of an older version gets a grace period to finish its work.
|
2. At grace start, the core sends each process of an older version the
|
||||||
The default is 60 seconds. A plugin can declare its own value in
|
JSON-RPC notification `rpc.superseded {"current": "<sha256 of the new file>"}`
|
||||||
`rpc.discover` (`x-sausage-grace`). Then the core stops the process.
|
on stdin, without an ID. The process is still running: calls and responses
|
||||||
|
continue to work. It can stop accepting work, finish its in-flight calls,
|
||||||
|
and exit early. Ignoring the notice changes nothing. The grace period is
|
||||||
|
the upper bound before the core stops the process (section 3.6); its
|
||||||
|
default is 60 seconds. A plugin can declare its own value in its old
|
||||||
|
`rpc.discover` document (`x-sausage-grace`).
|
||||||
3. When the old process of a service has ended, the core starts the service
|
3. When the old process of a service has ended, the core starts the service
|
||||||
again with the new version.
|
again with the new version.
|
||||||
4. When the last process of an older version has ended, the core starts a
|
4. When the last process of an older version has ended, the core starts a
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub(crate) struct OldProcess {
|
|||||||
pub(crate) struct Upgrade {
|
pub(crate) struct Upgrade {
|
||||||
plugin: String,
|
plugin: String,
|
||||||
previous: String,
|
previous: String,
|
||||||
|
current: String,
|
||||||
grace: Duration,
|
grace: Duration,
|
||||||
processes: Vec<OldProcess>,
|
processes: Vec<OldProcess>,
|
||||||
services: Vec<(Service, Option<watch::Receiver<bool>>)>,
|
services: Vec<(Service, Option<watch::Receiver<bool>>)>,
|
||||||
@@ -67,6 +68,7 @@ impl Host {
|
|||||||
Upgrade {
|
Upgrade {
|
||||||
plugin: plugin.into(),
|
plugin: plugin.into(),
|
||||||
previous,
|
previous,
|
||||||
|
current: current.into(),
|
||||||
grace: Duration::from_secs_f64(grace),
|
grace: Duration::from_secs_f64(grace),
|
||||||
processes,
|
processes,
|
||||||
services,
|
services,
|
||||||
@@ -74,6 +76,13 @@ impl Host {
|
|||||||
}
|
}
|
||||||
/// Lets old calls finish, stops them after grace, and starts each lifecycle hook once.
|
/// Lets old calls finish, stops them after grace, and starts each lifecycle hook once.
|
||||||
pub(crate) fn begin_upgrade(&self, upgrade: Upgrade) {
|
pub(crate) fn begin_upgrade(&self, upgrade: Upgrade) {
|
||||||
|
// Queue the notice before returning from activation, without waiting on guest code.
|
||||||
|
for process in &upgrade.processes {
|
||||||
|
self.send(
|
||||||
|
&process.pid,
|
||||||
|
json!({"jsonrpc":"2.0","method":"rpc.superseded","params":{"current":upgrade.current}}),
|
||||||
|
);
|
||||||
|
}
|
||||||
let host = self.clone();
|
let host = self.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if host.0.shutdown.load(Ordering::SeqCst) {
|
if host.0.shutdown.load(Ordering::SeqCst) {
|
||||||
|
|||||||
@@ -839,6 +839,76 @@ async fn old_process_grace_and_new_calls() {
|
|||||||
"2"
|
"2"
|
||||||
);
|
);
|
||||||
marker(world.0.join("state/notes/ended-1"), "ended").await;
|
marker(world.0.join("state/notes/ended-1"), "ended").await;
|
||||||
|
let notice: Value = serde_json::from_slice(
|
||||||
|
&std::fs::read(world.0.join(format!("state/notes/superseded-{pid}"))).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(notice["params"]["current"], fixture_hash(2));
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
/// PROTOCOL 3.7: every old process gets the new hash while still running, before grace ends.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn superseded_notifies_each_old_process() {
|
||||||
|
let world = TestWorld::new();
|
||||||
|
let mut old = document("hold");
|
||||||
|
old["x-sausage-grace"] = json!(60);
|
||||||
|
world.plugin("notes", old, document("hold"));
|
||||||
|
let host = world.start().await;
|
||||||
|
call(&host, "notes.hold", json!({})).await;
|
||||||
|
call(&host, "notes.hold", json!({})).await;
|
||||||
|
let pids: Vec<_> = host
|
||||||
|
.processes()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, process)| process.method == "hold")
|
||||||
|
.map(|(pid, _)| pid)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(pids.len(), 2);
|
||||||
|
activate(&host, "notes").await.unwrap();
|
||||||
|
let expected =
|
||||||
|
json!({"jsonrpc":"2.0","method":"rpc.superseded","params":{"current":fixture_hash(2)}})
|
||||||
|
.to_string();
|
||||||
|
for pid in pids {
|
||||||
|
// marker's existing 30-second bound is shorter than the old 60-second grace.
|
||||||
|
marker(
|
||||||
|
world.0.join(format!("state/notes/superseded-{pid}")),
|
||||||
|
&expected,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(host.processes()[&pid].state, "running");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
call(&host, "notes.version", json!({})).await["version"],
|
||||||
|
"2"
|
||||||
|
);
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
/// PROTOCOL 3.7: a service that exits on the notice restarts before its grace deadline.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn superseded_service_exits_early() {
|
||||||
|
let world = TestWorld::new();
|
||||||
|
let mut old = document("serve");
|
||||||
|
old["x-sausage-grace"] = json!(60);
|
||||||
|
world.plugin("notes", old, document("serve"));
|
||||||
|
std::fs::write(
|
||||||
|
world.0.join("config/boot.yaml"),
|
||||||
|
json!([{"method":"notes.serve","params":{"exit_on_superseded":true}}]).to_string(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let host = world.start().await;
|
||||||
|
marker(world.0.join("state/notes/service"), "1").await;
|
||||||
|
let pid = host
|
||||||
|
.processes()
|
||||||
|
.into_iter()
|
||||||
|
.find(|(_, p)| p.method == "serve")
|
||||||
|
.unwrap()
|
||||||
|
.0;
|
||||||
|
activate(&host, "notes").await.unwrap();
|
||||||
|
marker(world.0.join("state/notes/service"), "2").await;
|
||||||
|
assert!(!host.processes().contains_key(&pid));
|
||||||
|
assert!(
|
||||||
|
!world.0.join("state/notes/ended-1").exists(),
|
||||||
|
"old service must exit on the notice, not EOF"
|
||||||
|
);
|
||||||
host.stop().await;
|
host.stop().await;
|
||||||
}
|
}
|
||||||
/// PROTOCOL 3.7: retirement receives the previous hash only after old processes end.
|
/// PROTOCOL 3.7: retirement receives the previous hash only after old processes end.
|
||||||
|
|||||||
@@ -89,7 +89,24 @@ fn main() {
|
|||||||
if request["method"] == "serve" {
|
if request["method"] == "serve" {
|
||||||
std::fs::write(format!("/state/{plugin}/service"), version).unwrap();
|
std::fs::write(format!("/state/{plugin}/service"), version).unwrap();
|
||||||
}
|
}
|
||||||
for _ in lines {}
|
for line in lines {
|
||||||
|
let Ok(message) = serde_json::from_str::<Value>(&line.unwrap()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if message["method"] == "rpc.superseded" {
|
||||||
|
std::fs::write(
|
||||||
|
format!(
|
||||||
|
"/state/{plugin}/superseded-{}",
|
||||||
|
std::env::var("SAUSAGE_PID").unwrap()
|
||||||
|
),
|
||||||
|
message.to_string(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
if request["params"]["exit_on_superseded"] == true {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
std::fs::write(format!("/state/{plugin}/ended-{version}"), "ended").unwrap();
|
std::fs::write(format!("/state/{plugin}/ended-{version}"), "ended").unwrap();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-6
@@ -153,6 +153,7 @@ pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream {
|
|||||||
let mut stop = quote!();
|
let mut stop = quote!();
|
||||||
let mut has_stop = false;
|
let mut has_stop = false;
|
||||||
let mut has_retired = false;
|
let mut has_retired = false;
|
||||||
|
let mut has_superseded = false;
|
||||||
for member in &mut item.items {
|
for member in &mut item.items {
|
||||||
let ImplItem::Fn(f) = member else { continue };
|
let ImplItem::Fn(f) = member else { continue };
|
||||||
let retired = f.attrs.iter().any(|a| {
|
let retired = f.attrs.iter().any(|a| {
|
||||||
@@ -161,6 +162,12 @@ pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream {
|
|||||||
.last()
|
.last()
|
||||||
.is_some_and(|s| s.ident == "on_retired")
|
.is_some_and(|s| s.ident == "on_retired")
|
||||||
});
|
});
|
||||||
|
let superseded = f.attrs.iter().any(|a| {
|
||||||
|
a.path()
|
||||||
|
.segments
|
||||||
|
.last()
|
||||||
|
.is_some_and(|s| s.ident == "on_superseded")
|
||||||
|
});
|
||||||
let stopping = f.attrs.iter().any(|a| {
|
let stopping = f.attrs.iter().any(|a| {
|
||||||
a.path()
|
a.path()
|
||||||
.segments
|
.segments
|
||||||
@@ -179,11 +186,16 @@ pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream {
|
|||||||
"only one on_retired method is allowed",
|
"only one on_retired method is allowed",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if superseded && std::mem::replace(&mut has_superseded, true) {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
f.span(),
|
||||||
|
"only one on_superseded method is allowed",
|
||||||
|
));
|
||||||
|
}
|
||||||
f.attrs.retain(|a| {
|
f.attrs.retain(|a| {
|
||||||
!a.path()
|
!a.path().segments.last().is_some_and(|s| {
|
||||||
.segments
|
s.ident == "on_retired" || s.ident == "on_stop" || s.ident == "on_superseded"
|
||||||
.last()
|
})
|
||||||
.is_some_and(|s| s.ident == "on_retired" || s.ident == "on_stop")
|
|
||||||
});
|
});
|
||||||
documented(&f.attrs, f.span())?;
|
documented(&f.attrs, f.span())?;
|
||||||
if f.sig.inputs.is_empty()
|
if f.sig.inputs.is_empty()
|
||||||
@@ -224,7 +236,9 @@ pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream {
|
|||||||
stop = quote!(fn stop(&self)->::std::pin::Pin<Box<dyn ::std::future::Future<Output=()> + '_>> {Box::pin(async move {let _=self.#name() #await_;})});
|
stop = quote!(fn stop(&self)->::std::pin::Pin<Box<dyn ::std::future::Future<Output=()> + '_>> {Box::pin(async move {let _=self.#name() #await_;})});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let wire = if retired {
|
let wire = if superseded {
|
||||||
|
"rpc.superseded".to_string()
|
||||||
|
} else if retired {
|
||||||
"rpc.retired".to_string()
|
"rpc.retired".to_string()
|
||||||
} else {
|
} else {
|
||||||
name.unraw().to_string()
|
name.unraw().to_string()
|
||||||
@@ -253,7 +267,7 @@ pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream {
|
|||||||
};
|
};
|
||||||
let doc = docs(&f.attrs);
|
let doc = docs(&f.attrs);
|
||||||
arms.push(quote!(#wire => { fn params_type<T: ::sausage_plugin::Params>() {} params_type::<#p>(); let result=self.#name(#(#args),*) #await_ ?; Ok(::sausage_plugin::serde_json::to_value(result)?) }));
|
arms.push(quote!(#wire => { fn params_type<T: ::sausage_plugin::Params>() {} params_type::<#p>(); let result=self.#name(#(#args),*) #await_ ?; Ok(::sausage_plugin::serde_json::to_value(result)?) }));
|
||||||
if !retired {
|
if !retired && !superseded {
|
||||||
entries.push(quote!(::sausage_plugin::method::<#p,#output>(&mut doc,#wire,#doc);));
|
entries.push(quote!(::sausage_plugin::method::<#p,#output>(&mut doc,#wire,#doc);));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -328,6 +342,12 @@ pub fn import(input: TokenStream) -> TokenStream {
|
|||||||
pub fn on_retired(_: TokenStream, input: TokenStream) -> TokenStream {
|
pub fn on_retired(_: TokenStream, input: TokenStream) -> TokenStream {
|
||||||
input
|
input
|
||||||
}
|
}
|
||||||
|
/// Mark the method that handles `rpc.superseded` at the start of upgrade grace.
|
||||||
|
/// Calls still work here. Use `sausage_plugin::drain` to finish active work and exit.
|
||||||
|
#[proc_macro_attribute]
|
||||||
|
pub fn on_superseded(_: TokenStream, input: TokenStream) -> TokenStream {
|
||||||
|
input
|
||||||
|
}
|
||||||
/// Mark the hook that runs at stdin EOF without a live call transport.
|
/// Mark the hook that runs at stdin EOF without a live call transport.
|
||||||
#[proc_macro_attribute]
|
#[proc_macro_attribute]
|
||||||
pub fn on_stop(_: TokenStream, input: TokenStream) -> TokenStream {
|
pub fn on_stop(_: TokenStream, input: TokenStream) -> TokenStream {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ impl<T: serde::de::DeserializeOwned + 'static> IntoFuture for EndpointCall<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish a declared interface and keep its listener alive until stdin EOF.
|
/// Publish a declared interface and keep its listener alive until stdin EOF or [`drain`].
|
||||||
#[cfg(target_os = "wasi")]
|
#[cfg(target_os = "wasi")]
|
||||||
pub async fn listen<T: Interface>(path: impl AsRef<Path>, value: Rc<T>) -> Result<()> {
|
pub async fn listen<T: Interface>(path: impl AsRef<Path>, value: Rc<T>) -> Result<()> {
|
||||||
use wstd::iter::AsyncIterator;
|
use wstd::iter::AsyncIterator;
|
||||||
@@ -80,7 +80,7 @@ pub async fn listen<T: Interface>(path: impl AsRef<Path>, value: Rc<T>) -> Resul
|
|||||||
while let Some(Ok(stream)) = incoming.next().await {
|
while let Some(Ok(stream)) = incoming.next().await {
|
||||||
let value = value.clone();
|
let value = value.clone();
|
||||||
let name = name.clone();
|
let name = name.clone();
|
||||||
crate::transport::endpoint_task(async move {
|
background(async move {
|
||||||
let _ = serve_connection(stream, value, name).await;
|
let _ = serve_connection(stream, value, name).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -211,7 +211,11 @@ async fn serve_connection<T: Interface>(
|
|||||||
use futures::{FutureExt, StreamExt};
|
use futures::{FutureExt, StreamExt};
|
||||||
let (mut reader, mut writer) = stream.split();
|
let (mut reader, mut writer) = stream.split();
|
||||||
let mut lines = crate::lines::Lines::default();
|
let mut lines = crate::lines::Lines::default();
|
||||||
while let Some(request) = lines.rpc(&mut reader).await? {
|
while let Some(request) = crate::transport::until_drained(lines.rpc(&mut reader))
|
||||||
|
.await
|
||||||
|
.transpose()?
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
let method = request["method"].as_str().unwrap_or_default();
|
let method = request["method"].as_str().unwrap_or_default();
|
||||||
let (tx, mut rx) = futures::channel::mpsc::unbounded();
|
let (tx, mut rx) = futures::channel::mpsc::unbounded();
|
||||||
let ctx = Ctx {
|
let ctx = Ctx {
|
||||||
|
|||||||
+20
-2
@@ -358,11 +358,16 @@
|
|||||||
//! `core.revert` restores code, not state. Preserve compatibility or migrate the state.
|
//! `core.revert` restores code, not state. Preserve compatibility or migrate the state.
|
||||||
//! `#[rpc(grace_seconds = N)]` declares the grace period for old processes. The default is 60 seconds.
|
//! `#[rpc(grace_seconds = N)]` declares the grace period for old processes. The default is 60 seconds.
|
||||||
//! New calls use the new version immediately. Old processes can still use old imports during their grace period.
|
//! New calls use the new version immediately. Old processes can still use old imports during their grace period.
|
||||||
|
//! [`macro@on_superseded`] handles the notice at grace start, with normal calls still available.
|
||||||
|
//! It is opt-in: without a hook the process continues as before until core stops it.
|
||||||
|
//! Call [`drain`] in this hook to close SDK endpoint listeners and idle connections,
|
||||||
|
//! let active handlers and background tasks finish, and exit without waiting for grace.
|
||||||
|
//! Custom listeners and long-lived background tasks must be ended by the plugin itself.
|
||||||
//! [`macro@on_retired`] handles `rpc.retired` after the last process of an old version ends.
|
//! [`macro@on_retired`] handles `rpc.retired` after the last process of an old version ends.
|
||||||
//! This hook is best effort. If the host stops first, the hook does not run.
|
//! This hook is best effort. If the host stops first, the hook does not run.
|
||||||
//! New processes must handle unmigrated state. Check a migration marker and migrate if it is absent.
|
//! New processes must handle unmigrated state. Check a migration marker and migrate if it is absent.
|
||||||
//! See section 3.7 of `/proc/core/PROTOCOL.md`.
|
//! See section 3.7 of `/proc/core/PROTOCOL.md`.
|
||||||
//! Declare at most one stop hook and one retirement hook per interface.
|
//! Declare at most one hook of each kind per interface.
|
||||||
//!
|
//!
|
||||||
//! ```no_run
|
//! ```no_run
|
||||||
//! use sausage_plugin::{rpc, schema, state_dir, write_atomic, Result};
|
//! use sausage_plugin::{rpc, schema, state_dir, write_atomic, Result};
|
||||||
@@ -374,8 +379,21 @@
|
|||||||
//! /// The SHA-256 of the previous WASM file.
|
//! /// The SHA-256 of the previous WASM file.
|
||||||
//! previous: String,
|
//! previous: String,
|
||||||
//! }
|
//! }
|
||||||
|
//! /// The version now receiving new calls.
|
||||||
|
//! #[schema]
|
||||||
|
//! struct SupersededParams {
|
||||||
|
//! /// The SHA-256 of the newly active WASM file.
|
||||||
|
//! current: String,
|
||||||
|
//! }
|
||||||
//! #[rpc]
|
//! #[rpc]
|
||||||
//! impl Migrator {
|
//! impl Migrator {
|
||||||
|
//! /// Finish current work and exit early during upgrade grace.
|
||||||
|
//! #[sausage_plugin::on_superseded]
|
||||||
|
//! fn superseded(&self, p: SupersededParams) -> Result<()> {
|
||||||
|
//! eprintln!("replaced by {}", p.current);
|
||||||
|
//! sausage_plugin::drain();
|
||||||
|
//! Ok(())
|
||||||
|
//! }
|
||||||
//! /// Record a retired version. Normal methods must also handle missing markers.
|
//! /// Record a retired version. Normal methods must also handle missing markers.
|
||||||
//! #[sausage_plugin::on_retired]
|
//! #[sausage_plugin::on_retired]
|
||||||
//! fn retired(&self, p: RetiredParams) -> Result<()> {
|
//! fn retired(&self, p: RetiredParams) -> Result<()> {
|
||||||
@@ -401,6 +419,6 @@ pub use helpers::*;
|
|||||||
pub use imports::*;
|
pub use imports::*;
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub use inventory;
|
pub use inventory;
|
||||||
pub use sausage_plugin_macros::{import, on_retired, on_stop, rpc, schema, test};
|
pub use sausage_plugin_macros::{import, on_retired, on_stop, on_superseded, rpc, schema, test};
|
||||||
pub use transport::*;
|
pub use transport::*;
|
||||||
pub use {schemars, serde, serde_json};
|
pub use {schemars, serde, serde_json};
|
||||||
|
|||||||
+36
-4
@@ -96,6 +96,7 @@ struct State {
|
|||||||
active: bool,
|
active: bool,
|
||||||
endpoints: HashMap<std::any::TypeId, String>,
|
endpoints: HashMap<std::any::TypeId, String>,
|
||||||
stopping: bool,
|
stopping: bool,
|
||||||
|
draining: bool,
|
||||||
next: u64,
|
next: u64,
|
||||||
pending: HashMap<u64, Pending>,
|
pending: HashMap<u64, Pending>,
|
||||||
#[cfg(target_os = "wasi")]
|
#[cfg(target_os = "wasi")]
|
||||||
@@ -125,17 +126,48 @@ pub(crate) fn endpoint_name<T: 'static>() -> Result<String> {
|
|||||||
}
|
}
|
||||||
#[cfg(target_os = "wasi")]
|
#[cfg(target_os = "wasi")]
|
||||||
pub(crate) fn endpoint_task(future: impl Future<Output = ()> + 'static) {
|
pub(crate) fn endpoint_task(future: impl Future<Output = ()> + 'static) {
|
||||||
|
background(async move {
|
||||||
|
let _ = until_drained(future).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/// Cancel an accept/read wait when draining, but never an active handler.
|
||||||
|
#[cfg(target_os = "wasi")]
|
||||||
|
pub(crate) async fn until_drained<F: Future>(future: F) -> Option<F::Output> {
|
||||||
let (handle, registration) = futures::future::AbortHandle::new_pair();
|
let (handle, registration) = futures::future::AbortHandle::new_pair();
|
||||||
let id = STATE.with(|s| {
|
let id = STATE.with(|s| {
|
||||||
let mut s = s.borrow_mut();
|
let mut s = s.borrow_mut();
|
||||||
|
if s.draining {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
s.next += 1;
|
s.next += 1;
|
||||||
let id = s.next;
|
let id = s.next;
|
||||||
s.listeners.insert(id, handle);
|
s.listeners.insert(id, handle);
|
||||||
id
|
Some(id)
|
||||||
});
|
})?;
|
||||||
background(async move {
|
let result = futures::future::Abortable::new(future, registration)
|
||||||
let _ = futures::future::Abortable::new(future, registration).await;
|
.await
|
||||||
|
.ok();
|
||||||
STATE.with(|s| s.borrow_mut().listeners.remove(&id));
|
STATE.with(|s| s.borrow_mut().listeners.remove(&id));
|
||||||
|
result
|
||||||
|
}
|
||||||
|
/// Stop accepting SDK endpoint work, then exit when handlers and background tasks finish.
|
||||||
|
///
|
||||||
|
/// Calls and notifications still work. This does not run the EOF stop hook or cancel
|
||||||
|
/// background tasks: services with their own listeners/tasks must arrange for those
|
||||||
|
/// to finish too. Repeated calls are harmless. Core's upgrade grace remains the bound.
|
||||||
|
pub fn drain() {
|
||||||
|
STATE.with(|s| {
|
||||||
|
let mut s = s.borrow_mut();
|
||||||
|
s.draining = true;
|
||||||
|
#[cfg(target_os = "wasi")]
|
||||||
|
{
|
||||||
|
for (_, listener) in s.listeners.drain() {
|
||||||
|
listener.abort();
|
||||||
|
}
|
||||||
|
if let Some(w) = s.wake.take() {
|
||||||
|
w.wake();
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "wasi")]
|
#[cfg(target_os = "wasi")]
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ struct Retired {
|
|||||||
/// The previous hash.
|
/// The previous hash.
|
||||||
previous: String,
|
previous: String,
|
||||||
}
|
}
|
||||||
|
/// The replacement version.
|
||||||
|
#[schema]
|
||||||
|
struct Superseded {
|
||||||
|
/// The current hash.
|
||||||
|
current: String,
|
||||||
|
}
|
||||||
/// A large payload.
|
/// A large payload.
|
||||||
#[schema]
|
#[schema]
|
||||||
struct Echo {
|
struct Echo {
|
||||||
@@ -111,6 +117,15 @@ impl Probe {
|
|||||||
fn retired(&self, p: Retired) -> Result<String> {
|
fn retired(&self, p: Retired) -> Result<String> {
|
||||||
Ok(p.previous)
|
Ok(p.previous)
|
||||||
}
|
}
|
||||||
|
/// Drain on replacement while calls are still available.
|
||||||
|
#[sausage_plugin::on_superseded]
|
||||||
|
async fn superseded(&self, p: Superseded) -> Result<()> {
|
||||||
|
call("other.superseded", json!({"current":p.current})).await?;
|
||||||
|
sausage_plugin::drain();
|
||||||
|
sausage_plugin::drain();
|
||||||
|
Ctx::default().notify("draining", json!({}))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
/// Wait until EOF, then run the stop hook.
|
/// Wait until EOF, then run the stop hook.
|
||||||
async fn wait(&self) -> Result<String> {
|
async fn wait(&self) -> Result<String> {
|
||||||
call("other.wait", json!({})).await?;
|
call("other.wait", json!({})).await?;
|
||||||
@@ -193,6 +208,11 @@ impl Control {
|
|||||||
ctx.notify("endpoint-progress", json!({"value":7}))?;
|
ctx.notify("endpoint-progress", json!({"value":7}))?;
|
||||||
Ok(7)
|
Ok(7)
|
||||||
}
|
}
|
||||||
|
/// Finish an in-flight call after draining begins.
|
||||||
|
async fn wait(&self) -> Result<String> {
|
||||||
|
call("other.inflight", json!({})).await?;
|
||||||
|
Ok("finished".into())
|
||||||
|
}
|
||||||
/// End the endpoint process after the response.
|
/// End the endpoint process after the response.
|
||||||
fn stop(&self, ctx: &Ctx) -> Result<()> {
|
fn stop(&self, ctx: &Ctx) -> Result<()> {
|
||||||
ctx.exit_after_response();
|
ctx.exit_after_response();
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
struct Supersedes;
|
||||||
|
#[sausage_plugin::rpc]
|
||||||
|
impl Supersedes {
|
||||||
|
/// First superseded hook.
|
||||||
|
#[sausage_plugin::on_superseded]
|
||||||
|
fn a(&self) -> sausage_plugin::Result<()> { Ok(()) }
|
||||||
|
/// Second superseded hook.
|
||||||
|
#[sausage_plugin::on_superseded]
|
||||||
|
fn b(&self) -> sausage_plugin::Result<()> { Ok(()) }
|
||||||
|
}
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: only one on_superseded method is allowed
|
||||||
|
--> tests/ui/superseded.rs:7:5
|
||||||
|
|
|
||||||
|
7 | /// Second superseded hook.
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
@@ -201,6 +201,88 @@ fn protocol() {
|
|||||||
assert_eq!(outgoing["method"], "other.background");
|
assert_eq!(outgoing["method"], "other.background");
|
||||||
p.answer(&outgoing, json!({}));
|
p.answer(&outgoing, json!({}));
|
||||||
assert!(p.child.wait().unwrap().success());
|
assert!(p.child.wait().unwrap().success());
|
||||||
|
// Replacement is a notification, not EOF. Drain keeps both endpoint handlers
|
||||||
|
// and background calls alive, while closing idle connections and the listener.
|
||||||
|
assert!(
|
||||||
|
!d["result"]["methods"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|m| m["name"] == "rpc.superseded" || m["name"] == "superseded")
|
||||||
|
);
|
||||||
|
std::fs::remove_file(dir.join("stopped")).unwrap();
|
||||||
|
let mut provider = start(&wasm, &dir);
|
||||||
|
provider.request("serve", json!({}));
|
||||||
|
assert_eq!(provider.read()["result"], Value::Null);
|
||||||
|
provider.request("start", json!({}));
|
||||||
|
let first = provider.read();
|
||||||
|
let second = provider.read();
|
||||||
|
let background = if first["method"] == "other.background" {
|
||||||
|
first
|
||||||
|
} else {
|
||||||
|
second
|
||||||
|
};
|
||||||
|
assert_eq!(background["method"], "other.background");
|
||||||
|
let address = std::fs::read_to_string(dir.join("endpoint"))
|
||||||
|
.unwrap()
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.trim_start_matches("tcp:")
|
||||||
|
.to_owned();
|
||||||
|
let mut idle = std::net::TcpStream::connect(&address).unwrap();
|
||||||
|
idle.set_read_timeout(Some(std::time::Duration::from_secs(4)))
|
||||||
|
.unwrap();
|
||||||
|
writeln!(
|
||||||
|
idle,
|
||||||
|
"{}",
|
||||||
|
json!({"jsonrpc":"2.0","id":0,"method":"rpc.endpoint","params":{}})
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut idle = BufReader::new(idle);
|
||||||
|
let mut line = String::new();
|
||||||
|
idle.read_line(&mut line).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
sausage_plugin::serde_json::from_str::<Value>(&line).unwrap()["id"],
|
||||||
|
0
|
||||||
|
);
|
||||||
|
let mut active = std::net::TcpStream::connect(&address).unwrap();
|
||||||
|
active
|
||||||
|
.set_read_timeout(Some(std::time::Duration::from_secs(4)))
|
||||||
|
.unwrap();
|
||||||
|
writeln!(
|
||||||
|
active,
|
||||||
|
"{}",
|
||||||
|
json!({"jsonrpc":"2.0","id":1,"method":"wait","params":{}})
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let inflight = provider.read();
|
||||||
|
assert_eq!(inflight["method"], "other.inflight");
|
||||||
|
provider
|
||||||
|
.send(json!({"jsonrpc":"2.0","method":"rpc.superseded","params":{"current":"new-hash"}}));
|
||||||
|
let hook = provider.read();
|
||||||
|
assert_eq!(hook["method"], "other.superseded");
|
||||||
|
assert_eq!(hook["params"]["current"], "new-hash");
|
||||||
|
provider.answer(&hook, json!({}));
|
||||||
|
assert_eq!(provider.read()["params"]["name"], "draining");
|
||||||
|
line.clear();
|
||||||
|
assert_eq!(idle.read_line(&mut line).unwrap(), 0);
|
||||||
|
assert!(std::net::TcpStream::connect(&address).is_err());
|
||||||
|
assert!(provider.child.try_wait().unwrap().is_none());
|
||||||
|
provider.answer(&inflight, json!({}));
|
||||||
|
line.clear();
|
||||||
|
BufReader::new(active).read_line(&mut line).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
sausage_plugin::serde_json::from_str::<Value>(&line).unwrap()["result"],
|
||||||
|
"finished"
|
||||||
|
);
|
||||||
|
assert!(provider.child.try_wait().unwrap().is_none());
|
||||||
|
provider.answer(&background, json!({}));
|
||||||
|
provider.finishes();
|
||||||
|
assert!(
|
||||||
|
!dir.join("stopped").exists(),
|
||||||
|
"drain is not the EOF stop hook"
|
||||||
|
);
|
||||||
let provider_doc = &d["result"];
|
let provider_doc = &d["result"];
|
||||||
let imports = target.join("imports");
|
let imports = target.join("imports");
|
||||||
std::fs::create_dir_all(&imports).unwrap();
|
std::fs::create_dir_all(&imports).unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user