Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0aeeffb
acpiserver reads the battery and the AC adapter through the machine's…
Japabu Oct 10, 2026
6465a1d
A battery's read returns the lines it says, so a line said every poll…
Japabu Oct 10, 2026
5d53ebf
Merge remote-tracking branch 'origin/main' into wt/toyos-battery
Japabu Oct 10, 2026
e73c65d
A take of the Global Lock the firmware holds waits for its release, a…
Japabu Oct 10, 2026
adc25ee
Merge remote-tracking branch 'origin/main' into wt/toyos-battery
Japabu Oct 10, 2026
0ce2eec
An Acquire of \_GL waits its own TimeoutValue however long, and a pre…
Japabu Oct 10, 2026
0f1b059
The track records the 1 s bound on an unbounded take of the Global Lo…
Japabu Oct 10, 2026
0d6cb71
The wait for the firmware's release clears an enable only once its st…
Japabu Oct 10, 2026
e74eac3
An Acquire of \_GL waits no longer than its evaluation has left to wait
Japabu Oct 10, 2026
d89ef2f
The table in acpi_press_across_a_lock_wait waits for the press, not 5 s
Japabu Oct 10, 2026
60077a1
Merge remote-tracking branch 'origin/main' into wt/toyos-battery
Japabu Oct 10, 2026
83edfe5
Merge origin/main at 8b2969d0f into wt/toyos-battery
Japabu Oct 10, 2026
737f2c9
Merge origin/main at bb3344d1f into the battery: the battery reads th…
Japabu Oct 11, 2026
b91c58a
A controller port refused to the drain detaches it from AML too, unde…
Japabu Oct 11, 2026
d2c7267
The battery fixture's _REG writes the controller, as the T14's does
Japabu Oct 11, 2026
cce064f
battery_read demands no Global Lock take of the battery's methods, an…
Japabu Oct 11, 2026
788521a
The census is a line find answers, a query waiting at arm runs on the…
Japabu Oct 11, 2026
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,9 +9,15 @@ opened: 2026-10-04
A machine's AML names whether its firmware also reaches the embedded
controller from SMM: the controller device's `_GLK` returns 1, and then every
transaction is taken under the FACS global lock (ACPI 6.5 §6.5.7, §5.2.10.1).
`/system/bin/acpiserver` interprets no AML, so it cannot evaluate `_GLK`, and
it takes no lock: on such a machine a query of the server's and one of the
firmware's can interleave on the same two ports.
`/system/bin/acpiserver` evaluates `_GLK` only to leave the controller's
space unattached to AML, and so read no battery, wherever it is not 0
(`attach`, `userland/acpiserver/src/main.rs`), and takes no lock around its
own queries: on such a machine a query of the server's and one of the
firmware's can interleave on the same two ports. The lock itself is there to
take: the interpreter's host takes it for a Lock field or an Acquire of
`\_GL`, waiting out the firmware's hold on its `GBL_STS`
(`userland/acpiserver/src/host.rs`); nothing takes it around a controller
transaction.

On the T14 no table names a `_GLK` at all: a byte search of its DSDT and every
SSDT for the name finds none (the tables captured before its wipe, read
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ every CPU every 2.2 s

The ACPI server loads a machine's DSDT and SSDTs, evaluates `\_S5` and hands
the kernel its sleep type, which the kernel powers the machine off with and
has from nowhere else; it evaluates nothing else of their AML yet. What else
has from nowhere else. Beyond that it evaluates only the devices it serves:
the walk's `_HID`, `_STA`, `_CRS` and `_GPE`, the embedded controller's
`_GLK` and `_REG`, its query methods where the power button is a control
method device, and the batteries' and AC adapters' `_STA`, `_BIX` or
`_BIF`, `_BST` and `_PSR`. What else
ToyOS takes from them it takes another way: the loader asks UEFI for the root
bridges' windows `_CRS` would name (`bootloader/src/rootbridge.rs`). Nothing
reads `_CST`, which names a CPU's C-states.
Expand Down Expand Up @@ -263,17 +267,35 @@ otherwise pay to find again:
What the server's load of the tables, on the T14 and through the kernel's
mediated access, leaves open:

- **The server waits for no release of the Global Lock** (the orchestrator's
ruling, not the owner's). A take that finds the firmware holding the lock
leaves it the request, as ACPI 6.5 §5.2.10.1 has it, and is then denied by
name and counted in the server's ledger; the access under it is not made,
and the table or method that asked is refused. The wait that section
describes, for the SCI the firmware raises with `GBL_STS`, is not in the
tree: no tier reached it, and the T14's load took the lock 241 times and
found the firmware holding it in none. Owner: this stage. **Exit**: a
machine's log carries the denial, `the Global Lock: the firmware holds it`,
which the `acpi_tables_loaded` row reds on as on every refusal; the wait
comes back with the test that reaches its port sequence.
- **The controller's `_REG(3, 1)` runs on a namespace no `_INI` has run in.**
The server tells the controller its space is there as it attaches it to
AML (`attach`, `userland/acpiserver/src/main.rs`), before the battery's
read, and runs no `_INI`: the T14's controller `_INI` calls the firmware
through `SMI_CMD`, which the host does not write. The scouts' dry run read that
the T14's `_REG` takes another branch before its `_INI` than after it,
reading and writing controller offset 0x03 where after it touches the
controller not at all; Linux runs it after. Owner: this stage. **Exit**:
the slice that runs the `_STA` and `_INI` walk runs it before `_REG`.

- **A take of the Global Lock that the AML sets no bound on ends the server
where the firmware holds the lock past 1 s** (`RELEASE`,
`userland/acpiserver/src/host.rs`). The bound is this server's guess, not
a measurement. The firmware is meant to hold the lock for the run of one
SMI handler, and no hold has been seen. On the T14 the load takes the
lock 241 times, the namespace's `_HID` walk, the controller's `_GLK` and
`_REG` and the battery's first read together 3 times, and the battery's
and adapter's methods alone none; the firmware held it for none of them.
Linux's `ff_gbl_lock` counter there read 0 at 2 minutes of uptime, and a reading
after a longer session is still owed. A Lock field and an `Acquire(\_GL,
0xFFFF)` take with no bound. An Acquire with a TimeoutValue waits that
long, at most what the evaluation has left of its 10 s, and the
TimeoutValue is charged to it. Owner: this
stage. **Exit**: a hold measured on the T14, either by a contended take
in the count the server logs, which covers the load and the power
sources' census alone, or by `ff_gbl_lock` after a long Linux session.
That measurement sets the bound, or replaces it with no bound if the
firmware never holds the lock.

- **A press during the load waits for it, and a power-off asked during it is
refused.** The server arms the power button
and then loads the tables before it serves an SCI, so a press in that time
Expand Down Expand Up @@ -343,7 +365,8 @@ mediated access, leaves open:

What the firmware call, which the kernel makes for the server where its AML
stores a byte to `SMI_CMD`, leaves open. No AML the server runs makes one
now: its host asks the kernel for three port writes and denies every other
now: its host asks the kernel for three port writes, and for the embedded
controller's transactions where AML reaches its space, and denies every other
write by name (`FORWARDED` in `userland/acpiserver/src/host.rs`), the
index registers 0x72 and 0xCD6, a byte each, and the POST port's word.
Those are what an AMD laptop's AML wrote, measured outside the tree on its
Expand Down
129 changes: 129 additions & 0 deletions tests/common/power.rs
Original file line number Diff line number Diff line change
Expand Up @@ -760,3 +760,132 @@ pub fn acpi_power_button(test_config: &Path) -> Result<(), String> {
eprintln!(" [power] the press: {ACPI_PRESSED}");
Ok(())
}

/// The PM1a event block QEMU's q35 firmware sets, whose status
/// [`ssdt_acquiring_the_global_lock`] reads: the table is built before the
/// boot, which then says the block it found.
const Q35_PM1A_EVENTS: u16 = 0x600;

/// The Acquire's TimeoutValue, in ms; the wait for the press before it has
/// the rest of the 10 s one evaluation may spend asleep, past which the
/// load refuses the table.
const ACQUIRE_MS: u16 = 2000;

/// What the server says of the table's Notify, which runs only where the
/// Acquire before it timed out.
const ACQUIRE_TIMED_OUT: &str = "Notify(\\_SB_.GLW_, 0x80) for the first time";

/// What it says of the load's takes: one, and it found the firmware holding
/// the lock.
const ONE_TAKE_CONTENDED: &str = "; took the Global Lock 1 times, 1 of them from the firmware;";

/// An SSDT (ACPI 6.5 §5.2.11.2) whose definition block declares the device
/// `\_SB.GLW` and, as it loads, waits for `PWRBTN_STS` (Table 4.13) in the
/// PM1a event block at `pm1`, then Acquires `\_GL` within `ms`, giving it
/// back where that took it and Notifying `\_SB.GLW` with 0x80 where it timed
/// out:
///
/// ```text
/// Device (\_SB.GLW) {}
/// OperationRegion (PM1S, SystemIO, pm1, 2)
/// Field (PM1S, WordAcc, NoLock, Preserve) { Offset (1), PBST, 1 }
/// While (!PBST) { Sleep (1) }
/// If (Acquire (\_GL, ms)) { Notify (\_SB.GLW, 0x80) } Else { Release (\_GL) }
/// ```
fn ssdt_acquiring_the_global_lock(pm1: u16, ms: u16) -> Vec<u8> {
const GL: &[u8] = b"\\_GL_";
// `\` and a DualNamePrefix (§20.2.2).
const GLW: &[u8] = b"\\\x2E_SB_GLW_";
const PM1S: &[u8] = b"PM1S";
const PBST: &[u8] = b"PBST";
// A PkgLength of one byte (§20.2.4): itself and what follows, under 64.
let package = |op: &[u8], body: &[u8]| [op, &[u8::try_from(body.len() + 1).expect("a short package")], body].concat();
// SystemIO (1) at a WordConst, of a ByteConst's length.
let region = [&[0x5B, 0x80], PM1S, &[0x01, 0x0B], &pm1.to_le_bytes(), &[0x0A, 0x02]].concat();
// WordAcc (2); a ReservedField (0) of 8 bits, then PBST of 1.
let field = package(&[0x5B, 0x81], &[PM1S, &[0x02, 0x00, 0x08], PBST, &[0x01]].concat());
// LNot (0x92) of PBST; Sleep of One (0x01).
let wait = package(&[0xA2], &[&[0x92], PBST, &[0x5B, 0x22, 0x01]].concat());
let acquire = [&[0x5B, 0x23], GL, &ms.to_le_bytes()].concat();
let notify = [&[0x86], GLW, &[0x0A, 0x80]].concat();
let release = [&[0x5B, 0x27], GL].concat();
let aml = [
package(&[0x5B, 0x82], GLW),
region,
field,
wait,
package(&[0xA0], &[acquire, notify].concat()),
package(&[0xA1], &release),
]
.concat();
let length = u32::try_from(36 + aml.len()).expect("a short table");
let mut table = [b"SSDT".as_slice(), &length.to_le_bytes(), &[2, 0], b"TOYOS ", b"GLWAIT ", &1u32.to_le_bytes(), b"TOYS", &1u32.to_le_bytes(), &aml].concat();
// The byte at 9 makes the whole table sum to zero.
table[9] = table.iter().fold(0u8, |sum, &b| sum.wrapping_sub(b));
table
}

/// The number after `after` on a line, in hex.
fn hex_after(line: &str, after: &str) -> Result<u64, String> {
line.split_once(after)
.map(|(_, rest)| rest.trim_start_matches("0x"))
.and_then(|rest| u64::from_str_radix(&rest[..rest.find(|c: char| !c.is_ascii_hexdigit()).unwrap_or(rest.len())], 16).ok())
.ok_or_else(|| format!("no number after {after:?} on {line:?}"))
}

/// A press latched before the server's wait for the firmware's release of
/// the Global Lock is served once the wait ends at its Acquire's timeout: the
/// wait clears the press's enable once it finds its status latched, takes
/// the SCI the press raised, and puts every enable back after, so the press's
/// status raises the SCI again for `serve`. `tests/acpicase` with the test
/// kernel's actuator for the firmware's side of the lock, which
/// `acpi_mediated`'s `held` arm stages owned before it hands the server the
/// claim; the table QEMU adds waits as it loads for the press, sent once the
/// server has armed, and only then Acquires the lock within [`ACQUIRE_MS`].
/// Then the press must power the machine off, after the Acquire said it timed
/// out.
pub fn acpi_press_across_a_lock_wait(probe: (String, Vec<u8>)) -> Result<(), String> {
let case = super::compile::repo_root().join("tests/acpicase");
let mut qemu = QemuInstance::boot_with_options(
&case,
&[],
&[],
BootOptions {
// The test kernel, for the Global Lock's actuator, as `acpi_mediated_access` boots it.
kernel_params: &["i8042-withheld"],
ready_marker: "acpi: the Global Lock is the FACS's at ",
extra_root_files: vec![
probe,
// The arm's name is the file's: the probe asks whether it is there.
("share/acpi_mediated_held".to_string(), b"held\n".to_vec()),
],
acpi_tables: vec![ssdt_acquiring_the_global_lock(Q35_PM1A_EVENTS, ACQUIRE_MS)],
qmp: true,
..Default::default()
},
);
let boot = serial::Serial::named("the boot", qemu.boot_log().to_string());
let pm1 = hex_after(boot.must_say("acpi: the ACPI row: PM1a events ")?, "PM1a events ")?;
if pm1 != u64::from(Q35_PM1A_EVENTS) {
return Err(format!("the PM1a event block is at {pm1:#x}, where the table QEMU was handed reads {Q35_PM1A_EVENTS:#x}"));
}
// Opened before the press: QMP delivers no event emitted before its
// client connected.
let mut stop = qemu::QmpShutdown::open(qemu.qmp_socket(), qemu.budget(qemu::GUEST_QUIET));
let mut console = format!("{}\n", qemu.boot_log());
qemu::await_marker(&mut qemu, &mut console, ACPI_ARMED, "the ACPI server arming")?;
stop.power_button();
let pressed_at = console.len();
ended(&mut qemu, &mut stop, &mut console, SHUTTING_DOWN, "guest-shutdown")?;
let after = serial::Serial::named("the press", console[pressed_at..].to_string());
after.must_say(ONE_TAKE_CONTENDED)?;
after.must_say_after(ACQUIRE_TIMED_OUT, ACPI_PRESSED)?;
after.must_be_clean()?;
let whole = serial::Serial::named("the boot and the press", console);
if acpi_tables_loaded(&whole, &whole, Q35_S5_SUPPLIED)? < 2 {
return Err(format!("the server loaded no table beside the DSDT, so not the one QEMU was handed:\n{}", whole.text()));
}
eprintln!(" [power] {}", whole.must_say(ONE_TAKE_CONTENDED)?.trim());
eprintln!(" [power] {}", whole.must_say_after(ACQUIRE_TIMED_OUT, ACPI_PRESSED)?.trim());
Ok(())
}
17 changes: 17 additions & 0 deletions tests/common/qemu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,10 @@ pub struct BootOptions {
/// update's ROOT of this many bytes: a machine that updates itself.
/// `None` for every guest whose subject is not the update.
pub second_slot: Option<u64>,
/// Whole ACPI tables, header and checksum included, that QEMU lists
/// beside its own (`-acpitable`): firmware AML a guest's own tables do
/// not carry. q35's alone.
pub acpi_tables: Vec<Vec<u8>>,
/// The instant the machine's RTC starts at, as QEMU's `-rtc base=` takes
/// it, and the host's own UTC with `None`.
pub rtc_base: Option<&'static str>,
Expand Down Expand Up @@ -1144,6 +1148,7 @@ impl Default for BootOptions {
extra_root_files: Vec::new(),
psci_trace: None,
second_slot: None,
acpi_tables: Vec::new(),
rtc_base: None,
}
}
Expand Down Expand Up @@ -1427,6 +1432,9 @@ impl QemuInstance {
};

let sockets = Sockets::new(&options);
for (i, table) in options.acpi_tables.iter().enumerate() {
fs::write(acpi_table(&sockets.dir, i), table).expect("[qemu] write an added ACPI table");
}
let screendump = test_dir.join(format!("screen-{seq}.ppm"));

// Per-instance, not a fixed /tmp path: a screen test waits on this
Expand Down Expand Up @@ -2402,6 +2410,10 @@ fn qemu_command(
if options.gdb_stub {
qemu.arg("-s");
}
assert!(options.acpi_tables.is_empty() || arch == Arch::X86_64, "an added ACPI table is q35's");
for i in 0..options.acpi_tables.len() {
qemu.arg("-acpitable").arg(format!("file={}", acpi_table(socket_dir, i).display()));
}
if let Some(socket) = qmp_socket {
qemu.arg("-qmp")
.arg(format!("unix:{},server,nowait", socket.display()));
Expand Down Expand Up @@ -2433,6 +2445,11 @@ impl Sockets {
}
}

/// The `i`th of [`BootOptions::acpi_tables`], as a file in a boot's own `dir`.
fn acpi_table(dir: &Path, i: usize) -> PathBuf {
dir.join(format!("acpi-{i}.aml"))
}

/// The QMP socket `options` asks for, named in `dir`.
fn qmp_socket(dir: &Path, options: &BootOptions) -> Option<PathBuf> {
options.qmp.then(|| dir.join("qmp.sock"))
Expand Down
29 changes: 28 additions & 1 deletion tests/toyos-rust-tests/src/bin/acpi_mediated.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,18 @@
//! [`OUTLIVED`] has a child supply this guest's own sleep type and exit, and
//! the parent, which supplied none, ask for the power-off after the child's
//! claim and one more have been released.
//!
//! **A press is served across the server's wait for the firmware's release**
//! ([`held`]), on a boot whose ROOT carries [`HELD`] and whose firmware lists
//! a table that takes the Global Lock at its load: the firmware's side of the
//! lock is staged owned before `/system/bin/acpiserver` is handed the claim.
//! The press, the wait's timeout and the power-off are the harness's to read.

use std::os::toyos::process::CommandExt;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

use toyos::endow::{Endowments, SYSCAP_LABEL};
use toyos::endow::{Endowments, DEV_PREFIX, SYSCAP_LABEL};
use toyos::syscap::SysCap;
use toyos::{AsHandle, Device};
use toyos_abi::acpi::{pci_address, Access, AcpiInfo, Refused, Space, Width, UNLISTED};
Expand All @@ -65,6 +71,9 @@ const CLAIM_LABEL: &str = "acpi-claim";
/// binary's name and takes no argument.
const OUTLIVED: &str = "/system/share/acpi_mediated_outlived";

/// The file whose presence on ROOT makes this boot [`held`]'s.
const HELD: &str = "/system/share/acpi_mediated_held";

/// `EFI_MEMORY_TYPE`s: what the kernel hands out as RAM, and ACPI's two.
const USABLE: [u8; 5] = [1, 2, 3, 4, 7];
const ACPI_RECLAIM: u8 = 9;
Expand Down Expand Up @@ -112,6 +121,7 @@ impl Holder {
fn main() {
match std::env::args().nth(1).as_deref() {
None if std::fs::exists(OUTLIVED).expect("acpi: ask ROOT for this boot's arm") => outlived(),
None if std::fs::exists(HELD).expect("acpi: ask ROOT for this boot's arm") => held(),
None => probe(),
Some("keeper") => keeper(),
Some("supplier") => supplier(),
Expand Down Expand Up @@ -629,3 +639,20 @@ fn outlived() {
let refused = toyos::power::stop(toyos::power::Stop::Shutdown);
panic!("acpi: the power-off was refused: {refused:?}");
}

/// What [`held`] says once the server has the claim, with the firmware's
/// side of the lock owned.
const STAGED: &str = "acpi: the firmware's side of the Global Lock staged owned, and the server handed the claim";

fn held() {
let cap: SysCap = Endowments::get().take(SYSCAP_LABEL).expect("test-runner endows a device-minting capability");
let claim = claim(&cap);
assert_eq!(syscall::debug_with(debug_action::ACPI_FIRMWARE_LOCK, debug_action::FIRMWARE_OWNS), 0, "acpi: the lock was not free before the server ran");
let mut server = Command::new("/system/bin/acpiserver")
.endow(&format!("{DEV_PREFIX}{}", DeviceType::Acpi.class_name()), claim.into_raw().0)
.spawn()
.expect("acpi: spawn the server");
println!("{STAGED}");
let ended = server.wait().expect("acpi: wait for the server");
panic!("acpi: the server ended {ended:?}, where a press powers the machine off");
}
Loading
Loading