Publish exit events to subscribers; the VFS releases dead clients' handles (M17.3)
process_subscribe adds an endpoint to a bounded, ref-counted subscriber table; every death posts the same badge encoding a supervisor's exit notification uses, equally late, so subscribers observe a fully-released child. A dying subscriber's own subscriptions are removed first — it never hears about itself. The VFS is the first subscriber: open handles now record their owner and are swept when the owner dies, because a service must never depend on clients cleaning up after themselves (docs/process-lifecycle.md). Proven by the vfs-client-death scenario.
This commit is contained in:
parent
2ebfb0c3b0
commit
d8c55c6f2f
|
|
@ -35,7 +35,10 @@ only when its definition of green holds.
|
||||||
- [x] **M17.2** — exit reasons (`ExitReason` recorded at exit/fault/kill before
|
- [x] **M17.2** — exit reasons (`ExitReason` recorded at exit/fault/kill before
|
||||||
the notification; `process_exit_reason` supervisor-gated;
|
the notification; `process_exit_reason` supervisor-gated;
|
||||||
`runtime.process.exitReason`; kernel + ring-3 assertions; suite 49/49)
|
`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
|
- [ ] **M17.4** — signals, timer notifications, `runtime.process`, the service harness
|
||||||
- [ ] **merge** `feat/process-lifecycle` → main, push
|
- [ ] **merge** `feat/process-lifecycle` → main, push
|
||||||
- [ ] **M18.1** — device-manager protocol: hello + restart policy (branch `feat/device-manager`)
|
- [ ] **M18.1** — device-manager protocol: hello + restart policy (branch `feat/device-manager`)
|
||||||
|
|
|
||||||
|
|
@ -66,3 +66,13 @@ pub fn exitReason(id: u32) ?ExitReason {
|
||||||
if (r > ~@as(usize, 0) - 4095) return null; // a wrapped -errno
|
if (r > ~@as(usize, 0) - 4095) return null; // a wrapped -errno
|
||||||
return @enumFromInt(r);
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ pub const SystemCall = enum(u64) {
|
||||||
process_kill = 25, // process_kill(id) -> 0/-errno: end a process this process spawned
|
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
|
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_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
|
||||||
_,
|
_,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,7 @@ fn system_call(state: *architecture.CpuState) void {
|
||||||
.process_enumerate => systemProcessEnumerate(state),
|
.process_enumerate => systemProcessEnumerate(state),
|
||||||
.process_kill => systemProcessKill(state),
|
.process_kill => systemProcessKill(state),
|
||||||
.process_exit_reason => systemProcessExitReason(state),
|
.process_exit_reason => systemProcessExitReason(state),
|
||||||
|
.process_subscribe => systemProcessSubscribe(state),
|
||||||
_ => fail(state),
|
_ => fail(state),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -574,6 +575,16 @@ fn releaseTaskResourcesLocked(t: *scheduler.Task) void {
|
||||||
recordExitLocked(t);
|
recordExitLocked(t);
|
||||||
irq.releaseOwner(t.id);
|
irq.releaseOwner(t.id);
|
||||||
devices_broker.releaseAllOwnedBy(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| {
|
if (t.ipc_client) |client| {
|
||||||
t.ipc_client = null;
|
t.ipc_client = null;
|
||||||
client.ipc_status = -ipc.EPEER;
|
client.ipc_status = -ipc.EPEER;
|
||||||
|
|
@ -583,6 +594,12 @@ fn releaseTaskResourcesLocked(t: *scheduler.Task) void {
|
||||||
scheduler.removeFromWaitQueueLocked(t);
|
scheduler.removeFromWaitQueueLocked(t);
|
||||||
scheduler.forgetIpcClientLocked(t);
|
scheduler.forgetIpcClientLocked(t);
|
||||||
ipc.closeHandles(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| {
|
if (t.exit_endpoint) |raw| {
|
||||||
const endpoint: *ipc.Endpoint = @ptrCast(@alignCast(raw));
|
const endpoint: *ipc.Endpoint = @ptrCast(@alignCast(raw));
|
||||||
t.exit_endpoint = null;
|
t.exit_endpoint = null;
|
||||||
|
|
@ -690,6 +707,34 @@ pub fn exitReasonOf(caller_id: u32, target_id: u32) i64 {
|
||||||
return -ipc.ESRCH;
|
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 {
|
fn systemProcessExitReason(state: *architecture.CpuState) void {
|
||||||
const t = scheduler.current();
|
const t = scheduler.current();
|
||||||
if (t.aspace == 0) return fail(state);
|
if (t.aspace == 0) return fail(state);
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
|
||||||
supervisionTest(boot_information);
|
supervisionTest(boot_information);
|
||||||
} else if (eql(case, "claim-release")) {
|
} else if (eql(case, "claim-release")) {
|
||||||
claimReleaseTest(boot_information);
|
claimReleaseTest(boot_information);
|
||||||
|
} else if (eql(case, "vfs-client-death")) {
|
||||||
|
vfsClientDeathTest(boot_information);
|
||||||
} else if (eql(case, "initial-ramdisk")) {
|
} else if (eql(case, "initial-ramdisk")) {
|
||||||
initialRamdiskTest(boot_information);
|
initialRamdiskTest(boot_information);
|
||||||
} else if (eql(case, "vfs")) {
|
} else if (eql(case, "vfs")) {
|
||||||
|
|
@ -1535,6 +1537,74 @@ fn claimReleaseTest(boot_information: *const BootInformation) void {
|
||||||
result();
|
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,
|
/// 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
|
/// which — entirely from ring 3 — creates an exit endpoint, spawns its two
|
||||||
/// children supervised, sees them in process_enumerate, kills them (one blocked,
|
/// children supervised, sees them in process_enumerate, kills them (one blocked,
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,30 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const runtime = @import("runtime");
|
const runtime = @import("runtime");
|
||||||
|
|
||||||
pub fn main() void {
|
pub fn main(init: runtime.process.Init) void {
|
||||||
const u = @import("posix").unistd;
|
const u = @import("posix").unistd;
|
||||||
const payload = "hello-vfs";
|
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.
|
// The VFS server may not have registered yet — retry open until it's up.
|
||||||
var fd: i32 = -1;
|
var fd: i32 = -1;
|
||||||
var tries: u32 = 0;
|
var tries: u32 = 0;
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@ const Node = struct {
|
||||||
const OpenFile = struct {
|
const OpenFile = struct {
|
||||||
used: bool = false,
|
used: bool = false,
|
||||||
node: usize = 0,
|
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;
|
var nodes = [_]Node{.{}} ** 8;
|
||||||
|
|
@ -65,8 +69,29 @@ fn fail(out: []u8) usize {
|
||||||
return writeReply(out, .{ .status = -1 }, &.{});
|
return writeReply(out, .{ .status = -1 }, &.{});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle one request; write the reply into `out`, return its length.
|
/// Format one whole log line and emit it in a single `debug_write`, so lines from
|
||||||
fn handle(message: []const u8, out: []u8) usize {
|
/// 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);
|
if (message.len < protocol.request_size) return fail(out);
|
||||||
const request = std.mem.bytesToValue(protocol.Request, message[0..protocol.request_size]);
|
const request = std.mem.bytesToValue(protocol.Request, message[0..protocol.request_size]);
|
||||||
const payload = message[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);
|
const ni = findNode(name) orelse createNode(name) orelse return fail(out);
|
||||||
for (&opens, 0..) |*o, i| {
|
for (&opens, 0..) |*o, i| {
|
||||||
if (!o.used) {
|
if (!o.used) {
|
||||||
o.* = .{ .used = true, .node = ni };
|
o.* = .{ .used = true, .node = ni, .owner = sender };
|
||||||
return writeReply(out, .{ .status = 0, .node = i }, &.{});
|
return writeReply(out, .{ .status = 0, .node = i }, &.{});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -122,6 +147,12 @@ pub fn main() void {
|
||||||
_ = runtime.system.write("vfs: register failed\n");
|
_ = runtime.system.write("vfs: register failed\n");
|
||||||
return;
|
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");
|
_ = runtime.system.write("vfs: ready\n");
|
||||||
|
|
||||||
var reply_buffer: [protocol.message_maximum]u8 = undefined;
|
var reply_buffer: [protocol.message_maximum]u8 = undefined;
|
||||||
|
|
@ -129,8 +160,17 @@ pub fn main() void {
|
||||||
var receive: [protocol.message_maximum]u8 = undefined;
|
var receive: [protocol.message_maximum]u8 = undefined;
|
||||||
while (true) {
|
while (true) {
|
||||||
const got = runtime.ipc.replyWait(endpoint, reply_buffer[0..reply_len], &receive, null);
|
const got = runtime.ipc.replyWait(endpoint, reply_buffer[0..reply_len], &receive, null);
|
||||||
// Ignore notifications (none expected here); handle a request.
|
if (got.isChildExit()) {
|
||||||
reply_len = handle(receive[0..got.len], &reply_buffer);
|
// 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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,12 @@ CASES = [
|
||||||
"smp": 4,
|
"smp": 4,
|
||||||
"expect": r"DANOS-TEST-RESULT: PASS",
|
"expect": r"DANOS-TEST-RESULT: PASS",
|
||||||
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
"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
|
# 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).
|
# it and spawns each as a ring-3 process (here the VFS-server stub heartbeats).
|
||||||
{"name": "initial-ramdisk",
|
{"name": "initial-ramdisk",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue