From b3d095695283336e59ee80588484f98dd1c33a95 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Thu, 28 May 2026 14:16:41 +0100 Subject: [PATCH] Move host timer cleanup metadata into Rust --- crates/kernel/src/process.rs | 63 +++++++++++++++++ crates/kernel/src/wasm_api.rs | 40 +++++++++++ .../2026-05-20-rust-owned-host-logic-plan.md | 2 +- host/src/kernel-worker.ts | 68 +++++++++++++++---- host/test/process-wait-lifecycle.test.ts | 41 +++++++++++ 5 files changed, 199 insertions(+), 15 deletions(-) diff --git a/crates/kernel/src/process.rs b/crates/kernel/src/process.rs index 381e036e3..aa76360d3 100644 --- a/crates/kernel/src/process.rs +++ b/crates/kernel/src/process.rs @@ -212,6 +212,12 @@ pub struct ShmMapping { pub size: usize, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostTimerCleanup { + pub cancel_alarm: bool, + pub posix_timer_ids: Vec, +} + /// Per-thread state within a process. #[derive(Debug, Clone)] pub struct ThreadInfo { @@ -597,6 +603,27 @@ impl Process { Some(self.shm_mappings.swap_remove(idx)) } + /// Return host timer handles that should be cancelled for this process and + /// clear the Rust timer state that made them live. + pub fn take_host_timer_cleanup(&mut self) -> HostTimerCleanup { + let cancel_alarm = self.alarm_deadline_ns != 0 || self.alarm_interval_ns != 0; + self.alarm_deadline_ns = 0; + self.alarm_interval_ns = 0; + + let mut posix_timer_ids = Vec::new(); + for (timer_id, slot) in self.posix_timers.iter_mut().enumerate() { + if slot.is_some() { + posix_timer_ids.push(timer_id); + *slot = None; + } + } + + HostTimerCleanup { + cancel_alarm, + posix_timer_ids, + } + } + /// True if `tid` names the process's main thread. The main thread's TID /// equals the process PID (Linux convention) and is not tracked in /// [`Process::threads`]; per-thread signal state for the main thread lives @@ -1000,6 +1027,42 @@ mod tests { assert_eq!(proc.shm_mapping_at(0x20000), None); } + #[test] + fn host_timer_cleanup_drains_alarm_and_posix_timer_state() { + let mut proc = Process::new(1); + proc.alarm_deadline_ns = 10; + proc.alarm_interval_ns = 5; + proc.posix_timers.push(Some(PosixTimerState { + clock_id: 0, + sigev_signo: 14, + sigev_value: 0, + interval_sec: 0, + interval_nsec: 0, + value_sec: 1, + value_nsec: 0, + overrun: 0, + })); + proc.posix_timers.push(None); + proc.posix_timers.push(Some(PosixTimerState { + clock_id: 0, + sigev_signo: 15, + sigev_value: 0, + interval_sec: 1, + interval_nsec: 0, + value_sec: 1, + value_nsec: 0, + overrun: 0, + })); + + let cleanup = proc.take_host_timer_cleanup(); + + assert!(cleanup.cancel_alarm); + assert_eq!(cleanup.posix_timer_ids, alloc::vec![0, 2]); + assert_eq!(proc.alarm_deadline_ns, 0); + assert_eq!(proc.alarm_interval_ns, 0); + assert!(proc.posix_timers.iter().all(|slot| slot.is_none())); + } + #[test] fn spawn_child_basic_inherits_cwd_and_returns_pid() { use crate::process_table::ProcessTable; diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index 334eacb6b..dad3572bf 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -9091,6 +9091,46 @@ pub extern "C" fn kernel_timer_delete(timerid: i32) -> i32 { 0 } +/// Drain the process-owned timer cleanup list for host-side timer handles. +/// +/// Writes `{ u32 cancel_alarm, u32 posix_count, u32 timer_ids[posix_count] }` +/// to `out_ptr`, clears the Rust timer state, and returns `posix_count`. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_take_process_timer_cleanup( + pid: u32, + out_ptr: *mut u8, + max_timer_ids: u32, +) -> i32 { + if out_ptr.is_null() { + return -(Errno::EFAULT as i32); + } + + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let Some(proc) = table.get_mut(pid) else { + return -(Errno::ESRCH as i32); + }; + + let timer_count = proc + .posix_timers + .iter() + .filter(|slot| slot.is_some()) + .count(); + if timer_count > max_timer_ids as usize { + return -(Errno::EINVAL as i32); + } + + let cleanup = proc.take_host_timer_cleanup(); + let out_len = 8 + cleanup.posix_timer_ids.len() * 4; + let out = unsafe { core::slice::from_raw_parts_mut(out_ptr, out_len) }; + out[0..4].copy_from_slice(&(cleanup.cancel_alarm as u32).to_le_bytes()); + out[4..8].copy_from_slice(&(cleanup.posix_timer_ids.len() as u32).to_le_bytes()); + for (idx, timer_id) in cleanup.posix_timer_ids.iter().enumerate() { + let offset = 8 + idx * 4; + out[offset..offset + 4].copy_from_slice(&(*timer_id as u32).to_le_bytes()); + } + cleanup.posix_timer_ids.len() as i32 +} + /// Called by the host when a repeating POSIX timer fires to increment the overrun counter. /// This is used for timer_getoverrun() support. #[unsafe(no_mangle)] diff --git a/docs/plans/2026-05-20-rust-owned-host-logic-plan.md b/docs/plans/2026-05-20-rust-owned-host-logic-plan.md index fb9512d5a..643033c86 100644 --- a/docs/plans/2026-05-20-rust-owned-host-logic-plan.md +++ b/docs/plans/2026-05-20-rust-owned-host-logic-plan.md @@ -114,7 +114,7 @@ path. | Done / PR #534 | Rust-owned syscall marshalling descriptors | `crates/shared::host_abi` owns simple pointer-argument descriptors; `dump-abi` generates `SYSCALL_ARGS`; TS host keeps memory copies but reads generated descriptors. | The old TS `SYSCALL_ARGS` table and syscall-number size switches are gone. `poll`/`ppoll`, SysV message prefix, `semop`, and `msgrcv` copy-back adjustments are metadata fields. Nested-pointer syscalls (`readv`/`writev`/preadv/pwritev) stay on dedicated TS paths. | Shared unit tests for descriptor ordering/high-risk sizes/nested-pointer exclusion; xtask ABI tests; `bash scripts/check-abi-version.sh`; generated ABI vitest; host build; kernel lib tests. | | Done / PR #534 follow-up | Extended host-visible syscall numbers and names | Add Rust/shared metadata for ABI-visible syscall numbers still hardcoded in host TS but not currently in `shared::Syscall`, such as `getrandom`, `clone`, `futex`, `ppoll`, `pselect6`, epoll, `exit_group`, `waitid`, `msync`, preadv/pwritev, mqueue, SysV IPC, `sched_yield`, `fallocate`, timers, and `thread_cancel`. Generate TS bindings, logging names, and snapshot coverage. | Host TS no longer defines literal syscall numbers for this set, and syscall trace names are generated from Rust-owned metadata. Existing `HOST_INTERCEPTED_SYSCALLS` remains separate for fork/exec/spawn because those are caught before normal dispatch. Public behavior unchanged. | Rust metadata uniqueness tests; xtask compatibility tests; `bash scripts/check-abi-version.sh update` + check; generated ABI vitest; host build; kernel lib tests. | | Done / stacked PR | Rust-defined host adapter manifest | Add a compact Rust-defined manifest describing ABI version, required host adapter protocol version, required/optional exports, worker protocol features, and channel metadata. JS validates it during kernel boot. | Boot fails earlier with clear errors when the host/kernel contract is incompatible. No Worker creation or Wasm instantiation moves out of JS. | Rust manifest serialization tests; ABI snapshot check; vitest boot validation cases; Node/browser worker-entry smoke if boot code changes. | -| In progress / stacked PR | Process lifecycle cleanup consolidation | Rust `ProcessTable` now owns parent lookup, wait-target matching, wait-status derivation, host-crash zombie marking, authorized child reaping, thread-exit clear-tid metadata, SysV shared-memory attachment metadata, and host-bridged TCP listener target policy. TS keeps blocked waiter queues, Worker/memory cleanup, platform timers, process-memory writes/futex wakeups, and the actual TCP server objects because those are host primitives. Remaining audit: thread channel/Worker allocation and free-list lifecycle plus host timer cancellation. | Kernel owns process lifecycle invariants that do not require Worker identity; JS owns Worker termination, memory objects, crash observation, and platform callbacks. | ProcessTable unit tests; fork/exec/spawn/clone/wait tests; crash/trap tests; browser parity smoke when worker entries change. | +| In progress / stacked PR | Process lifecycle cleanup consolidation | Rust `ProcessTable` now owns parent lookup, wait-target matching, wait-status derivation, host-crash zombie marking, authorized child reaping, thread-exit clear-tid metadata, SysV shared-memory attachment metadata, host-bridged TCP listener target policy, and process-owned host timer cleanup metadata. TS keeps blocked waiter queues, Worker/memory cleanup, platform timer handles, process-memory writes/futex wakeups, and the actual TCP server objects because those are host primitives. Remaining audit: thread channel/Worker allocation and free-list lifecycle. | Kernel owns process lifecycle invariants that do not require Worker identity; JS owns Worker termination, memory objects, crash observation, and platform callbacks. | ProcessTable unit tests; fork/exec/spawn/clone/wait tests; crash/trap tests; browser parity smoke when worker entries change. | | In progress / stacked PR | IPC/resource cleanup in Rust | Rust `Process` now records `shmat` address -> segment metadata, inherits it through fork, clears it across exec setup, and detaches live mappings from `remove_process()`. TS still copies bytes between guest memory and kernel SysV segments because only the host can address guest `Memory`. | `remove_process()` owns IPC attachment cleanup; JS only handles guest-memory transfer and host primitive wake/schedule work. | SysV IPC and mqueue Rust tests plus host integration/e2e coverage for blocking and cleanup. | | Planned | Readiness metadata improvements | Replace broad host inference with kernel-emitted readiness events for pipe/socket/poll/select cases where the kernel already knows state changes. | JS still owns timers/retry queues/`Atomics.waitAsync`, but readiness decisions are less inferred from syscall numbers. No extra Wasm round trip per syscall. | Pipe/socket/poll/select/ppoll/pselect tests; browser bridge smoke for affected wake paths; performance comparison before removing broad fallback logic. | | Planned | VFS policy split | Keep backend I/O, OPFS/IndexedDB/fetch, Node `fs`, and lazy archive materialization in JS. Move permission and policy decisions into Rust where process uid/gid/umask/fd context is authoritative. | Guest-visible policy is enforced in Rust; host adapters only perform platform operations requested through a checked contract. | VFS unit tests, uid/gid/permission tests, host-fs metadata tests, default mount tests, Node/browser parity tests. | diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index c65dc62d9..c3149fc54 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -1397,20 +1397,7 @@ export class CentralizedKernelWorker { this.processes.delete(pid); this.stdinFinite.delete(pid); this.stdinBuffers.delete(pid); - // Cancel any pending alarm timer for this process - const alarmTimer = this.alarmTimers.get(pid); - if (alarmTimer) { - clearTimeout(alarmTimer); - this.alarmTimers.delete(pid); - } - // Cancel any pending posix timers for this process - for (const [key, entry] of this.posixTimers) { - if (key.startsWith(`${pid}:`)) { - clearTimeout(entry.timeout); - if (entry.interval) clearInterval(entry.interval); - this.posixTimers.delete(key); - } - } + this.cancelProcessHostTimers(pid); // Cancel any pending sleep timer for this process const sleepTimer = this.pendingSleeps.get(pid); if (sleepTimer) { @@ -1431,6 +1418,59 @@ export class CentralizedKernelWorker { this.hostReaped.delete(pid); } + private cancelProcessHostTimers(pid: number): void { + const cleanup = this.takeRustProcessTimerCleanup(pid); + if (cleanup !== undefined) { + if (cleanup.cancelAlarm) { + const alarmTimer = this.alarmTimers.get(pid); + if (alarmTimer) clearTimeout(alarmTimer); + this.alarmTimers.delete(pid); + } + for (const timerId of cleanup.posixTimerIds) { + const key = `${pid}:${timerId}`; + const entry = this.posixTimers.get(key); + if (entry) { + clearTimeout(entry.timeout); + if (entry.interval) clearInterval(entry.interval); + this.posixTimers.delete(key); + } + } + return; + } + + const alarmTimer = this.alarmTimers.get(pid); + if (alarmTimer) { + clearTimeout(alarmTimer); + this.alarmTimers.delete(pid); + } + for (const [key, entry] of this.posixTimers) { + if (key.startsWith(`${pid}:`)) { + clearTimeout(entry.timeout); + if (entry.interval) clearInterval(entry.interval); + this.posixTimers.delete(key); + } + } + } + + private takeRustProcessTimerCleanup(pid: number): { cancelAlarm: boolean; posixTimerIds: number[] } | undefined { + const takeCleanup = this.kernelInstance!.exports.kernel_take_process_timer_cleanup as + ((pid: number, outPtr: bigint, maxTimerIds: number) => number) | undefined; + if (!takeCleanup) return undefined; + + const maxTimerIds = Math.floor((SCRATCH_SIZE - 8) / 4); + const result = takeCleanup(pid, BigInt(this.scratchOffset), maxTimerIds); + if (result < 0) return undefined; + + const view = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + const cancelAlarm = view.getUint32(0, true) !== 0; + const count = view.getUint32(4, true); + const posixTimerIds: number[] = []; + for (let i = 0; i < count; i++) { + posixTimerIds.push(view.getUint32(8 + i * 4, true)); + } + return { cancelAlarm, posixTimerIds }; + } + /** * Run kernel-side exec setup: close CLOEXEC fds, reset signal handlers. * Returns 0 on success, negative errno on failure. diff --git a/host/test/process-wait-lifecycle.test.ts b/host/test/process-wait-lifecycle.test.ts index 9b6b97d25..5dcf1f05e 100644 --- a/host/test/process-wait-lifecycle.test.ts +++ b/host/test/process-wait-lifecycle.test.ts @@ -219,6 +219,47 @@ describe("Rust-owned process wait lifecycle", () => { expect(worker.pickListenerTarget(8080)).toEqual({ pid: 44, fd: 7 }); expect(pickTarget).toHaveBeenCalledWith(8080, 0, BigInt(worker.scratchOffset)); }); + + it("host timer cancellation follows Rust-owned cleanup metadata", () => { + vi.useFakeTimers(); + try { + const kernelMemory = createSharedMemory(); + const takeCleanup = vi.fn((_pid: number, outPtr: bigint, _maxTimerIds: number) => { + const view = new DataView(kernelMemory.buffer); + view.setUint32(Number(outPtr), 1, true); + view.setUint32(Number(outPtr) + 4, 2, true); + view.setUint32(Number(outPtr) + 8, 4, true); + view.setUint32(Number(outPtr) + 12, 8, true); + return 2; + }); + const worker = createWorkerHarness({ + kernel_take_process_timer_cleanup: takeCleanup, + }); + worker.kernelMemory = kernelMemory; + worker.alarmTimers = new Map([ + [11, setTimeout(() => {}, 1000)], + [12, setTimeout(() => {}, 1000)], + ]); + worker.posixTimers = new Map([ + ["11:4", { timeout: setTimeout(() => {}, 1000) }], + ["11:8", { timeout: setTimeout(() => {}, 1000), interval: setInterval(() => {}, 1000) }], + ["11:9", { timeout: setTimeout(() => {}, 1000) }], + ["12:4", { timeout: setTimeout(() => {}, 1000) }], + ]); + + worker.cancelProcessHostTimers(11); + + expect(takeCleanup).toHaveBeenCalledWith(11, BigInt(worker.scratchOffset), expect.any(Number)); + expect(worker.alarmTimers.has(11)).toBe(false); + expect(worker.alarmTimers.has(12)).toBe(true); + expect(worker.posixTimers.has("11:4")).toBe(false); + expect(worker.posixTimers.has("11:8")).toBe(false); + expect(worker.posixTimers.has("11:9")).toBe(true); + expect(worker.posixTimers.has("12:4")).toBe(true); + } finally { + vi.useRealTimers(); + } + }); }); function createWorkerHarness(exports: Record): any {