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
Original file line number Diff line number Diff line change
Expand Up @@ -1494,6 +1494,62 @@ test('waits passively for a Host that cannot be taken over', async () => {
await owner.close();
});

test('offers to stop an exact local ephemeral Host before retrying startup', async () => {
const observed = incompatibleHost('blocked_by_residency');
const conflict = {
...observed,
registration: { ...observed.registration, lifecycleMode: 'ephemeral' as const },
processIdentity: {
startIdentity: 'darwin:1700000000:123456',
},
handshake: {
...observed.handshake,
activity: {
connections: 0,
activeOperations: 0,
processUptimeSeconds: 60,
residencies: [],
},
},
};
const replacement = candidateHarness();
let starts = 0;
let prompts = 0;
let terminations = 0;
const owner = await startRuntimeHostDesktopManager(
{ rootPath: '/workspace' } as DesktopRuntimeHostCandidateStartInput,
{
startCandidate: async () => {
starts += 1;
return starts === 1 ? conflict : ready(replacement.candidate);
},
upgradePrompts: {
restartable: async () => assert.fail('incompatible Host used restart prompt'),
nonRestartable: async (_conflict, action) => {
prompts += 1;
assert.equal(action, 'replace_may_interrupt_work');
return 'replace';
},
},
forceTerminateObservedHost: async (identity, authority) => {
terminations += 1;
assert.deepEqual(identity, {
rootPath: '/workspace',
registration: conflict.registration,
});
assert.deepEqual(authority.processIdentity, conflict.processIdentity);
assert.equal(authority.isCurrent(), true);
return true;
},
},
);

assert.equal(prompts, 1, 'even an idle snapshot must not authorize a forced stop');
assert.equal(terminations, 1);
assert.equal(starts, 2);
await owner.close();
});

test('silently replaces an idle non-restartable Local Host and retries', async () => {
const observed = upgradeRequired(true);
const conflict = {
Expand Down Expand Up @@ -1667,7 +1723,7 @@ test('lets the user cancel startup when an incompatible Host owns the root', asy

function incompatibleHost(
replacement: 'wait_for_idle_exit' | 'blocked_by_residency',
): DesktopRuntimeHostCandidateStartResult {
): Extract<DesktopRuntimeHostCandidateStartResult, { kind: 'incompatible' }> {
return {
kind: 'incompatible',
registration: hostRegistration({ compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1 }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ test('maps the non-default replacement choice to the replace decision', async ()
const prompts = createRuntimeHostUpgradePrompts(
async () => 'en',
async (options) => {
assert.deepEqual(options.buttons, ['Stop Host and Continue', 'Cancel Startup']);
assert.equal(options.defaultId, 1);
assert.equal(options.cancelId, 1);
assert.deepEqual(options.buttons, ['Stop Host and Continue', 'Wait', 'Cancel Startup']);
assert.equal(options.defaultId, 2);
assert.equal(options.cancelId, 2);
assert.match(options.detail ?? '', /Maka will stop this Host/);
return { response: 0, checkboxChecked: false };
},
Expand All @@ -85,7 +85,7 @@ test('maps the non-default replacement choice to the replace decision', async ()
{
kind: 'upgrade_required',
restartable: false,
registration: { pid: 42 },
registration: { pid: 42, lifecycleMode: 'ephemeral' },
} as never,
'replace_may_interrupt_work',
),
Expand Down
44 changes: 43 additions & 1 deletion apps/desktop/src/main/runtime-host-desktop-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { randomUUID } from 'node:crypto';
import type { BotIncomingMessage } from '@maka/runtime/bots';
import {
abortable,
forceTerminateObservedRegisteredRuntimeHost,
forceTerminateRegisteredRuntimeHost,
RuntimeHostOperationError,
RuntimeHostPermanentReconnectError,
Expand Down Expand Up @@ -247,6 +248,7 @@ export async function startRuntimeHostDesktopManager(
upgradePrompts?: RuntimeHostUpgradePrompts;
waitForHostExit?: (pid: number) => Promise<void>;
forceTerminateHost?: typeof forceTerminateRegisteredRuntimeHost;
forceTerminateObservedHost?: typeof forceTerminateObservedRegisteredRuntimeHost;
waitForHostRetirement?: (
registration: HostRegistration,
signal: AbortSignal,
Expand All @@ -271,6 +273,7 @@ export async function startRuntimeHostDesktopManager(
options.upgradePrompts,
options.waitForHostExit ?? waitForProcessExit,
options.forceTerminateHost ?? forceTerminateRegisteredRuntimeHost,
options.forceTerminateObservedHost ?? forceTerminateObservedRegisteredRuntimeHost,
options.waitForHostRetirement ?? waitForProcessRetirement,
options.resolveLocalHostReplacement,
options.recoverLocalHost,
Expand Down Expand Up @@ -310,6 +313,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
private readonly upgradePrompts: RuntimeHostUpgradePrompts | undefined,
private readonly waitForHostExit: (pid: number) => Promise<void>,
private readonly forceTerminateHost: typeof forceTerminateRegisteredRuntimeHost,
private readonly forceTerminateObservedHost: typeof forceTerminateObservedRegisteredRuntimeHost,
private readonly waitForHostRetirement: (
registration: HostRegistration,
signal: AbortSignal,
Expand Down Expand Up @@ -1138,7 +1142,8 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
) {
const replacement = target.input.profileTarget
? undefined
: await this.resolveLocalHostReplacement?.(result.registration, signal);
: this.#registeredEphemeralHostReplacement(target, result, signal) ??
(await this.resolveLocalHostReplacement?.(result.registration, signal));
const activity = result.handshake?.activity;
if (replacement && activity && isHostActivityIdle(activity)) {
// Only a complete, observed snapshot can authorize silent
Expand Down Expand Up @@ -1184,6 +1189,43 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
}
}

#registeredEphemeralHostReplacement(
target: DesktopRuntimeHostTargetGeneration,
conflict: RuntimeHostWaitConflict,
signal: AbortSignal,
): RuntimeHostLocalReplacement | undefined {
const { registration, processIdentity } = conflict;
if (registration.lifecycleMode !== 'ephemeral' || !processIdentity) return undefined;
const stillAuthorized = () =>
!this.#closed &&
!signal.aborted &&
target.valid &&
this.#targets.get(target.target.profile.id) === target;
return {
replace: async (activeWorkPolicy) => {
// This Host cannot participate in the current retirement protocol, so
// an earlier idle snapshot cannot prove that it remains idle. Require
// explicit consent before using the identity-fenced termination path.
if (activeWorkPolicy === 'refuse_active_work') return 'active_tasks';
signal.throwIfAborted();
const terminated = await this.forceTerminateObservedHost(
{
rootPath: this.#baseInput.rootPath,
registration,
},
{ processIdentity, isCurrent: stillAuthorized },
);
signal.throwIfAborted();
if (!terminated) {
throw new RuntimeHostPermanentReconnectError(
'The older Runtime Host changed before it could be stopped safely',
);
}
return 'replaced';
},
};
}

#resolveRestartable(
conflict: RuntimeHostRestartableConflict,
): Promise<RuntimeHostRestartDecision> {
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/main/runtime-host-upgrade-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ export function buildRuntimeHostUpgradeDialog(
: undefined;
const canWait =
availability === 'wait' ||
(availability === 'restart' && conflict.registration.lifecycleMode !== 'service');
(availability === 'restart' && conflict.registration.lifecycleMode !== 'service') ||
(availability === 'replace_may_interrupt_work' &&
conflict.registration.lifecycleMode === 'ephemeral');
if (action) {
choices.push({
label: action === 'restart' ? copy.restart : copy.replace,
Expand Down
2 changes: 2 additions & 0 deletions native/runtime-host-peer/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

mod bindings;
mod engine;
mod process_identity;
mod webrtc_direct;
#[cfg(target_os = "windows")]
mod windows_lifecycle;
Expand All @@ -29,6 +30,7 @@ pub use bindings::{
PeerTransitSnapshot, StartPeerEndpointOptions, ensure_peer_identity, sign_peer_identity,
start_peer_endpoint, verify_peer_identity,
};
pub use process_identity::read_process_start_identity;
#[cfg(target_os = "windows")]
pub use windows_lifecycle::{
WindowsTaskStatus, own_current_process_tree, windows_task_activate, windows_task_converge,
Expand Down
176 changes: 176 additions & 0 deletions native/runtime-host-peer/src/process_identity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

use napi_derive::napi;

/// Returns an opaque identifier for one OS process lifetime. Unlike a command
/// line, this value cannot be forged accidentally by an argument or path.
#[napi]
pub fn read_process_start_identity(pid: u32) -> Option<String> {
if pid == 0 {
return None;
}
read_platform_process_start_identity(pid)
}

#[cfg(target_os = "linux")]
fn read_platform_process_start_identity(pid: u32) -> Option<String> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let boot_id = std::fs::read_to_string("/proc/sys/kernel/random/boot_id").ok()?;
let boot_id = boot_id.trim();
if boot_id.is_empty()
|| !boot_id
.bytes()
.all(|byte| byte.is_ascii_hexdigit() || byte == b'-')
{
return None;
}
let start_ticks = linux_process_start_ticks(&stat)?;
Some(format!("linux:{boot_id}:{start_ticks}"))
}

#[cfg(target_os = "linux")]
fn linux_process_start_ticks(stat: &str) -> Option<&str> {
// `comm` (field 2) may contain spaces and parentheses. The final `)` is
// the only safe boundary before field 3; starttime is field 22.
let mut fields = stat.get(stat.rfind(')')? + 1..)?.split_ascii_whitespace();
let start_ticks = fields.nth(19)?;
(!start_ticks.is_empty() && start_ticks.bytes().all(|byte| byte.is_ascii_digit()))
.then_some(start_ticks)
}

#[cfg(target_os = "macos")]
fn read_platform_process_start_identity(pid: u32) -> Option<String> {
use std::mem::{MaybeUninit, size_of};

const PROC_PIDTBSDINFO: i32 = 3;

#[repr(C)]
struct ProcBsdInfo {
pbi_flags: u32,
pbi_status: u32,
pbi_xstatus: u32,
pbi_pid: u32,
pbi_ppid: u32,
pbi_uid: u32,
pbi_gid: u32,
pbi_ruid: u32,
pbi_rgid: u32,
pbi_svuid: u32,
pbi_svgid: u32,
rfu_1: u32,
pbi_comm: [u8; 16],
pbi_name: [u8; 32],
pbi_nfiles: u32,
pbi_pgid: u32,
pbi_pjobc: u32,
e_tdev: u32,
e_tpgid: u32,
pbi_nice: i32,
pbi_start_tvsec: u64,
pbi_start_tvusec: u64,
}

unsafe extern "C" {
fn proc_pidinfo(
pid: i32,
flavor: i32,
arg: u64,
buffer: *mut core::ffi::c_void,
buffer_size: i32,
) -> i32;
}

let mut info = MaybeUninit::<ProcBsdInfo>::zeroed();
let expected_size = size_of::<ProcBsdInfo>();
// SAFETY: `info` points to writable storage of exactly the size passed to
// libproc. The value is read only when libproc reports a complete record.
let read_size = unsafe {
proc_pidinfo(
pid as i32,
PROC_PIDTBSDINFO,
0,
info.as_mut_ptr().cast(),
expected_size as i32,
)
};
if read_size != expected_size as i32 {
return None;
}
// SAFETY: the successful full-size call initialized every byte.
let info = unsafe { info.assume_init() };
if info.pbi_pid != pid || info.pbi_start_tvsec == 0 || info.pbi_start_tvusec >= 1_000_000 {
return None;
}
Some(format!(
"darwin:{}:{}",
info.pbi_start_tvsec, info.pbi_start_tvusec
))
}

#[cfg(target_os = "windows")]
fn read_platform_process_start_identity(pid: u32) -> Option<String> {
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use windows::Win32::{
Foundation::{FILETIME, HANDLE},
System::Threading::{GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION},
};

// SAFETY: the returned valid handle is immediately placed under RAII.
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }.ok()?;
// SAFETY: ownership of the newly opened handle is transferred exactly once.
let owned = unsafe { OwnedHandle::from_raw_handle(handle.0) };
let handle = HANDLE(owned.as_raw_handle());
let mut created = FILETIME::default();
let mut exited = FILETIME::default();
let mut kernel = FILETIME::default();
let mut user = FILETIME::default();
// SAFETY: the handle remains live and all four output pointers are valid.
unsafe { GetProcessTimes(handle, &mut created, &mut exited, &mut kernel, &mut user) }.ok()?;
let ticks = (u64::from(created.dwHighDateTime) << 32) | u64::from(created.dwLowDateTime);
(ticks != 0).then(|| format!("windows:{ticks}"))
}

#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn read_platform_process_start_identity(_pid: u32) -> Option<String> {
None
}

#[cfg(test)]
mod common_tests {
use super::read_process_start_identity;

#[test]
fn current_process_identity_is_stable() {
let first = read_process_start_identity(std::process::id());
assert!(first.is_some());
assert_eq!(read_process_start_identity(std::process::id()), first);
}
}

#[cfg(all(test, target_os = "linux"))]
mod linux_tests {
use super::linux_process_start_ticks;

#[test]
fn parses_start_ticks_after_a_hostile_process_name() {
let stat = "42 (workspace --startup-attempt-id 00000000-0000-4000-8000-000000000001) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 98765 20";
assert_eq!(linux_process_start_ticks(stat), Some("98765"));
}
}
Loading
Loading