Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ opened: 2026-10-02
`ops::close` answers every poll on a watch its object ends with `-NotFound`
(`Watch::cancel_polls`), in every ring, when any one handle to a pipe's read
end or an acceptor closes. A sibling handle from `dup` keeps the object open,
and its polls end all the same. `toyos::poller`'s `drain` hands the token of a
negative completion to its caller as it does a ready one's, so a reader that
takes that token for bytes and reads blocking parks on an object that is open
and empty. No caller in the tree reaches it: fsd's acceptors are endowed and
and its polls end all the same. `toyos::poller`'s `wait` hands the token of a
negative completion to its caller as it does a ready one's and drops the
kernel's word for it, so a reader that takes that token for bytes and reads
blocking parks on an object that is open and empty. `wait_answers` hands the
word beside the token. No caller in the tree reaches it: fsd's acceptors are endowed and
never duplicated. `inbox_cancel_wakes` stages the close.

**Exit**: a poll ends only when the last handle to its source closes, or a
completion's result reaches `Poller`'s caller; a test closes a duplicate under
a watch and reads what the wait hands back.
**Exit**: a poll ends only when the last handle to its source closes, or
`wait`, the form that drops a completion's result, is gone and every caller of
`Poller` reads it through `wait_answers`; a test closes a duplicate under a
watch and reads what the wait hands back.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
status: open
kind: defect
opened: 2026-10-08
---

# A shutdown of the sending half drops what the send pipe still holds

`handle_tcp_shutdown` (`userland/netstack/src/main.rs`) calls `socket.close()`
on the pass that reads the request. A client's bytes reach netstack through
its send pipe and its request through its connection, and nothing orders the
two: bytes the client wrote before it asked may still be in the pipe.
`bridge_piped` reads the pipe only while `send_room` holds, which is false
from `FIN-WAIT-1` on, so those bytes are never sent and the peer reads a
stream that ends short with a clean FIN.

Dropping the write end instead is the path that works: the bridge reads the
pipe to its end and closes the socket after the last byte.

Read from the code, not measured. `netstack_socket_churn`
(`tests/toyos-rust-tests/src/bin/netstack_socket_churn.rs`) writes into the
pipe after the shutdown, which is the client's own error and not this.

**Exit condition**: a shutdown of the sending half closes the socket after
the bytes the pipe held when the request was read, and a guest test on
`tests/netcase` whose client writes, shuts down at once and reads the peer's
echo sees every byte it wrote.

**Owner**: whoever holds `issues/toyos-has-its-own-network-stack.md`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
status: open
kind: defect
opened: 2026-10-08
---

# A stream its peer reset refuses the option requests a host answers

netstack lets a piped connection's socket and id go once the wire is finished
and it has closed both of its pipe ends (`bridge_piped`,
`userland/netstack/src/main.rs`). A peer's reset does both at once, so a
client that still holds the stream names an id netstack no longer has, and
`handle_tcp_set_option` and `handle_tcp_get_option` answer it
`ERR_NOT_CONNECTED`. Before the socket left with its bridge both answered from
the kept socket.

What a host answers `std::net::TcpStream` on a stream whose peer reset it,
after the read that reported the reset:

| | `set_nodelay(true)` | `nodelay()` |
|---|---|---|
| macOS (Darwin 27.0.0), measured | `Err(InvalidInput)`, `EINVAL` | `Ok(false)` |
| Linux, by the review of the change that made this, not measured | `Ok(())` | `Ok` |
| ToyOS, read from the code | `Err(NotConnected)` | `Err(NotConnected)` |

So the write is refused as macOS refuses it, under another kind, and the read
is refused where both hosts answer. Keeping the id until the client's close
request is not the fix: a client that dies sends none, and netstack, having
closed its pipe ends, has nothing left that says the client is gone.

**Exit condition**: a guest test on `tests/netcase` whose peer resets a stream
the client still holds, and `nodelay()` on it answers `Ok`, with
`netstack_socket_churn` still green.

**Owner**: whoever holds `issues/toyos-has-its-own-network-stack.md`.
20 changes: 20 additions & 0 deletions issues/logkeeper-hears-a-reader-leave-only-at-its-next-write.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
status: open
kind: finding
opened: 2026-10-08
---

# logkeeper hears a reader leave only at its next write

A log reader's thread (`userland/logkeeper/src/serve.rs`, `feed`) waits on
`shared.grew` once it has caught up, and learns its reader is gone only when a
write to the sink fails. A reader that leaves while the log is quiet keeps its
thread and its place in `MAX_NETWORK_READERS` or `MAX_LOCAL_READERS` until the
next record lands.

Read from the code, not measured.

**Exit condition**: a reader's end is what wakes its thread, or the header of
`serve.rs` says why the next record is soon enough.

**Owner**: whoever holds `issues/redesign-the-log-subsystem.md`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
status: open
kind: defect
opened: 2026-10-08
---

# netstack cuts a departed client's unsent tail at the ceiling

A piped connection whose client holds no end of a direction still open
(`userland/netstack/src/main.rs`, `clientless`) has `OWNERLESS_LIFE`, 100
seconds from the pass that found it so, to finish on the wire (`ownerless`).
That is every client that is gone, and one that is not: a client that let go
of its send end and still holds the read end of a receive pipe netstack closed
at the peer's FIN is timed and cut the same way, alive. The bound is absolute: at the
ceiling the socket is reset whatever it still owes its peer. A program that
writes and exits is the ordinary case of a client that is gone, so when its
peer takes longer than 100 seconds to acknowledge what was written, the peer's
stream ends in a reset with the tail undelivered: whatever is left of the
64 KiB send buffer, and whatever the client's send pipe still held, up to the
pipe's 2 MiB.

The cut is loud at both ends: netstack logs it and the peer reads a reset, not
a short stream. Nobody is left to tell on the client's side.

Hosts do otherwise for an orphaned connection with data still to send: it is
cut on no progress or under orphan pressure and not on a clock from the close.
From knowledge of Linux's `tcp_orphan_retries` and `tcp_max_orphans`, not
measured here.

The bound is absolute because the slot table has no per-client share
(`issues/netstack-lookup-slots-have-no-per-client-share.md` records the same of
the lookups): a peer that acknowledges a byte at a time would hold a slot of a
client that is gone for as long as it liked, and
`max_piped_connections` such peers hold every slot.

Read from the code, not measured: the bench rows that reach the ceiling
(`userland/netstack/src/listen/tests.rs`) cut a connection whose peer
acknowledges nothing.

**Exit condition**: a connection with no client whose peer keeps
acknowledging new bytes is kept while it makes progress, under a bound on how
many such connections one peer or one departed client may hold; and a bench
row whose peer acknowledges one segment a second past the ceiling sees the
whole tail and a FIN.

**Owner**: whoever holds `issues/toyos-has-its-own-network-stack.md`.
25 changes: 25 additions & 0 deletions issues/netstack-datagram-sockets-and-listeners-have-no-bound.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
status: open
kind: defect
opened: 2026-10-08
---

# netstack's datagram sockets and listeners have no bound

`max_piped_connections` (`userland/netstack/src/main.rs`) bounds piped TCP
connections and pending connects, and `resolve::MAX_LOOKUPS` the lookups in
flight. `handle_udp_bind` and `handle_tcp_bind_piped` check neither and
nothing else: every bind adds a socket with two 64 KiB buffers and holds the
client's 2 MiB pipes with it, two for a UDP socket and one for a listener. One holder of the `netstack`
connector that binds in a loop takes netstack's memory, and every dynamic UDP
port, from every other program. No bound is per client either:
`issues/netstack-lookup-slots-have-no-per-client-share.md` records that for
the two that exist.

Read from the code, not measured.

**Exit condition**: a bind past a stated bound is refused
`ERR_RESOURCE_EXHAUSTED` with nothing made, and a test binds past it and sees
the refusal and another client's bind answered.

**Owner**: whoever holds `issues/toyos-has-its-own-network-stack.md`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
status: open
kind: defect
opened: 2026-10-08
---

# netstack keeps the datagram socket of a client that sent no close

A UDP socket is three things in netstack (`userland/netstack/src/main.rs`):
the smoltcp socket with its two 64 KiB buffers, the id's entry in `sockets`,
and the two pipe ends in `udp_pipes`, each keeping a 2 MiB kernel pipe alive.
A close request removes all three. Nothing else looks at the pipes: a client
that dies, or drops its ends without the request, leaves the socket and its
bound port for the life of the process, and a restarted program that binds
the same port is answered `ERR_ADDR_IN_USE`. The one other way out is
`deliver_datagram`, which ends the socket when a receive that was already
pending writes into a pipe with no reader.

A listener's owner is heard, and only on a pass something else causes:
`serve_piped_listeners` writes zero bytes to every notify pipe each pass and
ends the listener the kernel answers `Gone` for, and no watch wakes a pass for
it, so an idle netstack keeps a dead owner's port until other traffic arrives.

A piped TCP connection is the shape to copy: `bridge_piped` ends it on what
the kernel says of the client's pipe ends, and the watch on each of them is
what wakes that pass.

Read from the code, not measured.

**Exit condition**: a datagram socket and a listener whose owner's pipe ends
are closed leave the socket set and the table with no other traffic, and a
guest test on `tests/netcase` that binds each, drops it without a close
request, and reads `net.sockets.udp` and `net.sockets.listeners` back at what
they were.

**Owner**: whoever holds `issues/toyos-has-its-own-network-stack.md`.

This file was deleted.

37 changes: 21 additions & 16 deletions issues/netstack-spends-two-poller-slots-per-piped-connection.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,27 @@ kind: defect
opened: 2026-09-26
---

# netd spends two poller slots per piped connection
# netstack spends two poller slots per piped connection

A piped connection registers its send pipe `READABLE` and, while it holds
bytes its receive pipe refused, its receive pipe `WRITABLE` — both in one pass,
because an ssh receiver sends `WINDOW_ADJUST` while its own receive is held.
`MAX_PIPED_SLOTS` (`userland/netd/src/main.rs`) divides the batch one poller
can carry (`Poller::MAX_HANDLES`, less the two fixed registrations and the
pending connections) by `POLL_HANDLES_PER_PIPED = 2`, so the ceiling on live
piped connections halved from 222 to 111.
A piped connection registers both its pipes on every pass, for as long as
netstack holds them: its send pipe `READABLE` while the socket has send room
and `OTHER_END_GONE` until the kernel has answered that, and its receive pipe
`OTHER_END_GONE` and, while it holds bytes the pipe refused, `WRITABLE`
(`userland/netstack/src/main.rs`, the loop in `main`). `MAX_PIPED_SLOTS`
divides the batch one poller can carry (`Poller::MAX_HANDLES`, less the fixed
registrations, the pending connections and the lookups' clients) by
`POLL_HANDLES_PER_PIPED = 2`, so the ceiling on live piped connections is half
what one registration each would give.

The memory budget (an eighth of memory at 4 MiB a connection) is the lower
bound only below 3552 MiB (111 × 4 MiB × 8), so every machine with 4 GiB
or more is held to 111.
The memory budget (an eighth of memory at 4 MiB a connection) binds first only
on a machine whose eighth holds fewer connections than that ceiling.

Exit condition: one registration per connection. netd's side of a
connection is one kernel `Connection` object, which `read_source` and
`write_source` both resolve (`kernel/src/object/ops.rs`), so one `OP_WATCH`
carries `READABLE | WRITABLE` (`kernel/src/inbox/mod.rs`, `process_watch`), and
`POLL_HANDLES_PER_PIPED` is deleted.
**Exit condition**: one registration per connection, and
`POLL_HANDLES_PER_PIPED` deleted. One `OP_WATCH` on a joined `Connection`
carries `READABLE | WRITABLE` for both its pipes, and is refused
`OTHER_END_GONE`: `ops::pipe_end_watch` (`kernel/src/object/ops.rs`) answers a
pipe end alone, and a connection has two other ends for the one bit. So the
exit also needs the kernel to say of a connection which of its directions has
no holder left.

**Owner**: whoever holds `issues/toyos-has-its-own-network-stack.md`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
status: open
kind: defect
opened: 2026-10-08
---

# netstack watches both pipes of every connection on every pass

Every pass of netstack's loop submits a watch for each pipe of each live piped
connection, ready or not, changed or not (`userland/netstack/src/main.rs`, the
loop in `main`): an idle connection submits two. A watch replaces its handle's
earlier one, so each is a poll allocated and registered again, with the takes
of the global pipe lock that `arm` makes to read the pipe and find its watches
(`kernel/src/inbox/mod.rs`).

A poll that asks `OTHER_END_GONE` sits on its own end's watch, which is the
watch that end's readers or writers park on. So every write a client makes
through the kernel posts the poll netstack keeps on that connection's send
pipe, and every read the poll on its receive pipe: netstack's submitter is woken in the kernel for a
look that finds the other end held and arms again. No completion is written and
no pass runs, and the wake is paid per client read and write.

Read from the code, not measured: nothing in the tree reads netstack's
throughput or its passes per second with connections open, and a duration is
the T14's to say.

**Exit condition**: a pipe's watch is submitted only when its interest changes
or the kernel has answered it, and a post that is no leaving wakes no
`OTHER_END_GONE` poll; or a throughput reading on the T14, the same transfer
with and without the watches, that shows the cost is nothing.

**Owner**: whoever holds `issues/toyos-has-its-own-network-stack.md`.
6 changes: 4 additions & 2 deletions kernel/pure/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
//! What the kernel decides without touching the machine: the scheduler core
//! ([`sched`]), the process and thread lifecycle ([`proclife`]), which PCID an
//! address space is handed ([`pcid`]) and what type the range registers give a
//! range ([`mtrr`]). The kernel binary links it; the host runs
//! address space is handed ([`pcid`]), what type the range registers give a
//! range ([`mtrr`]) and what a pipe's ends are told of each other ([`pipe`]).
//! The kernel binary links it; the host runs
//! its tests, because none of it reads a register, a clock or a kernel lock.

#![no_std]
Expand All @@ -13,5 +14,6 @@ extern crate std;

pub mod mtrr;
pub mod pcid;
pub mod pipe;
pub mod proclife;
pub mod sched;
Loading
Loading