Skip to content
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ jobs:
run: >-
soldr cargo run --locked --all-features
--bin kernal-tauri-smoke -- bootstrap
- name: Verify native Windows interactive window lifetime
if: runner.os == 'Windows'
timeout-minutes: 15
run: >-
soldr cargo run --locked --all-features
--bin kernal-tauri-smoke -- window-close
- name: Verify HTTP wire semantics under backend feature unification
# Run after locked checks: this deliberately resolves a backend feature
# outside our manifest's production graph and may update the lockfile.
Expand Down
46 changes: 44 additions & 2 deletions src/bin/kernal-tauri-smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ async fn lifecycle(
}
(SmokeScenario::Cancel, Ok(())) => {
webview.cancel();
if webview.wait_until_terminal(Duration::ZERO).await != Err(WebviewError::Cancelled) {
if webview.wait_for_terminal().await != Err(WebviewError::Cancelled) {
return Err(WebviewError::HostFailure(
"cancellation did not publish its typed terminal outcome".into(),
));
Expand All @@ -208,8 +208,50 @@ async fn lifecycle(
assert_clean(client)
}
(SmokeScenario::WindowClose, Ok(())) => {
for timed in [false, true] {
let pending = async {
if timed {
webview.wait_until_terminal(Duration::from_secs(30)).await
} else {
webview.wait_for_terminal().await
}
};
let mut pending = std::pin::pin!(pending);
if async_engine::timeout(Duration::from_millis(20), &mut pending)
.await
.is_ok()
{
return Err(WebviewError::HostFailure(
"interactive wait ended before window closure".into(),
));
}
if async_engine::timeout(Duration::from_secs(1), webview.wait_for_terminal())
.await
.map_err(|_| WebviewError::TimedOut)?
!= Err(WebviewError::TerminalWaitInProgress)
{
return Err(WebviewError::HostFailure(
"overlapping terminal wait was not rejected".into(),
));
}
if webview.wait_until_terminal(Duration::ZERO).await
!= Err(WebviewError::TerminalWaitInProgress)
{
return Err(WebviewError::HostFailure(
"overlapping timed wait was not rejected".into(),
));
}
}
let observation = client.test_observation();
if observation.native_backings != 1 || observation.live_resources != 1 {
return Err(WebviewError::HostFailure(format!(
"cancelled wait revoked its window: {observation:?}"
)));
}
webview.request_window_close_for_test()?;
if webview.wait_until_terminal(Duration::from_secs(5)).await
if async_engine::timeout(Duration::from_secs(5), webview.wait_for_terminal())
.await
.map_err(|_| WebviewError::TimedOut)?
!= Err(WebviewError::WindowClosed)
{
return Err(WebviewError::HostFailure(
Expand Down
55 changes: 49 additions & 6 deletions src/tauri.rs
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,9 @@ pub enum WebviewError {
WindowClosed,
#[error("the webview host failed: {0}")]
HostFailure(String),
/// Another timed or untimed terminal wait is currently pending.
#[error("a terminal webview wait is already active")]
TerminalWaitInProgress,
}

/// Semantic permissions for one external webview.
Expand Down Expand Up @@ -807,6 +810,17 @@ pub struct WebviewHandle {
store: u64,
resource: OpaqueToken,
terminal_operation: OpaqueToken,
terminal_wait_active: AtomicBool,
}

// The hub terminal operation is single-consumer. Admission belongs to the
// borrowed future so cancellation releases it without revoking the window.
struct TerminalWaitGuard<'a>(&'a AtomicBool);

impl Drop for TerminalWaitGuard<'_> {
fn drop(&mut self) {
self.0.store(false, Ordering::Release);
}
}

/// Acceptance-only semantic counters for the process-main-thread smoke test.
Expand Down Expand Up @@ -990,6 +1004,7 @@ impl ExternalWebviewClient {
store: self.store,
resource,
terminal_operation,
terminal_wait_active: AtomicBool::new(false),
})
}
Ok(Some(result)) => Err(map_terminal(result.terminal)),
Expand Down Expand Up @@ -1101,7 +1116,31 @@ impl WebviewHandle {
/// This is useful when an allowed top-level document finishes and then
/// attempts a prohibited redirect or popup. It is itself a hub-owned
/// operation, so callback completion never needs to retain a Store.
/// Only one terminal wait may be active; overlapping timed or untimed
/// waits return [`WebviewError::TerminalWaitInProgress`] without expiring
/// the window. Dropping the future releases that admission.
pub async fn wait_until_terminal(&self, timeout: Duration) -> Result<(), WebviewError> {
self.wait_terminal(Some(timeout)).await
}

/// Await user closure, cancellation, or a terminal host/security event
/// without imposing a lifetime deadline on an interactive window.
///
/// Owns no timer and does not poll periodically. Dropping this borrowed
/// future leaves the window alive; a subsequent wait observes retained
/// terminal state, including an event that arrived between waits. Dropping
/// or cancelling the handle still revokes the window. As with the timed
/// variant, normal user closure is reported as [`WebviewError::WindowClosed`].
/// Overlapping terminal waits return [`WebviewError::TerminalWaitInProgress`].
pub async fn wait_for_terminal(&self) -> Result<(), WebviewError> {
self.wait_terminal(None).await
}

async fn wait_terminal(&self, timeout: Option<Duration>) -> Result<(), WebviewError> {
self.terminal_wait_active
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.map_err(|_| WebviewError::TerminalWaitInProgress)?;
let _admission = TerminalWaitGuard(&self.terminal_wait_active);
// A cancellation or window callback may have completed this operation
// before the caller first awaits it. Poll first; if completion wins
// the short race before suspension, consume that typed terminal below
Expand All @@ -1120,12 +1159,16 @@ impl WebviewHandle {
.wait_external_operation(self.store, self.terminal_operation)
{
Ok(wake) => {
if async_engine::timeout(timeout, wake.notified())
.await
.is_err()
{
self.service
.revoke_with_terminal(self.resource, Terminal::TimedOut);
if let Some(timeout) = timeout {
if async_engine::timeout(timeout, wake.notified())
.await
.is_err()
{
self.service
.revoke_with_terminal(self.resource, Terminal::TimedOut);
}
} else {
wake.notified().await;
}
}
// Completion can race the poll above; the final observe below
Expand Down
Loading