Files
RedBear-OS/local/docs/INITNSMGR-CONCURRENCY-DESIGN.md
T
vasilito e38a444303 docs(initnsmgr): thread-spawn investigation revises A-vs-B ordering
Investigated the bootstrap thread bring-up needed for Design A
(worker-offload). Finding: `rlct_clone_impl` requires a fully-built TCB
for the new thread, but bootstrap's freestanding redox_rt has no
Tcb::new / TLS allocator / thread shim (only initialize_freestanding's
single TCB). So Design A needs, as a prerequisite, a freestanding
thread-spawn helper in redox_rt (its own task) — open-coding TCB/TLS in
initnsmgr is not acceptable.

Revised ordering: keep Step 1 (Arc<Mutex> Send refactor, inert until A),
boot-validate on idle + commit; then prefer Design B (kernel O_NONBLOCK
on open + single-thread deferred, no bootstrap threads) as the first
functional step; Design A later once redox_rt grows the freestanding
thread helper. All still require an idle host to validate.
2026-07-22 10:14:55 +09:00

223 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# initnsmgr Concurrency Design — worker-offload of the blocking open
**Status:** Design (not implemented). Companion to
`INIT-NAMESPACE-MANAGER-SCALABILITY-PLAN.md`, which states *why* this is needed; this document
states *how*. Implementation must be validated on an idle host or real hardware — see "Validation".
## Problem restated (precisely)
`local/sources/base/bootstrap/src/initnsmgr.rs` runs the init namespace manager as a single thread:
```
run(): loop {
req = socket.next_request() // one request
resp = req.handle_sync(&mut scheme) // synchronous — runs the handler inline
socket.write_response(resp)
}
```
Every path resolution in a restricted namespace (every login shell, much of init's own spawn path)
is proxied here. The only handler that can block is `openat``open_scheme_resource`, which does a
**blocking `syscall::openat(cap_fd, …)`** to the *provider* daemon. If that provider is briefly not
servicing its socket (descheduled under load, mid-`tick`, in a one-shot startup window), the
`openat` blocks, the loop stops, and **every** other request queues behind it. The wedge surfaces at
whatever boot stage was in flight (e.g. "ahcid" in the logs) — that stage is the *symptom location*,
not the cause. The cause is head-of-line blocking on a single serving thread.
All the other handlers are fast, in-memory operations that must **not** be parallelized (they mutate
shared namespace state): `dup` (ForkNs / ShrinkPermissions / IssueRegister), `unlinkat`, `on_close`,
`on_sendfd`, `getdents`, `fstat`, and the `namespace:`/`""` (list) branches of `openat`. Only the
`open_scheme_resource` branch — a blocking call to another daemon — needs to move off the loop.
## Design A — worker-offload (self-contained in bootstrap; recommended first)
Keep resolution serialized; move only the blocking `openat` to a small worker pool. The dispatcher
resolves the target `cap_fd` under a lock (fast), then hands the *blocking* call to a worker and
keeps accepting requests. The worker replies out-of-band when the open completes.
### Building blocks (all verified available in the no_std bootstrap)
- **`redox_rt::sync::Mutex<T>`** (`relibc/redox-rt/src/sync.rs`) — futex-based, `Send + Sync` for
`T: Send`. Replaces the current `Rc<RefCell<Namespace>>` sharing.
- **`FdGuard` is `#[repr(transparent)]` over `usize`** (`redox-rt/src/proc.rs:792`) — trivially
`Send`. The namespace already holds scheme caps as `Arc<FdGuard>`; a worker clones the `Arc`
(not the fd), so `FdGuard`'s `Drop` (which closes the fd) fires only when the last reference is
gone — no double-close.
- **`Socket`** (redox-scheme) is an fd wrapper; `write_response(&self, …)` takes `&self`, and
`CallRequest → Tag` is `Send`. Share the socket as `Arc<Socket>` so a worker can reply.
- **`redox_rt::thread::rlct_clone_impl`** (`redox-rt/src/thread.rs`) — the raw thread primitive.
**This is the hard blocker, and it is worse than first estimated.** `rlct_clone_impl(stack, tcb:
&RtTcb)` requires a *fully-constructed TCB for the new thread*. relibc's `pthread_create` builds
that TCB with `Tcb::new(tls_len)` (`relibc/src/pthread/mod.rs:167`), pushes the entry/arg/tcb/shim
onto a freshly `mmap`ed stack (`:177-201`), calls `rlct_clone`, and the `new_thread_shim`
(`:218`) activates the TCB (TLS) + installs the signal handler before jumping to the entry point.
**bootstrap has none of this**: it runs `redox_rt::initialize_freestanding(this_thr_fd)`
(`exec.rs:71`), which sets up exactly ONE TCB (`RtTcb::current()`); there is no `Tcb::new`, no TLS
allocator, no thread shim in the freestanding path. So Design A needs, as a prerequisite, either:
1. **Port a minimal thread-spawn helper into `redox_rt`'s freestanding path** — allocate a stack,
build a new `RtTcb`, wire the TLS masters pointers, and provide a shim — the low-level,
arch-specific core of `pthread_create`, but without relibc `std`. This is the real cost, and it
is deep `unsafe` in the earliest-boot component.
2. **Or use worker PROCESSES, not threads.** bootstrap already forks its three services via
`spawn` (`exec.rs:290`). But processes do not share `Arc<Mutex<Namespace>>`, so the dispatcher
would have to pass each resolved `cap_fd` + reply `Tag` to a worker process over a pipe/socket
and the worker replies on the shared scheme socket — more moving parts than threads.
**Consequence:** the "just call rlct_clone" framing was too optimistic. Given this, **Design B
(below) is now the more attractive first step** — it needs no bootstrap threads at all. Design A
remains the cleaner end-state *if* a freestanding thread-spawn helper is added to `redox_rt`
first (that helper is independently useful and should be its own task).
### State changes
```rust
// Before: single-threaded interior mutability
namespace: Rc<RefCell<Namespace>>
// After: shareable across dispatcher + workers
namespace: Arc<redox_rt::sync::Mutex<Namespace>>
```
`Namespace.schemes` is already `HashMap<String, Arc<FdGuard>>``Arc<FdGuard>` is `Send`, so the
map is `Send` once the outer cell is a `Mutex`. The `NamespaceScheme.handles`/`next_id` bookkeeping
stays on the dispatcher thread (never touched by workers).
### Work item + queue
```rust
struct OpenWork {
tag: Tag, // reply target (Send)
cap_fd: Arc<FdGuard>, // provider scheme cap (Send; refcount keeps it alive)
reference: String, // path within the provider scheme
flags: usize,
fcntl_flags: u32,
}
// Shared, bounded queue + futex wakeup (no std::mpsc in no_std):
struct WorkQueue {
inner: redox_rt::sync::Mutex<VecDeque<OpenWork>>,
// futex word bumped on push; workers futex-wait on it when the queue is empty.
}
```
Bounded (e.g. 64). On overflow the dispatcher falls back to handling the open inline (degrades to
today's behavior for that one request rather than dropping it) — never unbounded growth.
### Dispatcher openat path
```rust
// openat, scheme != "namespace" and != "" (list):
let cap_fd = {
let ns = ns_access.namespace.lock(); // fast: hashmap lookup
ns.get_scheme_fd(scheme).cloned() // Arc clone, released with the lock
};
let Some(cap_fd) = cap_fd else { return Err(ENODEV) };
queue.push(OpenWork { tag: req.tag(), cap_fd, reference, flags, fcntl_flags });
// DO NOT write a response here — the worker will. Return a "deferred" marker so
// run() skips write_response for this request.
```
This needs the low-level request API (`next_request` → keep the `CallRequest`/`Tag`, respond later)
rather than `handle_sync`, which always writes a response. redox-scheme already exposes `Tag` /
`Response::return_external_fd(fd, tag)` for exactly this.
### Worker loop
```rust
loop {
let work = queue.pop_blocking(); // futex-wait when empty
let res = syscall::openat(work.cap_fd.as_raw_fd(),
&work.reference, work.flags, work.fcntl_flags as usize);
let resp = match res {
Ok(fd) => Response::return_external_fd(fd, work.tag),
Err(e) => Response::err(e.errno, work.tag),
};
let _ = socket.write_response(resp, SignalBehavior::Restart); // Arc<Socket>
}
```
Results arrive out of order — fine, the kernel matches by `tag`. If the client died meanwhile, its
`on_close` already ran on the dispatcher; the late `write_response` targets a dead tag and the
kernel discards it (must be verified to be a no-op, not an error).
### Concurrency invariants
- **Namespace mutation stays serialized** under the `Mutex`; workers never touch namespace state,
only a cloned `cap_fd`. So there is no ordering hazard between a `fork`/`register` and an in-flight
open — the open captured its `cap_fd` before being queued.
- **Provider serialization is unchanged**: each provider still serializes its own requests; we only
stop *initnsmgr* from serializing *unrelated* providers behind one slow one.
- **Worker count**: 24. This is fault-isolation, not throughput — enough that a couple of slow
providers cannot stall the rest.
## Design B — kernel `O_NONBLOCK` on open + single-thread deferred (no bootstrap threads)
Avoids the no_std thread bring-up entirely, at the cost of a kernel change with a wider blast radius.
1. **Kernel**: make `UserInner::call_inner` (`kernel/src/scheme/user.rs`) honor `O_NONBLOCK` on the
open opcode — return `EAGAIN` instead of `.block()`ing when the provider has not taken/answered
the request, using the existing cancellation path.
2. **initnsmgr** stays single-threaded and event-driven (the `RawEventQueue` pattern acpid already
uses): try `openat` with `O_NONBLOCK`; on `EAGAIN`, park `(tag, cap_fd, reference, flags)` in a
pending list, subscribe to the provider fd's readiness, and continue `next_request`. On readiness,
retry and `write_response(tag)`.
3. **Bonus**: this immediately activates fbcond's existing handoff-retry (it already opens with
`O_NONBLOCK` and retries; today the retry never fires because the first open blocks).
B is architecturally cleaner (no threads in the earliest-boot component) but changes scheme-open
semantics for *every* scheme in the system, so it needs the strongest system-wide validation.
## Recommendation (revised 2026-07-22 after the thread-spawn investigation)
The original recommendation was "start with A". After confirming that bootstrap's freestanding
`redox_rt` has **no thread-spawn machinery** (no `Tcb::new`, no TLS allocator, no thread shim — see
the `rlct_clone_impl` note above), the ordering changes:
- **Step 1 (Send refactor) is done and stands regardless of A vs B** — `Arc<Mutex<Namespace>>` is
a strict improvement and a prerequisite for A; it is inert (single-threaded) until A lands.
Keep it (currently `local/patches/wip-initnsmgr/step1-send-refactor.patch`), boot-validate on an
idle host, and commit.
- **Prefer Design B (kernel `O_NONBLOCK` on open + single-thread deferred) as the first functional
step.** It needs no bootstrap threads, reuses the `RawEventQueue` pattern acpid already runs, and
immediately activates fbcond's existing retry. Its cost is a kernel scheme-open change with a
system-wide blast radius — so it needs strong validation — but it avoids the deepest `unsafe` in
the earliest-boot component.
- **Design A remains the cleaner end-state**, but only after a **freestanding thread-spawn helper**
is added to `redox_rt` as its own, independently-useful task. Do not attempt A's worker bring-up
by open-coding TCB/TLS setup inside initnsmgr.
Net: **Step 1 → (idle validation + commit) → Design B for the functional fix → Design A later** once
`redox_rt` grows a freestanding thread helper. All of this still requires an idle host or real
hardware; none of it can be validated under the current external load.
## Staged implementation plan (A)
1. **Refactor sharing, no behavior change**: `Rc<RefCell<Namespace>>``Arc<sync::Mutex<Namespace>>`,
still fully synchronous (lock/handle/unlock inline). Build + boot: must be identical to today.
This isolates the mechanical Send refactor from the concurrency change.
2. **Thread bring-up spike**: bring up ONE worker thread in bootstrap that does nothing but log a
heartbeat via the debug fd, and prove it survives boot. This de-risks the hardest part alone.
3. **Introduce the queue + single worker**, route only `open_scheme_resource` through it, deferred
response. Keep the inline fallback on queue-full. Boot + measure.
4. **Scale to N=24 workers**, add the bounded-queue overflow fallback and dead-tag handling.
5. **Load test**: N≥10 boots on an idle host, plus an induced-slow-provider test (a provider that
sleeps before servicing its socket) to confirm one slow provider no longer wedges the rest.
Each step is independently bootable and revertable.
## Validation
- Framebuffer screendump (QMP) is ground truth; the serial mirror is racy.
- **One boot proves nothing** — the failure is a race; compare rates across N≥10 boots **on an idle
host**. External load (e.g. a background `opencode` at 200290% CPU) contaminates every measurement
and must be absent for a verdict.
- Success: N≥10 consecutive clean boots to a working brush login, and no head-of-line wedge under an
induced slow-provider.
## Risk / rollback
- Blast radius of A is initnsmgr only; revert = restore one file + the `submodule/base` gitlink.
- The thread bring-up is the sole high-risk element; step 2 isolates it before any concurrency logic.
- Do NOT ship any step that was only validated under external load.