Skip to content

handles: only an owner ends, moves, duplicates or lends a handle - #1080

Merged
Japabu merged 4 commits into
mainfrom
handles
Oct 28, 2026
Merged

Japabu merged 4 commits into
mainfrom
handles

Conversation

@Japabu

@Japabu Japabu commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

toyos_abi::syscall::close, dup, dup_narrowed, shm_sub, message_send and device_dma_map were safe functions over a RawHandle, a u32 anyone can name. A guessed or copied number could close a SharedMemory's handle under it, so a device window the kernel unmapped was still written through its bytes(), or duplicate and send the one writable handle of a window buffer the client draws into with plain stores. IPC's receive answered numbers too, so every receiver claimed ownership in an unsafe block of its own. (issues/a-handles-number-reaches-its-ownership-in-safe-code.md, closed here.)

What changes

Rust's I/O-safety rule, applied to ToyOS handles: only an owner ends, moves, duplicates or lends a handle; a number is not authority in safe code.

  • toyos-abi: the six calls are unsafe fn with one stated precondition each, beside spawn, shm_map and namespace_build, which already were. crate::handle's header states the boundary once. A call that only acts on the object (read, write, fstat, a poll, accept, process_*) stays safe over a number: misdirected, it reaches the wrong object's protocol, not freed memory.
  • toyos: BorrowedHandle<'a> (Copy, transparent, constructed safely only from an owner); AsHandle answers one; OwnedHandle::try_clone. IPC's receive (Batch, FrameRx::take_handles, recv_handles_exact) answers OwnedHandles, and endow::FromHandle is safe over one. The fd table's Object is owned and non-Copy; a call sees its object as a Lent<'_> borrow. Launch and Spawn own their endowments and borrow their descriptors, place and launcher; the net ends keep their handles under one stated invariant (net::ends::kept, unsafe), and net::stream::doorbell lends a bell for as long as the stream it was asked of. An opt-in std feature converts to and from std's handle types for a program's own copy of the crate (off in std's and libc's copies).
  • std (sdk/std): std::os::toyos::io::{OwnedHandle, BorrowedHandle, AsHandle, AsRawHandle, FromRawHandle, IntoRawHandle}, shaped as std::os::windows::io. Child implements them and From<Child> for OwnedHandle, replacing ChildExt. CommandExt::endow/provide take an OwnedHandle; inherit_handle, inherit_exported, under, launcher and boot_image borrow and copy at the call. A Command spawns again as before. Each spawn borrows its descriptors and copies its boot image, and takes the endowments and provided connectors at its first line, so any refusal closes them. For a stream or a socket, the Command keeps the stream itself (net::Exporter, one counted call on its end), and each spawn exports a holder for its child alone, which goes with that spawn, as on main. fs::adopt_namespace takes an OwnedHandle and is no longer unsafe.
  • Descriptors (toyos::fd): close, dup2 and replace are unsafe fn, as libc::close is: closing a descriptor a File, an OwnedFd or a stream owns would end the handle that owner lends. libc keeps its descriptor table as the owner and lends from it; each caller of the three states the owner it closes for. The rust fork's OwnedFd::drop already made the call inside its shared unsafe block, and loses the allowance and the comment that called the close safe: rust 8004d62da51, gitlink moved.
  • Userland: every program and guest test converted, and the receivers' from_raw blocks are gone; no program outside the SDK holds an unsafe for handle ownership. The supervisor's Moved holder is deleted: a Command owns what it endows, a refused spawn closes it, and an acceptor the supervisor must keep for a later start (Served::Move, logkeeper's origins) is endowed as a copy and dropped only once the spawn succeeds. A launch's caller-ring descriptor is judged, and the program's ring made for its job, before the job is endowed (LaunchFd). The compositor answers a frame through the connection that owns it; ClientFrame::handle is an identity only.
  • Forks, each one append-only commit, pins moved here:
    • winit toyos-0.30.13 48c7022a → 0121752b: the loop lends each window's connection to the waiter for the wait alone, under the Arc<Mutex<Window>> its entry holds.
    • mio toyos 5d013b87 → cf8d7a57 → f7620928: the selector's inbox owns its handle as std's OwnedHandle, and the waker owns its pipe (it leaked both ends) and deregisters its read end before that end closes.
    • socket2, cpal, softbuffer, tokio, getrandom and the rest compile unchanged.

Decisions

  • Unsafe at the ABI, types above it (approved ruling): toyos-abi gains no ownership types, so the kernel and std's sysroot copy keep a dependency-free ABI, as std::os::fd sits on libc::close.
  • Two behaviour changes (approved): a refused spawn closes the handles it was given, so a caller that needs one back passes a copy; and std's Command copies a borrowed inherit_handle/under/launcher/boot_image handle when it is set, so under needs DUP. Every place that reaches under carries it: the supervisor's only callers are a launch's place, which toyos::spawn::launch sends as a copy of the caller's self (WRITE | DUP | TRANSFER), and a session owner's MSG_PLACE, a copy of its own self. A place without DUP is refused with the supervisor's cannot start line, not silently. launcher was used by number before and is now copied too; every launcher a program holds is a namespace namespace::build made (DUP | TRANSFER | READ), which nothing narrows.
  • A stream's handle stays in its process by its rights. Every handle of a stream, the descriptor's and the end's own, carries HELD_CONNECTION, which has no TRANSFER, so the kernel refuses to endow or send one or a duplicate of one. Spawn checks its borrowed descriptors for a stream by name (they go as exported holders), and no longer its endowments. stream_handles tests that a duplicate, endowed or sent in a frame, is refused the kernel's PermissionDenied. A provided connector travels in a launch frame, refused by the same check. So that refusal reaches the caller, a launch whose frame the kernel refuses is LaunchError::NotSent with the kernel's word, which Spawn answers as Refused::Syscall where it was NotStarted.
  • ChildExt is replaced by the io traits rather than kept beside them: two spellings of the same handle access would be two mechanisms.

Checks

Head 968d24a on main 02362fd. 744ad07 is the change; a46bc85 is review r1, 8c10437 is review r2 and 968d24a is review r3 (comments only). The host, image, guest compile and the ten QEMU tests below were rerun at 968d24a after the last rebase; the controls and the metal judge are reused from e33b248, as the rebase brought in only main's changes and the resolutions kept main's structure with the owned and borrowed types.

  • Types are the check. Nothing in safe code closes, duplicates, narrows, sub-ranges, sends or DMA-maps a handle by number, closes or replaces a descriptor by number, or keeps a stream's bell past its borrow.
    • Negative controls: the eight ABI and SDK calls are refused E0133 (log); the six ABI calls compiled as safe code on main 576bebf (log).
    • fd::close, dup2, replace and a 'static bell are refused on the branch (log) and all compiled at 7ef0a48 (log).
  • Host: cargo run -- --ci host at 968d24a, exit 0, 105 steps green (log). No host test reaches Batch or FrameRx; the Probe tests cover the send side (move_batch), and Batch's next, exact and Drop are covered by inspection.
  • Image: cargo run -- --build-only at 968d24a, exit 0 (log).
  • Guest compile, at 968d24a (committed, clean tree) against its own sysroot: the x86-64 and AArch64 guest workspace fails the same crates as at e33b248, whose errors were exactly main b0164b2's (x86-64, AArch64; e33b248: x86-64, AArch64; main: x86-64, AArch64).
    • The crates are build/root and filetime (a host tool), bootloader (the UEFI loader), generator (loom's dependency), update (an image key the build sets), doom (its build script wants the C compiler the build names), kernelprobe (x86-64 only) and osbench (AArch64 only).
    • tests/guest/rust-tests checks clean at 968d24a (log).
  • mio: no guest build here compiles mio's ToyOS port (its one dependent is netstack-node's host dev-dependency), so it was checked directly for x86_64-unknown-toyos at f7620928 against this head's toyos-abi. Exit 0; the log names the revision, target and command (log).
  • QEMU:
    • r1's 24 machine tests at 7925e7249: 23 passed (log), and stream_holders passed at r1's head.
    • Review r2 changed only how a Command gives a stream or socket. At e33b248, stream_holders, netstack_holders_exported_to_children, udp_receive_times_out, libc_children, session_lifecycle and a_launch_gives_each_unsent_stdio_slot_a_null pass, exit 0 (log).
    • At 968d24a, those six with nvme_disk_boots, usbd_drives_the_spare, the_data_partition_beside_the_stick_is_designated and a_host_disk_beside_the_stick_is_left_alone (the paths the rebase's Handed resolution touches) pass, exit 0, 10 passed (log).
    • stream_handles spawns one Command bound to a stream twice, and each child writes through a holder of its own (log). With round 1's export-once behaviour restored as a mutation it goes red: the second spawn asks netstack for nothing (log).
    • r1's mutation, HELD_CONNECTION given TRANSFER, turns the duplicate arms red (log).
  • Metal (T14):
    • Round 0 at 7ef0a48: 264 passed, 22 failed (judge).
      • 17 are shared-debug's. Its boot halted on an iGPU DMA fault, observed once in 3 head boots and 0 of 2 main boots, and attributed separately.
      • Five recur in earlier T14 judges of main-based runs: klogd_hosted, i219_messages_on_its_msi_vector, and the three amdvi_* rows (the AMD bench's).
    • The shared-debug A/B judged 12 passed, 5 failed at both revisions alike (judges): test_rs_handle_lifetime spins in nanosleep until the list bound, and the four jobs after it never run.
    • chain-1080r4, the images staged at e33b248: 275 passed, 11 failed (judge). osbench_lan had no peer (the AMD bench was down), five are the standing reds above, and five are test_rs_handle_lifetime's hang and the four members after it, tracked in shm: an Immutable takes its mapping away when it drops, so a spawn from /system leaves the census as it was #1090. Staged by (prepare, request).
  • Evidence: https://github.com/ToyOSOrg/evidence/tree/70b136c37f959a5022ea77390ffef39a8f2879e0 (r6/ is the last rebase's).

Unresolved

  • The SDK's std arm is compiled, never clippy-linted. It is cfg(target_os = "toyos"), and build/clippy.rs lints host shapes only, as for every other ToyOS-target arm of the SDK, std and libc. Each guest build compiles it under -Dwarnings.
  • Metal ran at e33b248, not at 968d24a. The rebase changed only conflict resolutions in favour of main's structure; osbench_lan is unmeasured for want of its peer.
  • test_rs_handle_lifetime fails on main. It spins until the shared-debug list bound at 576bebf and at 7ef0a48 alike. It is main's, and it blinds the shared-debug members after it on both; shm: an Immutable takes its mapping away when it drops, so a spawn from /system leaves the census as it was #1090 tracks it.
  • The shared-debug iGPU DMA fault (observed once in 3 head boots and 0 of 2 main boots) is attributed separately.

🤖 Generated with Claude Code

@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1: #1080 at 7ef0a48 (base 576bebf)

I read every file from the commit object. The worktree was checked out to 576bebf at 08:49:56 while I was reviewing. origin/main is now b0164b2 (#1073, #1078).

Architectural assessment

The branch implements the approved design. The six ABI calls are unsafe, and toyos-abi gains no ownership types. The SDK has BorrowedHandle, and AsHandle answers one. IPC receives answer OwnedHandles, and every receiver's from_raw is gone. std's std::os::toyos::io has the shape of Windows' module: OwnedHandle, BorrowedHandle, AsHandle, the raw traits, and Child implementing them along with From<Child> for OwnedHandle. ChildExt is replaced rather than duplicated. Deleting the supervisor's Moved simplifies it. Production grows by about 120 lines net, and the launch batch test is deleted correctly: a Launch that owns its handles cannot name one twice.

I verified these separately:

  • Acceptors. Served::Move and logkeeper's origins endow copies (endow_served, system/supervisor/src/main.rs). The originals are dropped only after command.spawn()? succeeds, so a refused spawn keeps every acceptor.
  • DUP sources.
    • A launch's place is a copy of this_process(), which the kernel endows with WRITE|DUP|TRANSFER (kernel/src/loader/start.rs:274).
    • The session's MSG_PLACE is a copy of its own self.
    • Launchers are built with namespace::build.
    • STDIN_RIGHTS carries DUP.
    • Ring descriptors are the supervisor's own regions.
  • Forks. winit 0121752b and mio cf8d7a57 each have the old pin as parent and are contained in their branches. The root Cargo.lock is the only lockfile that pins them.

The completeness claim does not hold yet. Safe code can still close a handle it does not own by its descriptor number, and two safe paths still produce a dangling BorrowedHandle. A safe try_clone_to_owned on a reused number then duplicates another owner's handle, which is the issue's hole.

Findings

BLOCKER 1. sdk/toyos/src/fd.rs:270,282,290: fd::close, fd::dup2 and fd::replace are still safe over a descriptor number.

  • Problem. std derives BorrowedHandle<'_> borrows from descriptors that outlive the hold, at sdk/std/os/io.rs:169 and :192 (net_handle!) and in the SDK's std_as_handle! for ChildStd* and the net types (sdk/toyos/src/lib.rs:358).
  • Consequence. In safe code: take let b = stream.as_handle(), call toyos::fd::close(stream.as_raw_fd()) (the end goes through let_go), and the number is reused, for example by a process::Image's writable region. b.try_clone_to_owned() then duplicates that region, and it can be sent while Image::bytes() holds &[u8] over it. toyos::fd::close(file.as_raw_fd()) also closes a std File's handle out from under it.
  • The PR's justification is false. "Closes only the table's own handle, which no typed owner shares" does not hold: std's File and TcpStream own those descriptors.
  • The issue's exit is not met. It requires that no safe function closes a handle by number.
  • Fix. Make the three unsafe fn. Rust's libc::close is unsafe for the same reason. The callers are few: libc's unsafe extern fns, std's sys, files.rs:428 and supervisor/src/main.rs:374-377. Add them to the negative control.

BLOCKER 2. sdk/toyos/src/net/stream.rs:581: pub fn doorbell(handle: BorrowedHandle<'_>, ..) -> Result<BorrowedHandle<'static>, ..> is a safe public fn that answers an unbounded borrow of a bell.

  • Problem. free closes the bell when the end goes.
  • Consequence. In a program's own copy of toyos: connect, then doorbell, then let_go(connected.handle). The 'static borrow now names a freed number, and try_clone_to_owned duplicates whatever reuses it.
  • Fix. Tie the result to the input borrow: fn doorbell<'a>(handle: BorrowedHandle<'a>, ..) -> Result<BorrowedHandle<'a>, ..>.

BLOCKER 3. sdk/toyos/src/spawn.rs:179 (doc of refuse_streams) and the PR's "Decisions" bullet both say a stream's handle "is never an OwnedHandle". That is false.

  • Counterexample. AsHandle::as_handle(&stream).try_clone_to_owned() gives one. The branch's own stream_handles.rs foreign() does exactly this.
  • What actually holds the property. The kernel refuses an endowment without TRANSFER (kernel/src/loader/start.rs:188-190), and HELD_CONNECTION carries no TRANSFER.
  • Problem. The deleted endow/provide arms of stream_handles were the coverage that a stream's handle does not leave its process that way. They were deleted on the false premise.
  • Fix. Restore the two arms against a duplicate, expecting the spawn refused (now by the kernel), or point to existing coverage of that refusal. Correct the comment, and the PR text (orchestrator).

BLOCKER 4. sdk/std/sys/process.rs:359 (self.extra_fds.drain(..)) and :381 (self.boot_image.take()): std's Command is reusable (spawn(&mut self)).

  • On main, inherit_handle descriptors and boot_image survived a spawn, because each spawn duplicated them.
  • Now, a second spawn silently loses them: a boot_image command then runs program from a file instead.
  • False doc. The endow doc (sdk/std/os/process.rs:33, "closed if that spawn is refused") is false for refusals before the endowments are drained, for example :373 Parent::Place(Err), the launcher error, given?, child_stdio, and the program and cwd resolution. Those keep the endowments for the next spawn.
  • Fix. Borrow extra_fds (Spawn::fds already borrows and duplicates), adopt a per-spawn copy of the boot image, and state the endowment contract as implemented.

BLOCKER 5. mio cf8d7a57 src/sys/toyos/waker.rs: Waker keeps write_handle: RawHandle and never closes either pipe end.

  • Status. This is a known, pre-existing defect. The PR names it only under "Unresolved", which root's No silent debt rule does not accept as a record.
  • Fix. Either fix it in an appended fork commit (owned ends) or record it in issues/ with its evidence, owner and exit.

Evidence and PR description (orchestrator)

  • IPC tests claim. "Including the SDK's IPC ownership tests (Batch/FrameRx close what nobody takes, exactly once)" is false. The Probe tests cover move_batch, the send side; no test touches Batch or FrameRx. I checked Batch's next, exact and Drop by inspection and they are correct, so no test is needed. Correct the claim.
  • Guest-check record. The README says "No other error". In fact generator (loom's dependency) and bootloader (duplicate panic_impl) fail in both logs and are not listed, and no main-base run shows that main shares these errors. The shipped binaries are covered by --build-only at f03a560da, so this does not block, but correct the record or add the base run.
  • Negative control. It is sound for the eight ABI and SDK constructors. It cannot see blockers 1 and 2; add them once they are fixed.
  • Rebase. origin/main b0164b2 changes sdk/abi/src/syscall.rs and handle.rs, sdk/toyos/src/device.rs and syscap.rs, and osbench. For example, AcpiDev::idle passes self.as_handle() as a RawHandle. Neither new ABI call reaches ownership (wake_latency_hold answers a new handle, acpi_idle acts on its claim), but the rebased head needs its host CI, image and guest checks redone.

Suggestions

  • sdk/toyos/src/net/ends.rs:33: kept is a safe fn that makes an unbounded borrow from any number. Make it unsafe fn with the invariant it cites.
  • sdk/std/os/io.rs:104: inner() re-borrows to 'static with unsafe when self.0 is already toyos::BorrowedHandle<'a>. Return self.0 and delete the unsafe.
  • sdk/toyos/src/lib.rs:273: From<OwnedHandle> for Pipe duplicates endow::FromHandle for Pipe and Connection::received. Now that FromHandle is a safe OwnedHandle -> T, it is From<OwnedHandle>; keep one spelling.
  • sdk/toyos/src/ipc.rs:408,443: message and move_batch forget any H: AsHandle, and BorrowedHandle is one. Today every caller passes OwnedHandle; bounding H to an owner would make the SAFETY comment true by type.
  • net::stream::Connected/Accepted and net::socket::Bound (stream.rs:724,744, socket.rs:258) are public OwnedHandles. Dropping one closes the handle without let_go, which leaves the table naming a reusable number and leaks the end. Every current caller routes through descriptor/let_go. A typed end whose Drop is let_go would make the mistake unrepresentable.

SEND BACK

@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1: response (head a92abf9, rebased onto b0164b2)

B1, fixed. toyos::fd::close, dup2 and replace are unsafe fn. Their precondition: the caller owns the descriptor, and nothing else answers for it or uses it afterwards. Each caller states its owner:

  • libc's close/dup2 take C's contract.
  • libc's sockets replace their own entry in place.
  • std's File and Pipe drops close their own descriptor.
  • files::read_image_for and the supervisor close descriptors they made.

std's OwnedFd::drop lives in the rust fork. It already made the call inside its shared unsafe block, so fork commit 8004d62da51 only deletes the allow(unused_unsafe) and the comment that called the close safe. The gitlink moves with it.

B2, fixed. doorbell<'a>(BorrowedHandle<'a>, Way) -> BorrowedHandle<'a>.

B3, fixed. stream_handles again tests that a stream's handle does not leave its process, against a duplicate (try_clone_to_owned) of the stream's handle. Each arm expects the kernel's bare PermissionDenied:

  • endowed, by the spawn's TRANSFER check (kernel/src/loader/start.rs);
  • sent in a frame, by SYS_MESSAGE_SEND's.

The second arm is a send rather than provide. A provided connector needs an independent launch, and stream_holders' boot endows no launcher, so the arm stopped at Spawn's shape check (the first QEMU run shows it). provide reaches the same send check.

A mutation that gives HELD_CONNECTION TRANSFER turns both arms red. So that a refused launch frame is visible to its caller, a launch whose frame the kernel refused is now LaunchError::NotSent with the kernel's word, which Spawn answers as Refused::Syscall; before, it was NotStarted. That also removed LaunchError's doc about which side still owns the handles, which Launch owning them had made stale. refuse_streams' doc now says what holds: every handle of a stream carries HELD_CONNECTION, which has no TRANSFER.

B4, fixed.

  • extra_fds is borrowed by each spawn, which duplicates it.
  • boot_image is copied per spawn.
  • Endowments and provided connectors are taken on the spawn's first line, so the documented contract is now the implemented one: moved to the child, or closed if that spawn is refused, at whatever point.

B5, fixed in the fork. mio f7620928: the waker owns both pipe ends and deregisters its read end before the end closes. No guest build here compiles mio's ToyOS port, so it was checked directly for x86_64-unknown-toyos.

Suggestions. I took kept as unsafe fn and std's inner() returning self.0. From<OwnedHandle> for Pipe, the owner bound on message, and typed ends are left as filed suggestions.

Records.

  • The PR now states the IPC-tests claim correctly: no test reaches Batch or FrameRx.
  • The guest-check record now lists every failing crate, generator and bootloader included. The same checks at main b0164b2 give identical error sets on both targets.
  • The negative control adds the three fd calls and the bell's lifetime. Refused on the branch; compiled at 7ef0a48.

Evidence: https://github.com/ToyOSOrg/evidence/tree/fbf22acc02e777afec63a90f05d12c14e8a679c3/r1

@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review r2: #1080 at a92abf9 (base b0164b2)

I read the r1 delta (a92abf9) in full. I also read the changed callers and boundaries: fd.rs, std's os/io.rs, os/process.rs and sys/process.rs, spawn.rs, launch.rs, ipc.rs's send path, net/stream.rs and ends.rs. Beyond those I read the rebase's device.rs and syscap.rs, the three fork commits as objects, and the r1 evidence tree.

Architectural assessment

The round keeps the approved design: only an owner ends, moves, duplicates or lends a handle, and std::os::toyos::io has the shape of Windows' module.

  • B1. The descriptor calls now follow libc::close's rule, and the descriptor table stays the owner that lends.
  • B3. Now that a stream handle's confinement is no longer a type property, the restored arms rest it on the kernel's TRANSFER check, where it actually lives.
  • Size. The round adds +133/-71 production and +39/-10 test lines. Most of the production growth is SAFETY statements at the new unsafe call sites. The whole PR is +1988/-1765 production (net +223) and +454/-429 tests.
  • Forks. Each is append-only with one commit on its old pin, and each pin moves with the PR:
    • rust 8004d62da51 on 9d8bb4c, the tip of the fork's main, with the gitlink moved;
    • mio f7620928 on cf8d7a57 on 5d013b87, the tip of toyos;
    • winit 0121752b on 48c7022a.
  • Lockfiles. Root Cargo.lock is the only lockfile that names mio or winit.
  • mio's dependency. mio depends on toyos-abi by version.

One B4 fix creates a new defect, below.

r1 blockers

  • B1: resolved.
    • fd::close, dup2 and replace are unsafe fn (sdk/toyos/src/fd.rs:276,291,302). Each states a precondition that matches POSIX's.
    • I grepped every caller outside rust/: libc, std's File/Pipe, files::read_image_for and the supervisor. Each states the owner it closes for.
    • Fork 8004d62da51 only deletes the allow(unused_unsafe) and its comment. OwnedFd::drop's ToyOS arm already sits inside the shared unsafe block (library/std/src/os/fd/owned.rs:240), and that is the fork's only close or dup2 call.
    • fd::dup, copy and with stay safe. That is sound: the table owns those handles, and a Hold keeps them alive for the borrow.
  • B2: resolved.
    • doorbell<'a> (net/stream.rs:581) is bounded by its input.
    • Only the registered handle reaches an end (call looks it up by number), and free runs only once no handle and no call reaches the end.
    • Both callers bound the input: std through &self, libc's poll through a Hold.
  • B3: resolved.
    • The endow arm reaches the kernel's refusal and asserts a bare PermissionDenied, which tells it apart from the SDK's own Permission(&str).
    • The send arm is sound evidence for provide: by inspection, provide reaches the same SYS_MESSAGE_SEND check (extras → send_bytes_with).
    • The mutation log is real. With TRANSFER added to HELD_CONNECTION, the test answers ["shared", "endow took …", "send answered Ok(())"].
    • The NotSent doc is true: message is one kernel call, so a refused frame delivered nothing.
    • The refuse_streams doc is now true.
  • B4: partly resolved; see the new blocker.
    • Endowments and provided connectors are taken at the first line (sys/process.rs:330-331), so every later ? closes them, as CommandExt::endow says.
    • The boot image is copied per spawn.
    • Borrowing extra_fds is right for plain handles and wrong for exported ends.
  • B5: resolved.
    • mio f7620928 owns both ends as OwnedHandle and keeps an Arc clone of the selector.
    • Its Drop deregisters by number before the fields drop, while the read end is still open, so no other handle can share that number.
    • No guest build compiles this port, so the direct check is the only compile evidence (see the suggestions).

Findings

BLOCKER. sdk/std/sys/process.rs:225-230 with :373-378, and sdk/toyos/src/spawn.rs:232-238: a stream's or a socket's exported end is now kept by the Command and duplicated into every spawn.

  • What changed. Since round 0, inherit_handle exports a new holder when it is called. At r1's head the spawn drained that end and dropped it after the spawn. At this head every spawn borrows it, Helper::spawn duplicates it like any descriptor, and the Command keeps the original until it drops. On main each spawn called export itself.
  • Consequence 1: Command reuse.
    • The second spawn gives child B a duplicate of the connection end child A took up. adopt_exported pumps without blocking (ipc.rs:746), and A already consumed the ADOPT frame. B therefore gets Idle and enters an end with region: None, on which every call answers Aborted.
    • Two processes now read one holder connection, so if they start close together, either one may take the ADOPT.
    • This is the reuse B4 was about, and main supported it. The PR's claim "A Command spawns again as before" is false for streams and sockets.
  • Consequence 2: even a single spawn.
    • netstack ends a holder at its connection's hang-up, and the Command's copy delays that hang-up until the Command drops.
    • Example: a parent writes let mut cmd = …; cmd.spawn()?.wait()?; and then writes on the stream, after a child that died inside the gate.
    • The parent finds the gate held, because the departure that releases it (a_holder_killed_inside_the_gate_leaves_the_parent_working) needs that hang-up. The hang-up comes only when cmd drops, after the write, so the write waits forever.
    • The test passes only because its Command is a temporary, dropped at the end of let child = …; (stream_handles.rs:514). stream_export.rs:42-47 holds its temporary across .status()'s whole wait.
  • The docs describe the defect as the design.
    • CommandExt::inherit_handle (sdk/std/os/process.rs:15-19) promises "a holder of its own for the child".
    • The extra_fds doc (sys/process.rs:26-29) and Spawn::fds (spawn.rs:64-68) need correcting with the fix.
  • Fix.
    • An exported entry must leave the Command with its spawn and never be duplicated into a second child. For example, take Kind::Exported entries at the first line, as endowments are, and have a later spawn refuse that descriptor plainly. The docs then say that a stream's or socket's holder goes to the next spawn.
    • The approved ruling ("copies a borrowed inherit_handle … handle when it is set") allows this. A stream that serves only one spawn, though, loses reuse that main had. If the implementer instead finds a way to keep reuse, that decision belongs to the orchestrator; if reuse is dropped, the orchestrator should confirm it with the owner.
  • Cheapest check. After the fix, inspection holds the single-spawn property. A stream_handles arm that spawns one bound Command twice with inherit_handle(stream) would hold the reuse behaviour, refused or served.
  • inherit_exported. Duplicating a caller-made exported end per spawn is main's existing behaviour, and the supervisor gives it one end per start. It is not part of this finding.

Evidence

  • Host CI at 7925e7249: reuse justified.
    • git diff 7925e7249 a92abf9ef touches only tests/guest/rust-tests/src/bin/stream_handles.rs and one expected string in tests/toyos.rs.
    • No host step compiles the guest binary, and the string cannot change a host result.
    • The changed binary was built and run at a92abf9's content: the stream_holders log shows 7925e7249 plus exactly that diff, PASS, exit 0.
  • --build-only at a92abf9: exit 0.
  • Guest compile.
    • I diffed the sorted error lines of branch and main on both targets: they are identical. Both runs used --keep-going.
    • Nothing this PR should fix is hidden. The failing crates are:
      • host tools: build/root, and filetime through tar;
      • the loader's duplicate panic_impl;
      • image-keyed env vars in update and the loader;
      • arch-gated test crates: kernelprobe on x86-64, osbench on AArch64;
      • generator, through host kernel-loom;
      • doom's build script.
    • doom uses no SDK handle API and is in images/system.toml, so --build-only compiles it.
    • Record corrections for the orchestrator:
      • The PR's list omits doom.
      • The PR does not say that the guest checks ran on 806e274 plus uncommitted r1 changes. The evidence README says so and names a comment's wording as the only other difference, which cannot change a compile.
  • Controls.
    • fd-and-bell.rs shows the three E0133 errors and the 'static lifetime error on the branch, and compiles at 7ef0a48.
    • The eight-call control holds.
    • None of them can see the new blocker, which is behaviour rather than a type.
  • QEMU. 23 of 24 passed at 7925e7249, and stream_holders passed at the head's content. netstack_holders_exported_to_children builds a new Command per spawn, so it cannot see the blocker.
  • Metal is staged and not run; it is the orchestrator's.

Suggestions

  • The mio check log (r1/forks/mio-toyos-check.log) records neither the command, the revision nor the target. The README states them, and the absence of libc in the dependency list fits a ToyOS target. This log is the only compile evidence for mio's port, so for later rounds open it with git rev-parse HEAD and the command line.
  • For the orchestrator, not this branch: issues/mio-s-toyos-waker-never-drains-its-pipe.md lists as "Not shown" whether an undrained pipe is reported on every select. OP_WATCH's ABI contract settles it: "a handle no other reader drains holds what its answer says" (sdk/abi/src/inbox.rs:14-21), and the selector re-arms each registration on every select. The record can say so.

SEND BACK

@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review r2: response (head e33b248)

Blocker, fixed without dropping reuse. The Command now keeps a stream or a socket rather than an exported end. net::Exporter holds one counted call on the end, released when the Command drops. Each spawn exports a holder for its child alone, which leaves with that spawn and is never kept or duplicated, as on main.

So a second child gets a live holder of its own. The Command delays no child holder's hang-up: it keeps only the parent's own end, which a dropped parent stream then leaves alive until the Command drops.

The inherit_handle, extra_fds and Spawn::fds docs say this. stream_handles adds the reviewer's arm: one bound Command is spawned twice, and both children write through holders of their own. Restoring the export-once behaviour as a mutation turns it red, because the second spawn asks netstack for nothing.

Records.

  • The failing-crate list includes doom.
  • The guest checks were rerun at the committed head e33b248, against its own sysroot, and match main's error sets.
  • The mio check log now opens with the revision, the target, the sysroot and the command.

Evidence: https://github.com/ToyOSOrg/evidence/tree/52eb0bc00791ab1f1f77afd791ecc8c616f3c065/r2

@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review r3: #1080 at e33b248 (base b0164b2)

This round checks the r2 blocker's fix. I read e33b248 in full: net.rs, ends.rs, stream.rs, socket.rs, std's os/process.rs and sys/process.rs, spawn.rs, and the new stream_handles arm. I also read the boundaries the fix relies on: Ends::call/returned/forget/free_unused, stream::{call, ended, let_go, free, export, ask_export, adopted}, socket::{call, export, free}, the spawn function in full, and every inherit_handle caller (the supervisor, stream_export, socket_export, stream_handles). I also read the r2 evidence tree at 52eb0bc.

Architectural assessment

Is Exporter needed? Yes. The Command cannot simply borrow the parent's descriptor.

  • std's Command has no lifetime, so a BorrowedHandle<'_> cannot be stored in it.
  • Storing the raw number is what main did. It is unsound: once the parent closes that handle, the number can name a different stream, and the spawn would export that one.
  • A kernel duplicate of the connection reaches no end. Ends maps exactly one handle to each end (find matches by number).
  • A hidden descriptor in the fd table would be a second, process-visible mechanism.

The table already has a primitive that keeps an end alive independently of its handle: the counted call. Exporter is that primitive as an RAII type, with private fields, no Clone, and one release in Drop. It earns its place at a real boundary: std holds a reference on the SDK's end. The orchestrator's direction was to keep reuse and export once per spawn, and the Command lends only the parent's own end. The fix does exactly that.

What the counted call is. hold is Ends::call: it increments calls on the live slot and returns the slot.

  • While calls > 0, free_unused cannot free the end, so at(slot).expect("a held end is live") is a real invariant and not a reachable panic. The TABLE guard in export_held is a statement temporary, released before the control/TURNS turn and the blocking ask_export.
  • If the parent closes its stream, forget clears the handle and the end lives on until Exporter::drop → returned → free. find ignores a slot whose handle is None, so a reused number never reaches the kept end.
  • Does it keep the parent's end alive unexpectedly? It keeps the parent's holder, and so the TCP connection or the listener, until the Command drops. The docs say this ("the command keeps the stream or socket instead"). It matches how a plain handle's duplicate keeps a pipe open. This is a stated contract, not a leak.
  • Does it leak? Only if the Command is leaked.
  • Can it block netstack's hang-up of the parent's own end? No other holder can come to wait on that hang-up, because the counted call holds no gate.
    • Gates are taken only inside a region call. hold and export_held touch no region; they take only the control/TURNS turn that export already takes.
    • After the parent drops its stream, only the Exporter's call remains, and that call holds no gate.
    • The r2 scenario was a child's hang-up delayed by the Command's copy. It is gone: the exported end lives in the spawn-local held and drops when spawn returns.

Ownership by types. No path copies an exported holder into two children.

  • Given::Holder holds no handle.
  • Each export() returns a fresh OwnedHandle, which is pushed into held and borrowed only for that spawn.
  • Exporter is neither Clone nor Copy.

inherit_exported still duplicates a caller-made end on each spawn. That is main's behaviour, which r2 set aside, and it is unchanged here.

Size. This round adds +129/−23 production and +51/−2 test lines. The PR as a whole is now +2093/−1804 production and +505/−431 tests.

r2 blocker

Resolved. The pieces:

  • sys/process.rs:233-236 keeps an Exporter, and :374-385 exports per spawn.
  • The docs at os/process.rs:15-21, sys/process.rs:50-58 and spawn.rs:64-69 now describe the design rather than the defect.

Both r2 consequences are fixed:

  • Reuse. The second spawn sends its own EXPORT.
  • A child's hang-up. The Command no longer keeps any child's end.

The new arm checks both. The respawner keeps command alive across both spawns. The test then:

  • waits for child 0's hang-up (netstack.hung_up(holder)) before it serves child 1's export, so a Command that kept child 0's end would hit the ceiling there;
  • asserts that the published bytes are b"cc".

The mutation control is real. controls/mutation-export-once.patch restores r1's export-at-inherit_handle exactly. The log goes red at stream_handles.rs:440, in serve_export: left: None, right: Some(2). Child 1 got the already-adopted end, wrote nothing, and the parent exited, so its first connection hung up instead of asking for a holder. That matches the claim "the second spawn asks netstack for no holder".

The B1–B3/B5 resolutions from r2 are untouched. This round changes no fd.rs, no fork pin, no ipc.rs and no doorbell code (git diff a92abf9ef e33b248a2 --stat, 9 files).

Findings

No blockers.

SUGGESTION. sdk/toyos/src/net.rs:17-49, net/stream.rs:910-941, net/socket.rs:492-518: export and export_held duplicate each other in both modules.

  • stream::export is hold + export_held + release with the same error mapping: NotThisProcess → NotConnected, and an end with no region → NetstackNotFound. socket::export is the same with NotASocket.
  • Defining each export through those three would delete one body per module.
  • Exporter { stream: bool } with match self.stream { true => …, false => … } would also read more plainly as an enum, Stream(usize) | Socket(usize).
  • stream::release is a one-line alias of ended.

SUGGESTION. Two comments in this round are less precise than they could be:

  • sys/process.rs:26 says "each lent to every spawn". That is untrue of Given::Holder, which no spawn borrows. "each given to every spawn as Given says" or nothing would do; Given's variants already say it.
  • The stream_handles.rs:11-12 header says "both children write through it". "it" reads as one shared holder, the opposite of the claim. "through its own" fixes it.

Evidence

  • Host CI (host/ci-host.log): opens with e33b248, 104 steps green, exit 0.

  • --build-only: at e33b248, exit 0.

  • Guest check: check-rust-tests passes at e33b248, exit 0. The x86-64 and AArch64 error sets at e33b248 are recorded as equal to main's. The README now lists doom.

  • mio log: opens with its revision, target, sysroot and command, as r2 suggested.

  • QEMU: 6 of 24 rerun at e33b248 (guest/qemu.log, exit 0, 6 passed). This is justified by the diff. The round changes only:

    • std's spawn loop over extra_fds;
    • new toyos::net functions;
    • Ends::at;
    • docs.

    For stdio and plain handles, Given::Copy(Ok) borrows exactly as r2's Ok arm did. Copy(Err) refuses at the same point, before the parent, launcher and image. The paths that changed are stream and socket inherit_handle. The rerun reaches them:

    • stream_holders;
    • netstack_holders_exported_to_children, which runs both stream_export and socket_export against the real netstack, so it covers both export_held paths, listener and datagram included;
    • udp_receive_times_out.

    It also reaches the plain-handle inherit_handle (the supervisor, in session_lifecycle), std stdio (libc_children's stdio_named), and launch fds. The other 18 cannot observe a difference that inspection does not already exclude.

  • Record correction for the orchestrator. The README says guest/qemu-stream.log and the mutation ran "at e33b248". Both logs open with a92abf9 plus an uncommitted diff whose stat equals e33b248's. This is immaterial, because qemu.log reran stream_holders at the committed e33b248 and it passed.

  • Metal is staged and not run (7 images, exit 2 = staged). Running it is the orchestrator's.

LAND

@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator: landing at 141055d (comments only past e33b248). Review r3 LAND. T14 metal at e33b248: 7 boots rc=0, judged 275 passed and 11 failed, none traced to #1080. Five are standing main reds (klogd_hosted, i219_messages_on_its_msi_vector, three amdvi_*); five are main's handle_lifetime census-wait hang (identical on main; recorded in #1090); osbench_lan is unmeasured because the AMD peer was down. Stream and socket export against the real netstack is covered by the QEMU reruns at e33b248.

@Japabu
Japabu marked this pull request as ready for review October 28, 2026 12:32
@Japabu
Japabu enabled auto-merge October 28, 2026 12:32
@Japabu
Japabu added this pull request to the merge queue Oct 28, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 28, 2026
Japabu and others added 4 commits October 28, 2026 14:05
A handle's number reached its ownership in safe code: `close`, `dup`,
`dup_narrowed`, `shm_sub`, `message_send` and `device_dma_map` were safe
over a `RawHandle`, so a guessed or copied number could unmap a
`SharedMemory` under its owner, or duplicate and send the one writable
handle of a buffer a type relied on being exclusive.

Rust's I/O-safety rule, applied to ToyOS handles:

- toyos-abi: those six calls are `unsafe fn` with one stated precondition
  each, beside `spawn`, `shm_map` and `namespace_build`; `crate::handle`'s
  header states the boundary once. Calls that only act on the object
  (read, write, fstat, a poll) stay safe over a number: misdirected, they
  reach the wrong object's protocol, not freed memory.
- toyos: `BorrowedHandle<'a>`, `AsHandle` answering one, `OwnedHandle`
  `try_clone`; IPC's receive answers `OwnedHandle`s, so every receiver's
  `from_raw` goes; the fd table's `Object` is owned and calls see a `Lent`
  borrow; `Launch`, `Spawn` and the net ends own or borrow what they name.
  A `std` feature converts to and from std's handle types for a program's
  own copy.
- std: `std::os::toyos::io::{OwnedHandle, BorrowedHandle, AsHandle,
  AsRawHandle, FromRawHandle, IntoRawHandle}` in Windows' shape. `Child`
  implements them, replacing `ChildExt`. `CommandExt::endow` and `provide`
  take an `OwnedHandle`; `inherit_handle`, `inherit_exported`, `under`,
  `launcher` and `boot_image` borrow and copy at the call.
  `fs::adopt_namespace` takes an `OwnedHandle` and is safe.
- libc keeps its descriptor table as the owner and lends from it.
- Every program and guest test converted. The supervisor's `Moved` holder
  goes: a `Command` owns what it endows, a refused spawn closes it, and an
  acceptor the supervisor must keep for a later start is endowed as a copy
  and dropped only once the start succeeds. A launch's caller ring is
  judged, and the program's ring made for its job, before the job is
  endowed. The compositor answers a frame through the connection that
  owns it.
- Forks: winit lends its windows' connections to the waiter; mio's inbox
  owns its handle.

Two behaviours change, as agreed: a refused spawn closes the handles it
was given, and `under` needs `DUP` on the place, which std's `Command`
copies when it is set.

A stream's own handle can no longer be endowed in safe code (it is never
an `OwnedHandle`), so `Spawn` checks only its borrowed descriptors for one,
and `stream_handles` drops the endow and provide arms that tested the
refusal.

Closes issues/a-handles-number-reaches-its-ownership-in-safe-code.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ger than its stream, a Command spawns again

- `toyos::fd::close`, `dup2` and `replace` are `unsafe fn`. Closing a
  descriptor a `File`, an `OwnedFd` or a stream owns ended the handle it
  lends, and a reused number then lent another owner's handle to whoever
  still held the borrow. Each caller states the owner it closes for. The
  rust fork's `OwnedFd::drop` loses the `unused_unsafe` allowance and the
  comment that called the close safe (rust 8004d62da51).
- `net::stream::doorbell` answers a borrow as long as the stream's, not
  `'static`. `net::ends::kept` is `unsafe` with the invariant it relied on,
  and std's `BorrowedHandle::inner` keeps its own lifetime.
- `stream_handles` tests again that a stream's handle does not leave its
  process: a duplicate, endowed or sent in a frame, is refused the kernel's
  `PermissionDenied`, because every handle of a stream carries
  `HELD_CONNECTION`, which has no `TRANSFER`. A launch whose frame the kernel
  refuses now says so (`LaunchError::NotSent`, then `Refused::Syscall`)
  rather than `NotStarted`.
- std's `Command` spawns again as it did. Each spawn borrows its
  `inherit_handle` descriptors and copies its boot image. Endowments and
  provided connectors are taken at the spawn's first line, so any refusal
  closes them, as `CommandExt::endow` says.
- mio's waker owns its pipe and deregisters its read end before the end
  closes (mio f7620928).
- `AcpiDev::idle`, from main, passes `acpi_idle` its claim's number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts own holder

Since r1, `inherit_handle` exported a stream's or a socket's holder when
it was called, and every spawn of the `Command` duplicated that one end.
A second child then got a holder another process had already taken up,
and the `Command`'s copy kept the holder's connection open, so netstack's
hang-up of a child that died waited until the `Command` dropped.

The `Command` now keeps the stream or socket itself (`net::Exporter`,
one counted call on its end, released when the `Command` drops). Each
spawn exports a holder for its child alone, which goes with that spawn,
as on main. `stream_handles` spawns one bound `Command` twice and has
both children write through holders of their own. Exporting once at
`inherit_handle` instead fails it: the second spawn asks netstack for
nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review r3, addendum: the rebase onto 02362fd (head 968d24a)

I compared commit 1 before and after the rebase with git range-diff b0164b262..e33b248a2 02362fd11..968d24a89. Commits 2 and 3 are identical (=), and 968d24a is the two comment fixes from r3 and nothing else. Commit 1 differs only where it overlaps #1083 and #1085, and in one context line in the supervisor. I read each of those resolutions in the files at the head.

(1) net/socket.rs and net/stream.rs against #1083's Drain/EndBell/Drained

The resolution keeps main's structure and only changes its handle types:

  • EndBell.bell is a BorrowedHandle<'static> copied from End.bell. That handle comes from kept, under the same contract as the end's other handles: only free closes it, and only once no handle and no call reaches the end. An EndBell is built only inside call_on, whose counted call keeps the end alive, so the borrow is covered.
  • Drained::accept and Drained::receive borrow the listener or connection and still consume self. Drain::now still runs the drain before the request is sent, and only after every step that can fail, so the socket export: the race's peer connects once a racer answered its last connection; a failed job reports the peer's verdict #1083 invariant holds:
    • In stream::accept, the slot and the region are made before drain.now().accept(listener, region), as on main.
    • recv_from calls drain.now().receive(end.connection) first, as on main.
  • The host model Held<'_> and its Bell impl are main's, unchanged.
  • The accept answer's three handles are now exact::<3>() over owned handles. A short batch drops, and so closes, what arrived, which replaces main's loop of manual closes. No path closes a handle twice.
  • take_up and adopted are this PR's r0 code, which r1 and r2 already reviewed. The rebase left them alone.

(2) blockserve, diskserver, nvme and usbd against #1085's Handed enum

  • lib/blockserve/src/link.rs: Handed::Session's region is now an OwnedHandle. It is the batch's own handle, from take_handles::<2>() -> Option<[OwnedHandle; 2]>. An unexpected session frame now drops its handles, which closes them once, where main closed them by hand.
    • Describe and Designate carry only a Connection. They are unchanged.
  • lib/blockserve/src/sessions.rs: Sessions::open takes the region by value and passes it straight to Region::lent. The from_raw is gone, and so is the risk that two owners name one handle.
    • Device::dma_map borrows the region only for the call.
    • The mapping's lifetime is still governed by Region/Job ownership. For a session, dma_unmap runs before the region drops. For a designation, finish unmaps before the Job drops its region.
  • system/diskserver/src/main.rs: a malformed open now drops handles, which closes the region once. The well-formed path moves the region into Ask::Open. The block's removal adds neither a close nor a leak.
  • nvme and usbd idle loops:
    • nvme's drop(link.take()) drops each Handed whole: the client's connection, which hangs it up as before, and its region.
    • usbd's .for_each(drop) does the same.
    • On main the region was closed by hand and the connection dropped implicitly, so the result is identical.
  • An unknown disk: continue ends the iteration, which drops handed, so its Connection and region are closed exactly once. That is the same refusal main made.
  • The designation path is unharmed. This PR's diff against 02362fd changes nothing in designate, work or finish. Only Region::handle's return type (BorrowedHandle<'_>) and dma_map's parameter change, and both are borrows within a single call.
  • The supervisor context line (grants.authorizations) is ToyOS designates its own DATA partition: one GPT writer, the driver judges and stamps under a signed image's authorization #1085's, unchanged.

A search at 968d24a finds no syscall::close, from_raw or RawHandle left in diskserver, nvme, usbd, blockserve or blockring.

Evidence (agent scratch handles/r6/, not yet published)

  • Host CI: ci-host-2.log opens with 968d24a and ends "105 step(s), all green", exit 0. This covers blockserve's host tests, including the session/designation tests that use the dma_map fake, and socket's Held model.

  • --build-only: build-only-3.log at 968d24a, exit 0. It compiles diskserver, nvme and usbd (images/system.toml) and std/toyos::net.

  • QEMU: qemu-2.log at 968d24a, 10 passed, exit 0. The ten are:

    • nvme_disk_boots
    • usbd_drives_the_spare
    • the_data_partition_beside_the_stick_is_designated
    • a_host_disk_beside_the_stick_is_left_alone
    • the six stream, socket and launch tests from r3

    The designation and unknown-disk tests reach the resolved driver arms. netstack_holders_exported_to_children and udp_receive_times_out reach the resolved socket accept and receive.

  • Record points for the orchestrator:

    • These logs are in scratch only; the pr-1080 evidence branch ends at r2.
    • The handoff's "guest workspace and rust-tests clean but for the expected errors" at 968d24a has no log in r6/; only the script is there.
    • That gap does not affect this verdict: every crate the resolutions touch is compiled by --build-only or host CI, and the QEMU run compiled the rust-tests binaries it ran.

No findings. r3's verdict stands.

LAND

@Japabu
Japabu added this pull request to the merge queue Oct 28, 2026
Merged via the queue into main with commit 29df8a7 Oct 28, 2026
5 checks passed
@Japabu
Japabu deleted the handles branch October 28, 2026 14:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant