hybrid: the driver is killed, and the driver comes back

H2 of docs/plan/hybrid.md -- the milestone the plan itself calls the one
that proves the whole idea, and the one that cannot be skipped.

    # cat /proc/schinit
    1 4 11 os.schos.vgpud
    # kill 4
    schinitd: restarting os.schos.vgpud
    vgpud: claimed
    vgpud: pid 8 drew a test pattern from userspace

The test pattern ends in a twelve-block tally of the drawing process's
pid, because a second run that redraws an identical picture is
indistinguishable from a first run that never stopped. Read off the
virtio-gpu scanout across four generations it says 4, 8, 10, 12, and the
DMA arena hands back the same physical address every time.

What it took:

* A halt the kernel can apply to a device it does not understand.
  Clearing PCI_CMD_BUS_MASTER is the only one there is, and it has to
  happen before the DMA goes back to the arena -- a dead driver's device
  keeps reading a framebuffer the kernel is about to give to somebody
  else. Claim sets the bit, release clears it; pcitest shows it in a bm
  column, with a third state so a failed read is never printed as off.

* A refusal. vgpu_release() asks vga_fb_restore() whether there is a
  framebuffer to put the console back on, and declines to hand over the
  device if there is not. The rule that the kernel keeps a minimal
  driver for everything on the path to a shell is worth writing down
  only if something enforces it.

* The DMA arena not being owned by two allocators at once. This is the
  bug that stopped H2: freevm() frees every mapped page below PHYSTOP
  and the arena is the top 16 MB of exactly that range, so a DMA block
  mapped into a driver went onto the page allocator's free list when the
  driver died, while pciuser_release_all() handed the same pages back to
  the arena. The restarted vgpud asked for its framebuffer, got pages
  kalloc() had already given out -- including its own page tables -- and
  dma_alloc()'s zeroing wiped them, so it died inside the ioctl on the
  return from a syscall whose stack had stopped existing. do_dma() now
  records a VMA with no_free set, the way shared memory does.

* One exit status. KeepAlive must restart a driver that crashed and must
  not restart one that has looked at the machine and found its device
  absent: on hardware with no virtio-gpu that was fifteen spawns in
  forty-five seconds, each printing a line to the console, for ever.
  SCHINIT_EXIT_NORESTART is 78, sysexits.h's EX_CONFIG. It is not a new
  manifest key -- the wait status has always travelled from PID 1 to the
  supervisor, and note_exit() simply stopped discarding it.

The milestone's third clause, "the compositor reattaches", is not done
and is not claimed: there is no compositor running. That is recorded in
the plan as a Schcom milestone rather than counted here.

Closes the register row "A released PCI device" in docs/plan/1.0.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
This commit is contained in:
Nexoniarz 2026-09-14 00:05:43 +02:00
commit d1f50c4da3
15 changed files with 442 additions and 40 deletions

View file

@ -93,12 +93,31 @@ main(int argc, char *argv[])
}
if (argc == 1) {
puts(" # bdf vendor:device irq class driver\n");
puts(" # bdf vendor:device irq class bm driver\n");
for (int i = 0; i < count; i++) {
struct pciuser_info in;
in.index = (unsigned int)i;
if (ioctl(fd, PCIIOC_INFO, &in) < 0)
continue;
// Bus mastering, straight out of the command register.
//
// Worth a column of its own because it is the one bit that says
// whether a device can still touch memory, and the whole lifecycle
// turns on it: claiming a device sets it, releasing one clears it,
// and a driver that died leaves a device that must not be left
// reading buffers the kernel is about to hand to somebody else.
struct pciuser_cfg cfg;
cfg.index = (unsigned int)i;
cfg.offset = 0x04; // PCI_COMMAND
cfg.value = 0;
// Three states, not two. A read that failed is not the same as a
// bit that is clear, and printing them the same way is how a
// column stops being evidence -- which is exactly what happened
// the first time this was run.
const char *bm = "? ";
if (ioctl(fd, PCIIOC_CFG, &cfg) == 0)
bm = (cfg.value & 0x0004) ? "on " : "-- ";
puts(" "); putn(i); puts(" ");
puthex(in.bus, 2); puts(":"); puthex(in.slot, 2);
puts("."); puthex(in.func, 1); puts(" ");
@ -106,6 +125,7 @@ main(int argc, char *argv[])
if (in.irq == 0xFF) puts(" - "); else { putn(in.irq); puts(" "); }
puts(class_name(in.class));
puts(" ");
puts(bm);
puts(in.driver[0] ? in.driver : "-");
puts("\n");
}

View file

@ -455,7 +455,6 @@ job_by_cookie(unsigned int cookie)
static void
note_exit(unsigned int cookie, int pid, int status)
{
(void)status;
struct job *j = job_by_cookie(cookie);
if (!j)
return; // not ours, or already noticed
@ -465,6 +464,16 @@ note_exit(unsigned int cookie, int pid, int status)
if (!j->keep_alive)
return;
// A job that says it cannot run on this machine is believed. The status is
// wait(2)-encoded: the low seven bits are the signal that killed it, so a
// job that was killed or faulted never matches this and is restarted the
// way KeepAlive promises. See SCHINIT_EXIT_NORESTART.
if ((status & 0x7f) == 0 && ((status >> 8) & 0xff) == SCHINIT_EXIT_NORESTART) {
out("schinitd: "); out(j->label);
out(" has nothing to do on this machine; not restarting\n");
return;
}
// Throttle. A job that exits immediately and forever would otherwise spin
// the machine; launchd's ThrottleInterval exists for exactly this.
int ticks = j->throttle * 100;

View file

@ -26,6 +26,7 @@
#include "posix.h"
#include "pciuser.h"
#include "virtio.h"
#include "schinit.h"
// --- small output, since lib/ has no printf ---------------------------------
@ -64,6 +65,17 @@ die(const char *why)
exit();
}
// Not a failure: a report. There is no virtio-gpu here, or the kernel will not
// give it up, and neither of those changes by trying again -- so this tells the
// supervisor to stop, rather than being restarted every three seconds for the
// rest of the uptime. See SCHINIT_EXIT_NORESTART in include/schinit.h.
static void
nothing_to_do(const char *why)
{
puts("vgpud: "); puts(why); puts("\n");
exit_group(SCHINIT_EXIT_NORESTART);
}
// --- register access --------------------------------------------------------
static inline unsigned char r8 (volatile unsigned char *p, int o) { return p[o]; }
@ -453,6 +465,12 @@ draw_test_pattern(void)
{
volatile unsigned int *fb = (volatile unsigned int *)fb_uaddr;
// A tally of this instance's pid along the bottom edge, because the thing
// H2 has to show is that the driver *came back* -- and a second run that
// redraws an identical picture is indistinguishable from a first run that
// never stopped. One block per bit, low bit at the left.
int mypid = getpid();
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
unsigned int c;
@ -474,6 +492,14 @@ draw_test_pattern(void)
fb[y * width + x] = c;
}
}
for (int b = 0; b < 12; b++) {
unsigned int c = (mypid >> b) & 1 ? 0x4ADE80 : 0x12151C; /* SCH_OK */
for (int y = height - 24; y < height - 8; y++)
for (int x = 16 + b * 24; x < 16 + b * 24 + 16; x++)
if (y >= 0 && x < width)
fb[y * width + x] = c;
}
}
// --- main -------------------------------------------------------------------
@ -502,7 +528,7 @@ main(int argc, char *argv[])
}
}
if (index_ < 0)
die("no virtio-gpu on this machine");
nothing_to_do("no virtio-gpu on this machine");
puts("vgpud: virtio-gpu at "); puthex(info.bus, 2); puts(":");
puthex(info.slot, 2); puts("."); puthex(info.func, 1);
@ -513,7 +539,7 @@ main(int argc, char *argv[])
// back on screen before returning, so there is never a moment with no
// display -- see vgpu_release() and vga_fb_restore().
if (ioctl(pcifd, PCIIOC_CLAIM, &index_) < 0)
die("claim refused -- the kernel driver would not release it");
nothing_to_do("claim refused -- the kernel driver would not release it");
puts("vgpud: claimed\n");
// The info is re-read because claiming is what enables memory space and
@ -532,7 +558,9 @@ main(int argc, char *argv[])
draw_test_pattern();
present();
puts("vgpud: drew a test pattern from userspace\n");
puts("vgpud: pid ");
putn(getpid());
puts(" drew a test pattern from userspace\n");
if (argc > 1 && argv[1][0] == '-' && argv[1][1] == 'x') {
// Exit without releasing, to show what a crashed driver leaves behind:
@ -542,10 +570,17 @@ main(int argc, char *argv[])
exit();
}
// Nothing to serve yet -- the compositor is what will connect here, over
// mechanism 1, which does not exist. Holding the device is the honest
// thing to do until then: releasing it would put the console back and make
// it look like this never worked.
// Hold the device and wait.
//
// Mechanism 1 exists now, so what belongs here eventually is an AF_UNIX
// socket a compositor connects to. It is not built, and building it before
// there is a compositor to judge it is the mistake this plan spends a
// section warning about -- so this sleeps instead, and holding the device
// is the honest state: releasing it would put the console back and make it
// look like none of this ever worked.
//
// Being killed here is not an error path, it is the H2 path: schinit
// restarts this process, and the device has to come back with it.
for (;;)
sleep(100);
}

View file

@ -113,6 +113,44 @@ console falls back to the VBE framebuffer.
**No syscall on that path knows it is talking about a GPU.** The kernel hands
over a PCI device; the process decides what it is.
### Taking it back
A driver that dies is the case this whole arrangement exists for, so release is a
symmetric operation rather than a courtesy. Three things happen that are not
obvious from "the process exited":
**Bus mastering is cleared before the DMA is freed.** A device whose driver has
just died is still writing into memory the kernel is about to hand to somebody
else. The kernel cannot quiesce a device it does not understand — that is a
different register on every chip — but it can clear `PCI_CMD_BUS_MASTER`, which
stops the writes whatever the device is, and it is the only halt available to a
kernel that knows nothing but the header. So `PCIIOC_CLAIM` sets the bit and
release clears it, in that order relative to freeing the memory. `pcitest` prints
the bit in a `bm` column, with a separate mark for "could not read it", because a
failed read rendered as "off" is worse than no column at all.
**DMA memory a driver mapped is not `freevm()`'s to release.** `freevm()` frees
every mapped page below `PHYSTOP` and the DMA arena is the top 16 MB of exactly
that range, so a DMA block left for it went onto the page allocator's free list
while `pciuser_release_all()` handed the same pages back to the arena — two
allocators, one set of pages. `PCIIOC_DMA` records a VMA with `no_free` set, the
same way shared memory does, and `proc_release_shared()` unmaps the range before
the page tables are torn down. The same rule covers every future case: **anything
mapped into a process that the process does not own the pages of needs a VMA, or
exit will free it twice.**
**A device that is the only display will not be released.** `vgpu_release()` asks
`vga_fb_restore()` whether there is a framebuffer to put the console back on, and
declines if the answer is no. The rule that the kernel keeps a minimal driver for
everything on the path to a shell is only worth writing down if something
enforces it.
Coming back is a job, not a kernel feature. `etc/schinit/vgpud.job` carries
`KeepAlive = yes`, so killing the driver is the entire test: `schinitd` starts
another one, it claims the device, and the pid of whichever process drew the
frame is legible in the frame itself. See
[../plan/hybrid.md](../plan/hybrid.md), "The driver came back".
### What this is not
**It is not a security boundary.** There is no IOMMU, so a process holding a DMA
@ -170,9 +208,11 @@ existing mechanisms for keeping that true mechanically rather than by intention.
kernel is an ordinary program and can be started whenever — but it reaches its
device through `/dev/pci` and publishes nothing to the class vtables, so the
console cannot be handed to one yet.
- **A released device does not come back by itself.** `PCIIOC_RELEASE`, or the
process exiting, leaves the interrupt line masked and the in-kernel driver
unbound. Something has to start a replacement; nothing watches for one.
- **The kernel does not rebind its own driver.** `PCIIOC_RELEASE`, or the process
exiting, leaves the interrupt line masked and the in-kernel driver unbound —
the console stays wherever the release put it. A *supervisor* can start a
replacement driver, and `schinitd` does; the kernel itself watches for
nothing.
- **Only two class vtables are defined**: `display_ops` and `netdev_ops`.
Storage and input devices appear in the list with `ops = 0` and are reached
through their own subsystems instead.

View file

@ -229,7 +229,7 @@ and this is not a language).
| `Program` | The binary to execute |
| `Args` | argv, space separated. argv[0] is conventionally the basename, so `Args = sh` for `/bin/sh` |
| `RunAtLoad` | Start it when schinit loads |
| `KeepAlive` | Restart it when it exits |
| `KeepAlive` | Restart it when it exits — unless it exits `SCHINIT_EXIT_NORESTART`, below |
| `Disabled` | Load it but do not start it |
| `ThrottleInterval` | Seconds between respawns (default 1) |
| `ControllingTerminal` | Claim the console |
@ -272,6 +272,31 @@ subject to `ThrottleInterval` — a job that exits immediately and forever would
otherwise spin the machine. launchd defaults to 10 seconds; 1 is enough here,
where the common case is a shell someone just exited on purpose and expects back.
### One exit status the supervisor reads
Throttling stops a restart loop from costing CPU. It does not stop it being a
loop, and a job that can never succeed on this machine will print its reason
every `ThrottleInterval` for the rest of the uptime — measured at fifteen spawns
in forty-five seconds for `vgpud` on a machine with no virtio-gpu.
So a `KeepAlive` job that exits **`SCHINIT_EXIT_NORESTART`** (78, sysexits.h's
`EX_CONFIG`) is not restarted, and the supervisor says which job stopped and
why:
```
vgpud: no virtio-gpu on this machine
schinitd: os.schos.vgpud has nothing to do on this machine; not restarting
```
The distinction it draws is the right one: the status is wait(2)-encoded, so a
job that was **killed or faulted** has a signal in the low bits, never matches,
and comes back exactly as `KeepAlive` promises. Only a job that exited
deliberately, saying this machine is not one it can run on, is believed.
This is not a new manifest key and there is no policy knob. The wait status has
always travelled from PID 1 to the supervisor inside `struct schinit_reply`;
`note_exit()` simply stopped discarding it.
## The fallback
With no manifests found, the supervisor says so and starts `/bin/sh` on the

View file

@ -94,9 +94,14 @@ The galling ones. The work exists, is tested, and nothing uses it.
| | Where it stopped |
|---|---|
| **`foundations.md`** | Item 1 built. Items 2 (a shareable memory object), 3 (`AF_UNIX` in the filesystem namespace), 4 and 5 open. **Wayland stops at item 2.** |
| **`hybrid.md`** | All four mechanisms built, H1 done. **H2 — kill the driver on purpose and watch it come back — is the milestone the plan itself calls the one that proves the whole idea**, and it is not done. |
| **`hybrid.md`** | All four mechanisms built. H1 and H2 done — the driver can be killed and comes back, which is the milestone the plan calls the one that proves the whole idea. H3 (a modeset API) and H4 (USB) open. |
| **Schcom v0** | 715 lines that composite, over a wire format that is superseded. Frozen: it runs, nothing more is added, and it is kept only because it is the one thing in the tree that has ever put a client's pixels on screen. |
| **A released PCI device** | `PCIIOC_RELEASE` leaves the line masked and the in-kernel driver unbound. Nothing starts a replacement. Half a handover. |
**Closed since this register was written.** *A released PCI device* — release is
now symmetric with claim (bus mastering cleared before the DMA is freed, a
refusal when the device is the only display), a supervised job starts the
replacement, and it is described in
[../architecture/devices.md](../architecture/devices.md) under "Taking it back".
### Named in a "what it does not do" and still true

View file

@ -172,6 +172,79 @@ version that survives a licensing conversation.
---
## The driver came back
H2 is the milestone this plan calls the one that cannot be skipped, and the
reason is that H1 proves only half of the idea. A driver that runs in a process
is interesting; a driver that runs in a process, *dies*, and is replaced by
another process that picks the device up is the thing the whole hybrid argument
rests on.
`etc/schinit/vgpud.job` makes it a supervised job -- `KeepAlive = yes` -- so
killing it is the whole test:
```
# cat /proc/schinit
1 4 11 os.schos.vgpud
# kill 4
schinitd: restarting os.schos.vgpud
vgpud: virtio-gpu at 00:03.0
vgpud: claimed
vgpud: display is 1280x800
vgpud: scanout is ours, framebuffer at physical 0x3eceb000
vgpud: pid 8 drew a test pattern from userspace
```
The test pattern ends in a twelve-block tally of the drawing process's pid,
because a second run that redraws an identical picture is indistinguishable
from a first run that never stopped. Read off the virtio-gpu scanout across
four generations of the driver it says 4, 8, 10, 12. The same physical address
comes back every time, which is the DMA arena reclaiming cleanly rather than
drifting.
### What it took that H1 had not needed
- **A halt the kernel can apply without knowing what the device is.** A driver
that dies leaves its device bus-mastering into memory the kernel is about to
hand to somebody else. The kernel cannot quiesce a device it does not
understand, but it can clear `PCI_CMD_BUS_MASTER`, and that is enough to stop
the writes. `pciuser_stop()` does it before the DMA is freed, not after, and
claim/release became a symmetric pair: claiming sets the bit, releasing
clears it. `pcitest` grew a `bm` column so the state is visible rather than
assumed.
- **A refusal, when there is nothing to fall back to.** `vgpu_release()` now
asks `vga_fb_restore()` whether there is a framebuffer to put the console on,
and declines to hand the device over if there is not. The rule that the
kernel keeps a minimal driver for everything on the path to a shell is only
worth writing down if something enforces it.
- **The DMA arena not being handed to two allocators at once.** This is the bug
that stopped H2 for a day, and it is worth writing out: `freevm()` frees
every mapped page below `PHYSTOP`, and the DMA arena is the top 16 MB of
exactly that range. So a DMA block mapped into a driver and left for
`freevm()` went onto the page allocator's free list when the driver died,
while `pciuser_release_all()` handed the same pages back to the arena. Both
allocators then believed they owned it. The restarted `vgpud` asked for its
4 MB framebuffer, got pages `kalloc()` had already given out -- including its
own page tables -- and `dma_alloc()`'s zeroing wiped them, so the driver died
inside the ioctl, on the return from a syscall whose stack had stopped
existing. `do_dma()` now records a VMA with `no_free` set, the same way
shared memory does, so `proc_release_shared()` unmaps the range before the
page tables are torn down.
### The clause that is not true yet
The milestone says *"the compositor reattaches"*. It does not, because there is
no compositor running: Schcom is v0 and is not started at boot. What has been
shown is the two parts that do not depend on it -- the screen falls back to
VBE, and the driver restarts and takes the device back. During the gap the VBE
head carries a live console and the virtio-gpu head still shows the dead
driver's last frame, which is exactly what a device nobody is driving does.
Reattachment is a Schcom milestone, not a hybrid one, and it is recorded that
way rather than counted here.
---
## The mechanisms
Four, and only the fourth is large.
@ -279,13 +352,13 @@ start because the display driver is a process that needs a compositor.
## Milestones
| | |
|---|---|
| **H0** | Mechanisms 2, 3 and 4, each with a test that exercises it from a userspace program against a device nothing else is using. |
| **H1** | A userspace virtio-gpu driver, publishing `display_ops` across the channel. Schcom talks to it and does not know the difference. |
| **H2** | Kill the driver process on purpose. The screen falls back to VBE, the driver restarts, the compositor reattaches. **This is the milestone that proves the whole idea**, and it is the one that cannot be skipped. |
| **H3** | A modeset API, which is the thing an out-of-kernel GPU driver makes affordable and an in-kernel one never got. |
| **H4** | USB, same shape. |
| | | |
|---|---|---|
| **H0** | Mechanisms 2, 3 and 4, each with a test that exercises it from a userspace program against a device nothing else is using. | done |
| **H1** | A userspace virtio-gpu driver, publishing `display_ops` across the channel. Schcom talks to it and does not know the difference. | done, except that Schcom does not talk to it -- `cmd/vgpud` holds the device and draws its own frame |
| **H2** | Kill the driver process on purpose. The screen falls back to VBE, the driver restarts, the compositor reattaches. **This is the milestone that proves the whole idea**, and it is the one that cannot be skipped. | done, less the compositor -- see "The driver came back" |
| **H3** | A modeset API, which is the thing an out-of-kernel GPU driver makes affordable and an in-kernel one never got. | |
| **H4** | USB, same shape. | |
---

View file

@ -19,6 +19,14 @@ key = value; # starts a comment, keys are case-insensitive.
Path a unix socket schinit will hold on the job's behalf and
start the job on first connect
A KeepAlive job that exits with status 78 (sysexits.h's EX_CONFIG) is not
restarted, and schinit says which job stopped and that it stopped on purpose.
This is for a program that has looked at the machine and found nothing here it
can run against -- a driver whose device is absent, say -- which would otherwise
report the same thing every ThrottleInterval for the rest of the uptime. A job
that was killed or crashed carries a signal in its status, never matches, and
comes back exactly as KeepAlive promises.
Socket sections are parsed but not yet acted on -- that is milestone 2, and
demand launching is the whole reason this is shaped after launchd rather than
sysvinit. The key exists now so manifests do not have to change later.

31
etc/schinit/vgpud.job Normal file
View file

@ -0,0 +1,31 @@
# The virtio-gpu driver, as an ordinary process.
#
# docs/plan/hybrid.md milestone H2. This manifest is the "the driver restarts"
# half of it: KeepAlive means schinit brings vgpud back when it dies, and
# because PID 1 forks every job, a driver that crashes is reaped and replaced by
# the same machinery that looks after a shell. Nothing about driver recovery is
# special -- that is the point.
#
# Enabled unconditionally, and safe to be, on two machines it is not written
# for: one with no virtio-gpu at all, and one where the GPU is the only display
# and the kernel therefore refuses to hand it over (virtio_gpu.c's release
# hook). In both cases vgpud says so and exits SCHINIT_EXIT_NORESTART, which
# stops KeepAlive from starting it again -- the alternative, measured, is
# fifteen spawns in forty-five seconds, each printing a line to the console for
# the rest of the uptime. No per-machine configuration either way.
Label = os.schos.vgpud
Program = /bin/vgpud
Args = vgpud
RunAtLoad = yes
KeepAlive = yes
# Higher than a shell's second, because a driver that cannot claim its device
# will fail identically every time and there is no user waiting on it. Low
# enough that a kill is answered while you are still looking at the screen.
ThrottleInterval = 3
# It has no terminal and wants none: a driver that seized the console would be
# doing exactly what moving it out of the kernel was meant to stop.
StandardOut = /dev/tty1
StandardError = /dev/tty1

View file

@ -172,6 +172,7 @@ uint16 pci_read16(uint8, uint8, uint8, uint8);
uint8 pci_read8(uint8, uint8, uint8, uint8);
void pci_write32(uint8, uint8, uint8, uint8, uint32);
void pci_enable(struct pci_dev *, uint16 bits);
void pci_disable(struct pci_dev *, uint16 bits);
uint8 pci_find_cap(struct pci_dev *, uint8 id);
int pci_msi_enable(struct pci_dev *, int vector, int cpu);
int pci_count(void);
@ -611,7 +612,7 @@ void platform_register_devices(uint64 fb_addr);
// from registry.c; everything else draws through include/gfx.h.
void vga_fb_init(uint64 phys_addr);
uint64 vga_fb_phys(void);
void vga_fb_restore(void);
int vga_fb_restore(void); // 0 = there is no fallback
uint64 vga_fb_size(void);
void e820_init(void);

View file

@ -50,6 +50,26 @@
#define SCHINIT_EXITED 130 // struct schinit_reply -- a child has been reaped
#define SCHINIT_PING 131 // struct schinit_hello -- answer within the deadline
// Exit statuses a job can use to tell the supervisor something.
//
// KeepAlive restarts a job that crashed or was killed, which is the whole
// point of it for a driver. It must not restart one that has looked at the
// machine and reported there is nothing here for it to do: a driver whose
// device is absent fails identically every time, and a supervisor that tries
// anyway spends the rest of the uptime doing so -- 15 spawns in 45 seconds,
// on hardware with no virtio-gpu, each one printing a line to the console.
//
// 78 is sysexits.h's EX_CONFIG, "something was found in an unconfigured or
// misconfigured state", which is exactly what a driver with no device has
// found. Borrowed rather than invented, so a program written against any
// other Unix already means the right thing by it.
//
// Note what this is *not*: it is not a new manifest key and not a new policy
// knob. The wait status has always travelled from PID 1 to the supervisor in
// struct schinit_reply, and note_exit() has always thrown it away. This is
// the other half of a thing that was already built.
#define SCHINIT_EXIT_NORESTART 78
// The heartbeat.
//
// docs/architecture/init.md, "The heartbeat" -- the failure the two-init scheme

View file

@ -77,6 +77,24 @@ pci_enable(struct pci_dev *d, uint16 bits)
pci_write32(d->bus, d->slot, d->func, PCI_COMMAND, cmd | bits);
}
// The other direction, and PCI_CMD_BUS_MASTER is the reason it exists.
//
// Clearing that bit is the only way this kernel can stop a device it does not
// understand from touching memory. Everything else about halting a device is
// device-specific -- a different register on every chip -- but bus mastering is
// in the standard config header of every PCI device ever made, and clearing it
// means the next DMA cycle does not happen.
//
// That makes it the correct thing to do when a userspace driver dies: the
// kernel is about to take its DMA buffers back, and a device still scanning out
// of them would read whatever is put there next. See kernel/dev/pciuser.c.
void
pci_disable(struct pci_dev *d, uint16 bits)
{
uint32 cmd = pci_read32(d->bus, d->slot, d->func, PCI_COMMAND);
pci_write32(d->bus, d->slot, d->func, PCI_COMMAND, cmd & ~(uint32)bits);
}
// ---------------------------------------------------------------------------
// Capabilities
//

View file

@ -131,16 +131,49 @@ pciuser_mmio_ok(uint64 phys, uint64 len)
return 0;
}
// Stop a device dead, without knowing anything about it.
//
// Clearing PCI_CMD_BUS_MASTER is the one halt available to a kernel that does
// not know what the device is: bus mastering lives in the standard config
// header of every PCI device, and with it clear the device cannot start another
// DMA cycle. Everything else -- quiescing a queue, parking a scanout, waiting
// for in-flight work -- is device-specific, which is exactly the knowledge that
// was moved out of the kernel on purpose.
//
// **This has to happen before the DMA buffers go back to the arena.** A driver
// that died left its device configured and running: virtio-gpu with a scanout
// set keeps reading its framebuffer for ever, and that framebuffer is about to
// be handed to whoever asks next. The failure is not a crash, which is why it
// is worth writing down -- it is a display showing another program's memory, or
// a NIC writing packets into a buffer a different driver is using.
static void
pciuser_stop(int idx)
{
const struct pci_dev *d = pci_get(idx);
if (d)
pci_disable((struct pci_dev *)d, PCI_CMD_BUS_MASTER);
}
// Release everything a process held. Called when it exits, because a driver
// that crashed must not keep its device locked away from its replacement --
// that is the entire point of running drivers in processes.
void
pciuser_release_all(struct proc *p)
{
// Devices first, and outside the lock, because stopping one is a config-space
// write and the ordering against the DMA free below is the whole point.
for (int i = 0; i < NPCI; i++) {
int mine;
acquire(&pciuser_lock);
for (int i = 0; i < NPCI; i++)
if (owner[i] == p)
mine = (owner[i] == p);
if (mine)
owner[i] = 0;
release(&pciuser_lock);
if (mine)
pciuser_stop(i);
}
acquire(&pciuser_lock);
// And its interrupt line -- unregistered, and left MASKED.
//
@ -277,7 +310,12 @@ do_claim(uint64 argp)
// Bus mastering and memory space, because a driver that has to ask for this
// separately will forget, and the symptom -- DMA that silently does nothing
// -- costs a day to find.
pci_enable((struct pci_dev *)d, 0x0006);
//
// This is also what makes a device usable again after the driver holding it
// died: release clears bus mastering to stop the device, so claim has to put
// it back. The two are a pair, and the second driver to run is the one that
// finds out if they are not.
pci_enable((struct pci_dev *)d, PCI_CMD_MEMORY | PCI_CMD_BUS_MASTER);
return 0;
}
@ -291,9 +329,17 @@ do_release(uint64 argp)
return -ENODEV;
acquire(&pciuser_lock);
if (owner[idx] == myproc())
int mine = (owner[idx] == myproc());
if (mine)
owner[idx] = 0;
release(&pciuser_lock);
// Same halt as the death path, for the same reason. A driver that releases
// politely has usually quiesced its device already, but "usually" is not a
// property the kernel can check, and the cost of being sure is one config
// write.
if (mine)
pciuser_stop(idx);
return 0;
}
@ -314,33 +360,63 @@ do_dma(uint64 argp)
if (size == 0 || size > 4 * 1024 * 1024)
return -EINVAL;
// A slot to remember it by, found before allocating: handing out memory that
// cannot be recorded is how the leak this fixes got written in the first
// place.
struct proc *p = myproc();
// Two slots, both found before anything is allocated: one to remember the
// block by, one to remember the mapping by. Handing out memory that cannot
// be recorded is how the leak this fixes got written in the first place.
//
// The VMA is not bookkeeping. It is the thing that keeps this memory out of
// kfree()'s hands. freevm() frees every mapped page below PHYSTOP, and the
// DMA arena is the top 16 MB of exactly that range -- so a DMA block left
// for freevm() to find goes onto the page allocator's free list when the
// process dies, while pciuser_release_all() hands the same pages back to
// the arena. Both allocators then believe they own it.
//
// That is what broke H2. The restarted vgpud asked for a 4 MB framebuffer,
// dma_alloc() returned pages kalloc() had already given to somebody --
// including the page tables of the process doing the asking -- and zeroed
// them, so the driver died inside this ioctl, on the return from a syscall
// whose stack had just stopped existing.
//
// no_free says these pages are not freevm()'s to release;
// proc_release_shared() unmaps the range before the page tables go away.
struct dmablk *blk = 0;
acquire(&pciuser_lock);
for (int i = 0; i < PCIUSER_MAXDMA; i++)
if (!dmablk[i].proc) { blk = &dmablk[i]; blk->proc = myproc(); break; }
if (!dmablk[i].proc) { blk = &dmablk[i]; blk->proc = p; break; }
release(&pciuser_lock);
if (!blk)
return -ENOMEM;
void *kva = dma_alloc(size, PGSIZE);
if (!kva) {
struct vma *v = 0;
for (int i = 0; i < NVMA; i++)
if (!p->vma[i].used) { v = &p->vma[i]; break; }
if (!v) {
blk->proc = 0;
return -ENOMEM;
}
// Its own mapping in the caller, taken from the same bump allocator mmap
// uses so the two cannot collide.
struct proc *p = myproc();
if (p->mmap_next == 0)
p->mmap_next = 0x40000000;
uint64 va = p->mmap_next;
if (va + size < va || va + size > KERNBASE) {
blk->proc = 0;
return -ENOMEM;
}
void *kva = dma_alloc(size, PGSIZE);
if (!kva) {
blk->proc = 0;
return -ENOMEM;
}
p->mmap_next += size;
uint64 phys = dma_phys(kva);
if (mappages(p->pgdir, (void *)va, size, phys, PTE_W | PTE_U) < 0) {
vm_unmap_norelease(p->pgdir, va, size); // whatever part of it took
dma_free(kva, size);
blk->proc = 0;
return -ENOMEM;
@ -348,6 +424,12 @@ do_dma(uint64 argp)
blk->kva = kva;
blk->size = size;
v->addr = va;
v->length = size;
v->used = 1;
v->no_free = 1;
v->shmid = -1;
req->uaddr = va;
req->paddr = phys;
return 0;
@ -422,11 +504,25 @@ do_cfg(uint64 argp)
unsigned int idx = c->index;
if (idx >= (unsigned int)pci_count() || idx >= NPCI)
return -ENODEV;
if (owner[idx] != myproc())
return -EACCES;
if (c->offset > 0xFC)
return -EINVAL;
// The standard header -- the first 64 bytes -- is readable without holding
// the device. Refusing it was inconsistent rather than careful: PCIIOC_INFO
// already hands back the vendor, device, class, interrupt line and every BAR,
// all of which are read out of exactly these bytes. Denying the same bytes in
// a different shape protects nothing and makes the bus unenumerable by
// anything that is not already driving something.
//
// Past 0x40 is the capability list and whatever the device puts after it.
// That is device-specific, it is what a driver reads to find its own
// registers, and it belongs to whoever holds the device.
//
// Reads only. There is no config *write* here at all, and that is the line
// that matters -- a write is how you move a BAR out from under the kernel.
if (c->offset >= 0x40 && owner[idx] != myproc())
return -EACCES;
const struct pci_dev *d = pci_get((int)idx);
if (!d)
return -ENODEV;

View file

@ -225,13 +225,19 @@ const struct display_ops vga_display_ops = {
// This is the fallback rule from docs/plan/hybrid.md made real: the kernel
// keeps a minimal driver for everything on the path to a shell, so a fancy
// driver leaving degrades to a working screen instead of to no screen.
void
int
vga_fb_restore(void)
{
struct fb_info info;
// Zero means there is no framebuffer to go back to, and the caller has to
// care. It used to return void and say nothing here, which reads as "done"
// -- so on a machine whose only display is the one being handed over, the
// eviction went ahead and left the console drawing into a driver that had
// just freed its memory. The answer is not to try harder; it is to say no,
// which is what the caller now does.
if (!fb || !fb_width || !fb_height)
return; // nothing was ever set up; nothing to go back to
return 0;
info.ptr = fb;
info.phys = fb_phys;
@ -241,4 +247,5 @@ vga_fb_restore(void)
info.width = fb_width;
info.height = fb_height;
gfx_attach_scanout(&info);
return 1;
}

View file

@ -537,7 +537,21 @@ vgpu_release(struct pci_dev *d)
if (!g)
return -1;
vga_fb_restore();
// The rule from docs/plan/hybrid.md, enforced rather than described: **the
// kernel keeps a minimal driver for everything on the path to a shell.** If
// there is no framebuffer to fall back to, this device is the only screen the
// machine has, and handing it to a process that might crash would make a
// failed driver indistinguishable from a dead machine.
//
// Refusing here is what lets the userspace driver be started unconditionally
// at boot: on hardware where it is safe it takes the GPU, and on hardware
// where it is not it is told no and says so, rather than each machine needing
// to be configured for its own display topology.
if (!vga_fb_restore()) {
cprintf("virtio-gpu: refusing to release -- it is the only display, and "
"there is no framebuffer to fall back to\n");
return -1;
}
// Status 0 is the virtio reset. The spec requires a driver to wait for the
// device to acknowledge by reading the register back as zero.