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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,19 @@ hashes are given where a change is easiest to understand by reading the commit.
(reset values, visible `GFIFOLEVEL` only for the last 32 slots, no FIFO
threshold interrupt), are now pinned by tests and recorded in
`docs/Indy/rex3.md`.
### Monitor and CI

- **`machine-start` is idempotent and acknowledges the transition, and
`machine-status` reports guest state and monitor readiness.** The monitor
command now prints `idle -> starting -> running` (or `running (no change)`),
`machine-stop` acknowledges symmetrically, and `machine-status [json]` gives
a caller one place to ask whether the guest is `idle`, `starting`, `running`
or `halted` and whether the monitor has bound its port. The CI socket gains
the same `status` command, `iris-ci status [--wait-ready SECS]` polls it
instead of sleeping, and `--ci` creates the control socket only after
`Machine::start` has bound the monitor, so a successful connection is
itself a readiness signal. Tests cover a command sent before the monitor is
ready, duplicate starts, start after halt/reset, and invalid transitions.

### NAT gateway

Expand Down
6 changes: 6 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,12 @@ windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_
[target.'cfg(not(windows))'.dependencies]
libc = "0.2"

[dev-dependencies]
# Integration tests (`tests/`) are separate crates and do not inherit the
# package's normal dependencies; serde_json is needed to parse the monitor's
# and CI socket's JSON replies.
serde_json = "1.0"

[profile.release]
lto = "fat"
codegen-units = 1
Expand Down
14 changes: 9 additions & 5 deletions HACKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,15 @@ The front ends do not use `Machine` directly. `main.rs`, the window
iris-gui hold a `Box<dyn Workstation>` from `workstation::build`
(`src/workstation.rs`). The trait's required methods are what every machine
has (start and stop, the screen, the keyboard and mouse, the serial console,
the monitor); its provided methods are optional facilities (snapshots, CHD
folding, the scratch volume, disc changes, the NAT controls) that default to
"not supported on this machine". `Machine` implements all of it. A machine
built around a different chipset implements the same trait and gets the
window, iris-ci, iris-gui and the monitor's `reset`/`save`/`load` with it.
the monitor, the stored lifecycle state); its provided methods are either
shared logic every machine wants the same way (`status`, and the idempotent
`start_guest`/`stop_guest` behind `machine-start`/`machine-stop`, see
`src/machine_state.rs`) or optional facilities (snapshots, CHD folding, the
scratch volume, disc changes, the NAT controls) that default to "not
supported on this machine". `Machine` implements all of it. A machine built
around a different chipset implements the same trait and gets the window,
iris-ci, iris-gui and the monitor's `reset`/`save`/`load`/`machine-status`
with it.

The monitor's system controller, the machine-events thread and the CI server
reach the machine through one shared `MachineSlot` per machine, which the
Expand Down
19 changes: 19 additions & 0 deletions STORAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ debugging the wrapper itself.
./target/release/iris --ci

# In another terminal: drive it
./target/release/iris-ci status --wait-ready 60 # guest state + monitor readiness
./target/release/iris-ci boot # PROM menu → IRIS console login (one cmd)
./target/release/iris-ci login # send root + dismiss vt100 prompt + wait #
./target/release/iris-ci run 'ls /' # send shell command, get stdout + exit code
Expand All @@ -134,6 +135,24 @@ Run `iris-ci --help` for the full list, or `iris-ci <subcmd> --help` for any
subcommand. Every operation has a typed clap arg — no JSON quoting, no
hand-managed timeouts.

### Waiting for a fresh machine

The control socket is created only after `Machine::start` has bound the monitor,
so a successful connection (or `iris-ci ping`) also means the monitor is
listening. A harness that launches iris should poll, not sleep:
`iris-ci status --wait-ready 60` retries until `monitor_ready` is true and then
prints the guest state (`idle`, `starting`, `running`, `halted`) and the
monitor address. `--json` emits the same fields as one object. A `--ci` machine
sits in `starting` with the CPU parked until `iris-ci start` (or the monitor's
`machine-start`), whose reply acknowledges the transition; repeating it is a
no-op. See [debug.md](debug.md#machine) for `machine-status` on the monitor
side.

`script FILE` (also spelled `batch FILE`) runs one command per line through a
single control-socket connection, so a multi-step scenario costs one connect
rather than one per line. It prints per-step status and stops at the first
failure; `--keep-going` runs the remaining steps and still exits non-zero.

For automation that doesn't want to depend on `iris-ci`, the underlying socket
protocol is newline-delimited JSON; `cmd` and `args` per request, `{ok, data,
error}` per response. See `src/ci.rs` for the dispatch table.
Expand Down
1 change: 1 addition & 0 deletions clanker/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ CPU architecture: CP0, TLB and its fast paths, caches, FPU, LL/SC, CPU models. B
| [instr_stats_insmap_generalization.md](cpu/instr_stats_insmap_generalization.md) | design | instr_stats generalized into a decode+exec instruction map (insmap) |
| [intrfast-self-overwrite-investigation.md](cpu/intrfast-self-overwrite-investigation.md) | open | intrfast (0x08016xxx) gets overwritten with data mid-boot — investigation in progress |
| [movci-is-cp1-by-behaviour-not-encoding.md](cpu/movci-is-cp1-by-behaviour-not-encoding.md) | rule | MOVF/MOVT are CP1 accesses with an integer encoding |
| [mp-llsc-is-a-host-compare-exchange.md](cpu/mp-llsc-is-a-host-compare-exchange.md) | design | MP LL/SC: SC is a host compare-exchange on the data, not a line-ownership tracker |
| [nanotlb-asid-mutation-flush.md](cpu/nanotlb-asid-mutation-flush.md) | rule | nanotlb must be flushed on ASID mutation, not just on privilege transition |
| [nutlb-erl-guard-defeats-permission-test.md](cpu/nutlb-erl-guard-defeats-permission-test.md) | history | nutlb: the ERL guard bit defeats the permission test entirely |
| [r5000-taglo-is-the-r4400-pstate.md](cpu/r5000-taglo-is-the-r4400-pstate.md) | rule | R5000 L1 TagLo uses the R4400 PState encoding |
Expand Down
114 changes: 114 additions & 0 deletions clanker/cpu/mp-llsc-is-a-host-compare-exchange.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# MP LL/SC: SC is a host compare-exchange on the data, not a line-ownership tracker

Status: design decision, 2026-10-23. Nothing implemented yet; this records the
reasoning so the multiprocessor (Octane) work does not re-derive it. Today
`exec_sc`/`exec_scd` in `src/cpu/mips_exec.rs` test the LLbit and then call
`write_data`, which is correct for one CPU and racy for two.

## The decision

When IRIS runs more than one CPU thread:

- `LL`/`LLD` record the physical address **and the value loaded**, and set the
LLbit as today.
- `SC`/`SCD` succeed iff the LLbit is set **and** a host atomic
`compare_exchange` of the word at that physical address, from the LL'd value
to the new value, succeeds. The store happens only inside the CAS.
- Plain stores from other CPUs, and DMA, stay what they are now: aligned host
stores of exactly their own width. They need no tracking and no locking.
- Exceptions and `ERET` keep clearing the LLbit (see
`sc-succeeds-on-llbit-alone.md`); that part does not change.
- `LL`/`SC` stay `Classify::Excluded` in jitv2, so the CAS lives in one place,
the interpreter.

This is what QEMU does for SMP MIPS, ARM and PowerPC guests.

## Why not track cache-line ownership

The tempting design is one atomic byte per guest cache line, every CPU access
ORs its bit in, `LL` resets the byte to the issuing CPU, `SC` checks that no
other bit appeared. It was considered and rejected for four reasons:

1. **It is the global lock in disguise.** A locked RMW on every guest load and
store is a full fence per access on x86, and the tracking bytes false-share:
64 guest lines' bytes sit in one host line, so two CPUs working in unrelated
8 KB regions bounce the same host line on every access. It also puts a
store on the load path, which the inline-memory JIT and tcache fast paths
cannot absorb.
2. **Reads must not be tracked.** The architecture requires `SC` to fail only
when another processor or coherent I/O agent *stores* into the block.
Remote loads downgrade the line to shared and are allowed to leave the
LLbit alone. Tracking them only adds spurious failures.
3. **`LL` resetting the byte clobbers other CPUs' reservations.** Two CPUs
with `LL` outstanding on different words of one line (adjacent lock words,
which IRIX has) each cancel the other; both `SC`s fail, both retry, and
they livelock with no store ever happening.
4. **Check-then-store is not atomic.** Another CPU's store lands between the
check and the store and two `SC`s both report success. Checking the byte
again *after* the store does not fix it: a failed `SC` must leave memory
untouched, and by then the store has landed. Concretely: A checks (clean),
B stores, A stores 1 into a lock word, A re-checks, sees B, reports
failure. Memory holds A's 1, nobody owns the lock, everyone spins forever.
Restoring the old value destroys B's store instead.

Validate-before-and-after works for operations whose effect can be discarded
(seqlock readers). A store cannot be discarded. The only correct form of the
tracking design is a per-line lock that every plain store from every CPU must
take, which is strictly more expensive than the CAS and buys only exact ABA.

## Why the CAS is enough

Hardware makes "nobody intervened" and "the store happens" one event by holding
the line exclusive across both. The host CAS is the same mechanism one level
down: the host core takes the host cache line exclusive for the duration of the
instruction, and every other emulated CPU's plain store to that word is
serialised against it by the host's own coherence protocol. The guest word is a
single aligned host `u32`/`u64` thanks to the `^3` byte and `rotate_left(32)`
qword layout in `src/dev/mem.rs`, so there is nothing to tear.

The one deviation from hardware is **ABA**: if the word changed and changed
back between `LL` and `SC`, the CAS succeeds where hardware would fail.
Spurious *failure* is architecturally allowed (software must loop), so spurious
*success* is the only thing that can go wrong, and ABA is all of it. Every
primitive in the IRIX kernel's `atomic_ops.h` is value-based (add, compare-and-
swap, test-and-set, bit set/clear, the `k_flags` bitlock), so none of them can
observe it. Nothing in IRIX pops a lock-free stack with `LL`/`SC`.

Width interactions are consistent with hardware: a byte store by another CPU
into a different byte of the LL'd word fails the CAS, and hardware fails that
`SC` too because the byte store is a store into the block.

## Prerequisites that matter more than the CAS itself

These are the real MP work; the CAS is a dozen lines.

- **The cache model must be data-less.** `mips_cache_v2` keeps real L1D and L2
line contents; with two CPUs that is two private copies of RAM. An MP build
runs tags-only with every access going to shared RAM. Under tcache the L2
data is already bypassed; L1D must follow.
- **`write64_masked` is a read-modify-write** of a host qword and will lose
bytes when two CPUs hit one qword. It must become per-byte stores or a CAS
loop. `write8`/`write16`/`write32`/`write64` are already exact-width single
stores; keep them that way and audit device DMA paths for merges.
- **RAM backing must be 8-byte aligned** for `SCD`'s CAS and for torn-free
`write64`. `Vec<u32>` guarantees 4; allocators give 16 in practice; make it
explicit.
- **Memory ordering per host.** x86 TSO is stronger than MIPS, so guest `SYNC`
can be a no-op there. On aarch64 `SYNC` needs a real fence and the `SC` CAS
needs acquire-release. Pick the `Ordering` per host arch from day one.
- **Cross-CPU code invalidation.** The per-page generation counters in
`Memory` are atomic already, but a compiled page running on CPU B must see
CPU A's write before B's `CACHE` flush completes. IRIX flushes explicitly
after writing code, so a check at the flush point on the executing CPU is
enough, but it has to be cross-thread.
- **Count vs. the HEART timer.** CPU threads drift relative to each other;
per-CPU Count must stay monotonic and roughly in step
(`cpu-count-is-wallclock-derived.md`).

## Instrument

`--features llstats` (`ll stats`, see `sc-succeeds-on-llbit-alone.md`) already
reports per-address `sc_fail` runs. Under MP it is the first thing to look at
when a lock appears stuck: a `max_run` in the hundreds on one address means the
CAS is failing where it should not, or a plain store is reaching the word
through a path that is not width-exact.
29 changes: 28 additions & 1 deletion debug.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,38 @@ accepted in every build but do nothing without the feature.

| Command | Description |
|---|---|
| `machine-start` / `machine-stop` | Start / stop the CPU and all peripherals |
| `machine-start` | Start the CPU and all peripherals. Idempotent: prints the transition it performed, e.g. `machine-start: halted -> starting -> running`, or `machine-start: running (no change)` |
| `machine-stop` | Stop the CPU and all peripherals. Idempotent: prints `machine-stop: running -> halted` |
| `machine-status [json]` | Guest state (`idle`, `starting`, `running`, `halted`) and whether the monitor is accepting commands; `json` emits one object |
| `reset` | Reset all hardware to power-on state |
| `save <name>` / `load <name>` | Save / load a snapshot under `saves/<name>/` (see [STORAGE.md](STORAGE.md#snapshots-and-rollback)) |
| `testdev` | Bare-metal test device status (`cpu-tests/`, `bench/`) |

The machine lifecycle is one value, and every surface reports it the same way:
`idle` (not started; the monitor is not listening), `starting` (the monitor
accepts commands but the CPU is parked), `running` (the CPU is executing), and
`halted` (stopped by `machine-stop`, `reset` or a guest power-off). `running` is
read from the CPU's own running flag, so the `start`/`stop` execution commands
and breakpoints are reflected without a separate bookkeeping step.

A fresh `--ci` machine is **ready for commands while still `starting`**: the
monitor is up and the peripherals are running, but the CPU stays parked until
`machine-start` (or `iris-ci start`). Do not sleep before sending it — poll
instead. Either retry the monitor TCP connect until it answers, or use the CI
socket, which iris creates only after the monitor has bound:

```sh
iris-ci --socket /tmp/iris.sock status --wait-ready 60
tools/mon.py "machine-start"
```

`machine-status` is available as soon as a monitor connection is accepted; the
`json` form is one object with `state`, `cpu_running`, `monitor_ready` and
`monitor_addr`. `iris-ci status` prints the same fields from the same source.
If the monitor could not bind its port (another iris is using it), the CI
socket still answers but reports `monitor_ready` false, so a harness can tell a
bind failure from a guest that never started.

### Execution control

| Command | Description |
Expand Down
19 changes: 17 additions & 2 deletions src/ci.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ fn dispatch(server: &CiServer, req: &Request) -> Response {
"ping" => Response::ok(),
"quit" => cmd_quit(server, &req.args),
"start" => cmd_start(server),
"status" => cmd_status(server),
"save" => cmd_save(server, &req.args),
"restore" => cmd_restore(server, &req.args),
"rollback" => cmd_rollback(server),
Expand Down Expand Up @@ -422,8 +423,22 @@ fn cmd_quit(server: &CiServer, args: &Value) -> Response {
}

fn cmd_start(server: &CiServer) -> Response {
match server.with_machine(|m| m.cpu_start()) {
Ok(()) => Response::ok(),
// Same path as the monitor's `machine-start`: idempotent, and it brings
// peripherals back before releasing the CPU after a stop. A machine that
// has not finished `Machine::start` reports `idle` and the harness should
// poll `status` first; the CI socket is created after that startup step,
// so a connected client normally cannot see `idle`.
match server.with_machine_result(|m| m.start_guest()) {
Ok(_) => Response::ok(),
Err(e) => Response::err(e),
}
}

/// Guest state and monitor readiness, from the same `Workstation::status` the
/// monitor's `machine-status` prints.
fn cmd_status(server: &CiServer) -> Response {
match server.with_machine(|m| m.status()) {
Ok(status) => Response::data(status.to_json()),
Err(e) => Response::err(e),
}
}
Expand Down
16 changes: 16 additions & 0 deletions src/ip32_machine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ pub struct O2Machine {
disks: Vec<crate::snapshot::DiskRef>,
/// The flash file, recorded in a snapshot's manifest.
flash_path: Option<String>,
/// Where the machine is in its start/stop lifecycle (see
/// `Workstation::lifecycle`); never `Running`, which `status` reads from
/// the CPU.
lifecycle: crate::machine_state::GuestState,
}

/// What an O2 snapshot's manifest names as its machine.
Expand Down Expand Up @@ -342,6 +346,7 @@ impl O2Machine {
controller_slot: None,
boot,
paused: Arc::new(AtomicBool::new(false)),
lifecycle: crate::machine_state::GuestState::Idle,
disks,
flash_path: o2.flash.clone(),
})
Expand Down Expand Up @@ -434,6 +439,11 @@ impl O2Machine {
}

pub fn start(&mut self) {
// Publish "starting" before the monitor binds, so a caller polling
// readiness can tell "not started" from "startup under way".
if let Ok(starting) = self.lifecycle.transition(crate::machine_state::GuestState::Starting) {
self.lifecycle = starting;
}
self.running.store(true, Ordering::SeqCst);
self.monitor.clone().start_server(format!("127.0.0.1:{}", self.monitor_port));
let d = self.display.clone();
Expand Down Expand Up @@ -521,6 +531,10 @@ impl O2Machine {
eprintln!("iris: O2 stopped with rendering engine writes still queued");
}
self.bus.save_flash();
// A machine that was never started has nothing to halt.
if self.lifecycle != crate::machine_state::GuestState::Idle {
self.lifecycle = crate::machine_state::GuestState::Halted;
}
}

/// Let the service thread look after the devices again.
Expand Down Expand Up @@ -762,6 +776,8 @@ impl Workstation for O2Machine {
self.controller_up.store(true, Ordering::SeqCst);
}
fn monitor_addr(&self) -> Option<std::net::SocketAddr> { self.monitor.bound_addr() }
fn lifecycle(&self) -> crate::machine_state::GuestState { self.lifecycle }
fn set_lifecycle(&mut self, state: crate::machine_state::GuestState) { self.lifecycle = state; }

fn get_display(&self) -> Option<Arc<dyn crate::gfx_display::GfxDisplay>> { Some(O2Machine::get_display(self)) }
fn get_ps2(&self) -> Arc<crate::dev::ps2::Ps2Controller> { O2Machine::get_ps2(self) }
Expand Down
Loading
Loading