diff --git a/docs/m17-m18-plan.md b/docs/m17-m18-plan.md index 0d38428..e3e0efe 100644 --- a/docs/m17-m18-plan.md +++ b/docs/m17-m18-plan.md @@ -35,7 +35,10 @@ only when its definition of green holds. - [x] **M17.2** — exit reasons (`ExitReason` recorded at exit/fault/kill before the notification; `process_exit_reason` supervisor-gated; `runtime.process.exitReason`; kernel + ring-3 assertions; suite 49/49) -- [ ] **M17.3** — published exit events + VFS subscriber +- [x] **M17.3** — published exit events + VFS subscriber (`process_subscribe`, + bounded ref-counted table, publish on every death; + `runtime.process.subscribeExits`; VFS handles carry owners and are swept on + the owner's death; `vfs-client-death` test; suite 50/50) - [ ] **M17.4** — signals, timer notifications, `runtime.process`, the service harness - [ ] **merge** `feat/process-lifecycle` → main, push - [ ] **M18.1** — device-manager protocol: hello + restart policy (branch `feat/device-manager`) diff --git a/library/runtime/process.zig b/library/runtime/process.zig index d0f66db..064e50f 100644 --- a/library/runtime/process.zig +++ b/library/runtime/process.zig @@ -66,3 +66,13 @@ pub fn exitReason(id: u32) ?ExitReason { if (r > ~@as(usize, 0) - 4095) return null; // a wrapped -errno return @enumFromInt(r); } + +/// Subscribe `endpoint` to published exit events: every process death posts an +/// asynchronous notification with the same badge encoding as a supervisor's exit +/// notice (decode with `ipc.Received.isChildExit`/`childProcessId`). For stateful +/// services: release what the dead client held — file handles, subscriptions — +/// because a service must never depend on clients cleaning up after themselves +/// (docs/process-lifecycle.md). Ungated, like `system.processes`. +pub fn subscribeExits(endpoint: usize) bool { + return sc.systemCall1(.process_subscribe, endpoint) == 0; +} diff --git a/system/abi.zig b/system/abi.zig index c3c4d97..95a9a0d 100644 --- a/system/abi.zig +++ b/system/abi.zig @@ -54,6 +54,7 @@ pub const SystemCall = enum(u64) { process_kill = 25, // process_kill(id) -> 0/-errno: end a process this process spawned ipc_send = 26, // ipc_send(handle, message_ptr, message_len) -> 0/-errno: post a payload to an endpoint's async queue without blocking process_exit_reason = 27, // process_exit_reason(id) -> ExitReason/-errno: how a dead child ended (its supervisor only) + process_subscribe = 28, // process_subscribe(endpoint) -> 0/-errno: subscribe to published exit events — every death posts a notification _, }; diff --git a/system/kernel/process.zig b/system/kernel/process.zig index 5896c74..9b2662c 100644 --- a/system/kernel/process.zig +++ b/system/kernel/process.zig @@ -201,6 +201,7 @@ fn system_call(state: *architecture.CpuState) void { .process_enumerate => systemProcessEnumerate(state), .process_kill => systemProcessKill(state), .process_exit_reason => systemProcessExitReason(state), + .process_subscribe => systemProcessSubscribe(state), _ => fail(state), } } @@ -574,6 +575,16 @@ fn releaseTaskResourcesLocked(t: *scheduler.Task) void { recordExitLocked(t); irq.releaseOwner(t.id); devices_broker.releaseAllOwnedBy(t.id); + // A dead subscriber's own subscriptions go first: it must not hear about + // itself, and the slots' endpoint references drop with it. + for (&exit_subscribers) |*slot| { + if (slot.*) |subscriber| { + if (subscriber.owner == t.id) { + ipc.dropRef(subscriber.endpoint); + slot.* = null; + } + } + } if (t.ipc_client) |client| { t.ipc_client = null; client.ipc_status = -ipc.EPEER; @@ -583,6 +594,12 @@ fn releaseTaskResourcesLocked(t: *scheduler.Task) void { scheduler.removeFromWaitQueueLocked(t); scheduler.forgetIpcClientLocked(t); ipc.closeHandles(t); + // Publish the exit to every subscriber (docs/process-lifecycle.md): the same + // badge encoding as the supervisor's notification, and equally late, so a + // subscriber also observes a fully-released child. + for (&exit_subscribers) |*slot| { + if (slot.*) |subscriber| ipc.notifyLocked(subscriber.endpoint, abi.notify_exit_bit | t.id); + } if (t.exit_endpoint) |raw| { const endpoint: *ipc.Endpoint = @ptrCast(@alignCast(raw)); t.exit_endpoint = null; @@ -690,6 +707,34 @@ pub fn exitReasonOf(caller_id: u32, target_id: u32) i64 { return -ipc.ESRCH; } +/// The published exit events' subscribers (docs/process-lifecycle.md "Who learns +/// of a death"): stateful services — the VFS's file handles, input's +/// subscriptions — that must release what a dead client held and cannot learn it +/// any other way (a client that simply never calls again looks like silence). +/// Bounded like every kernel table; each entry holds its own endpoint reference. +const exit_subscriber_capacity = 8; +const ExitSubscriber = struct { endpoint: *ipc.Endpoint, owner: u32 }; +var exit_subscribers: [exit_subscriber_capacity]?ExitSubscriber = .{null} ** exit_subscriber_capacity; + +/// process_subscribe(endpoint): subscribe the caller's endpoint to published exit +/// events. Ungated, like process_enumerate — what is running (and dying) is not a +/// secret between cooperating processes. -ENOSPC when the table is full. +fn systemProcessSubscribe(state: *architecture.CpuState) void { + const t = scheduler.current(); + if (t.aspace == 0) return fail(state); + const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF); + const flags = sync.enter(); + defer sync.leave(flags); + for (&exit_subscribers) |*slot| { + if (slot.* == null) { + endpoint.refcount += 1; // the slot's own reference, dropped on unsubscribe-by-death + slot.* = .{ .endpoint = endpoint, .owner = t.id }; + return architecture.setSystemCallResult(state, 0); + } + } + failErr(state, ipc.ENOSPC); +} + fn systemProcessExitReason(state: *architecture.CpuState) void { const t = scheduler.current(); if (t.aspace == 0) return fail(state); diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index 38bdf98..d289039 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -134,6 +134,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void { supervisionTest(boot_information); } else if (eql(case, "claim-release")) { claimReleaseTest(boot_information); + } else if (eql(case, "vfs-client-death")) { + vfsClientDeathTest(boot_information); } else if (eql(case, "initial-ramdisk")) { initialRamdiskTest(boot_information); } else if (eql(case, "vfs")) { @@ -1535,6 +1537,74 @@ fn claimReleaseTest(boot_information: *const BootInformation) void { result(); } +/// M17.3: the published exit events, proven by their first subscriber. The VFS +/// subscribes at startup; a client opens a file and parks holding the handle; +/// the kill posts the exit event to the VFS's endpoint; the VFS releases the +/// dead client's handle and says so — the service-side mirror of iron rule 1 +/// (a service must never depend on clients cleaning up after themselves). +fn vfsClientDeathTest(boot_information: *const BootInformation) void { + log("DANOS-TEST-BEGIN: vfs-client-death\n", .{}); + if (boot_information.initial_ramdisk_len == 0) { + check("bootloader handed over an initial_ramdisk", false); + result(); + return; + } + const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len]; + const rd = initial_ramdisk.Reader.init(image) orelse { + check("initial_ramdisk image is valid", false); + result(); + return; + }; + + process.write_count = 0; + check("vfs spawned", spawnNamed(rd, "vfs")); + + const me = scheduler.currentId(); + const endpoint = ipcsync.createIpcEndpoint() orelse { + check("exit endpoint allocated", false); + result(); + return; + }; + var client: u32 = 0; + var i: u32 = 0; + while (i < rd.count) : (i += 1) { + const item = rd.entry(i) orelse continue; + if (!eql(item.name, "vfs-test")) continue; + client = process.spawnProcessSupervised(item.blob, 4, &.{ "vfs-test", "park" }, me, endpoint) catch 0; + break; + } + check("parked client spawned (supervised)", client != 0); + + // Its heartbeat is the fence: once it beats, the handle is open. + const parked = "vfstest: parked"; + scheduler.setPriority(1); + var deadline = architecture.millis() + 10000; + while (architecture.millis() < deadline) { + if (process.write_len >= parked.len and eql(process.write_buffer[0..parked.len], parked)) break; + scheduler.yield(); + } + scheduler.setPriority(4); + check("client parked holding an open handle", process.write_len >= parked.len and eql(process.write_buffer[0..parked.len], parked)); + + check("the kill is accepted", process.killProcess(me, client) == 0); + var badge: u64 = 0; + var received_cap: u64 = 0; + _ = ipcsync.replyWait(endpoint, 0, 0, 0, 0, abi.no_cap, &badge, &received_cap); + check("the exit notification arrived", badge == abi.notify_badge_bit | abi.notify_exit_bit | client); + + // The VFS heard the same published event; its release line is the proof. + const released = "vfs: released 1 handle(s) for dead client"; + scheduler.setPriority(1); + deadline = architecture.millis() + 10000; + while (architecture.millis() < deadline) { + if (process.write_len >= released.len and eql(process.write_buffer[0..released.len], released)) break; + scheduler.yield(); + } + scheduler.setPriority(4); + check("the VFS released the dead client's handle", process.write_len >= released.len and eql(process.write_buffer[0..released.len], released)); + result(); +} + /// The whole user-side surface at once: spawn process-test's supervisor role, /// which — entirely from ring 3 — creates an exit endpoint, spawns its two /// children supervised, sees them in process_enumerate, kills them (one blocked, diff --git a/system/services/vfs/vfs-test.zig b/system/services/vfs/vfs-test.zig index f30afd6..d76bad9 100644 --- a/system/services/vfs/vfs-test.zig +++ b/system/services/vfs/vfs-test.zig @@ -6,10 +6,30 @@ const std = @import("std"); const runtime = @import("runtime"); -pub fn main() void { +pub fn main(init: runtime.process.Init) void { const u = @import("posix").unistd; const payload = "hello-vfs"; + // The "park" role (the vfs-client-death test): open a file, then hold the + // handle forever without closing — the kill and the VFS's release-on-death + // are the point. + if (init.arguments.count > 1) { + var fd: i32 = -1; + var tries: u32 = 0; + while (fd < 0 and tries < 200) : (tries += 1) { + fd = u.open("parked", u.O_CREAT); + if (fd < 0) runtime.system.sleep(20); + } + if (fd < 0) { + _ = runtime.system.write("vfstest: park open failed\n"); + return; + } + while (true) { + _ = runtime.system.write("vfstest: parked\n"); + runtime.system.sleep(500); + } + } + // The VFS server may not have registered yet — retry open until it's up. var fd: i32 = -1; var tries: u32 = 0; diff --git a/system/services/vfs/vfs.zig b/system/services/vfs/vfs.zig index df605e6..d40dd16 100644 --- a/system/services/vfs/vfs.zig +++ b/system/services/vfs/vfs.zig @@ -23,6 +23,10 @@ const Node = struct { const OpenFile = struct { used: bool = false, node: usize = 0, + // The client (task id — an IPC badge is one) that opened this handle. What + // release-on-death sweeps by: a service must never depend on its clients + // cleaning up after themselves (docs/process-lifecycle.md). + owner: u32 = 0, }; var nodes = [_]Node{.{}} ** 8; @@ -65,8 +69,29 @@ fn fail(out: []u8) usize { return writeReply(out, .{ .status = -1 }, &.{}); } -/// Handle one request; write the reply into `out`, return its length. -fn handle(message: []const u8, out: []u8) usize { +/// Format one whole log line and emit it in a single `debug_write`, so lines from +/// concurrent processes can never land in the middle of it. +fn writeLine(comptime fmt: []const u8, arguments: anytype) void { + var line: [96]u8 = undefined; + _ = runtime.system.write(std.fmt.bufPrint(&line, fmt, arguments) catch return); +} + +/// Release every open handle `client` held — called on that client's published +/// exit event. The nodes (the files) stay: ramfs contents outlive their writers, +/// only the dead client's handles go. +fn releaseClientHandles(client: u32) void { + var released: u32 = 0; + for (&opens) |*o| { + if (o.used and o.owner == client) { + o.used = false; + released += 1; + } + } + if (released != 0) writeLine("vfs: released {d} handle(s) for dead client {d}\n", .{ released, client }); +} + +/// Handle one request from `sender`; write the reply into `out`, return its length. +fn handle(message: []const u8, out: []u8, sender: u32) usize { if (message.len < protocol.request_size) return fail(out); const request = std.mem.bytesToValue(protocol.Request, message[0..protocol.request_size]); const payload = message[protocol.request_size..]; @@ -77,7 +102,7 @@ fn handle(message: []const u8, out: []u8) usize { const ni = findNode(name) orelse createNode(name) orelse return fail(out); for (&opens, 0..) |*o, i| { if (!o.used) { - o.* = .{ .used = true, .node = ni }; + o.* = .{ .used = true, .node = ni, .owner = sender }; return writeReply(out, .{ .status = 0, .node = i }, &.{}); } } @@ -122,6 +147,12 @@ pub fn main() void { _ = runtime.system.write("vfs: register failed\n"); return; } + // First subscriber of the published exit events (docs/process-lifecycle.md): + // when a client dies holding open handles, the notification below is how the + // VFS learns to release them. + if (!runtime.process.subscribeExits(endpoint)) { + _ = runtime.system.write("vfs: exit subscription failed\n"); + } _ = runtime.system.write("vfs: ready\n"); var reply_buffer: [protocol.message_maximum]u8 = undefined; @@ -129,8 +160,17 @@ pub fn main() void { var receive: [protocol.message_maximum]u8 = undefined; while (true) { const got = runtime.ipc.replyWait(endpoint, reply_buffer[0..reply_len], &receive, null); - // Ignore notifications (none expected here); handle a request. - reply_len = handle(receive[0..got.len], &reply_buffer); + if (got.isChildExit()) { + // A published exit event: a process died; drop whatever it held. + releaseClientHandles(got.childProcessId()); + reply_len = 0; + continue; + } + if (got.isNotification()) { + reply_len = 0; // not a request; nothing owed + continue; + } + reply_len = handle(receive[0..got.len], &reply_buffer, got.senderTaskId()); } } diff --git a/test/qemu_test.py b/test/qemu_test.py index da67797..f0b9e52 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -246,6 +246,12 @@ CASES = [ "smp": 4, "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, + # M17.3: published exit events — the VFS subscribes, a client dies holding an + # open handle, and the VFS releases it (process-lifecycle.md "Who learns of a death"). + {"name": "vfs-client-death", + "smp": 4, + "expect": r"DANOS-TEST-RESULT: PASS", + "fail": r"DANOS-TEST-RESULT: FAIL"}, # The initial_ramdisk: the loader ferries a bundle of user binaries; the kernel parses # it and spawns each as a ring-3 process (here the VFS-server stub heartbeats). {"name": "initial-ramdisk",