From 47acf983609cf3aa1391770810ce566b67f78311 Mon Sep 17 00:00:00 2001 From: Thomas Stokes Date: Sun, 9 Aug 2026 01:02:24 +0800 Subject: [PATCH] wasm: keep the console available during initramfs startup --- tools/wasm/src/virtio/console.ts | 214 +--------------------- tools/wasm/test/wasm.test.ts | 299 ++++--------------------------- 2 files changed, 38 insertions(+), 475 deletions(-) diff --git a/tools/wasm/src/virtio/console.ts b/tools/wasm/src/virtio/console.ts index dbe2a1b5ff075d..ab71e8f8d9b31d 100644 --- a/tools/wasm/src/virtio/console.ts +++ b/tools/wasm/src/virtio/console.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT -import { Struct, U16LE, U32LE } from "../bytes.ts"; +import { Struct, U16LE } from "../bytes.ts"; import { assert } from "../util.ts"; import { VirtioController, @@ -11,37 +11,13 @@ import { const Features = { SIZE: 1n << 0n, - MULTIPORT: 1n << 1n, } as const; -const ControlEvents = { - DEVICE_READY: 0, - PORT_ADD: 1, - PORT_READY: 3, - CONSOLE_PORT: 4, - RESIZE: 5, - PORT_OPEN: 6, -} as const; - -const PORT_0 = 0; - class ConsoleConfig extends Struct({ columns: U16LE, rows: U16LE, - max_nr_ports: U32LE, }) {} -class ControlMessage extends Struct({ - id: U32LE, - event: U16LE, - value: U16LE, -}) {} - -interface QueuedControl { - event: number; - bytes: Uint8Array; -} - /** * The console device: a `VirtioDevice` whose dimensions can be changed * after boot. @@ -62,11 +38,6 @@ export interface ConsoleDevice extends VirtioDevice { * send. `output` is a `WritableStream` of bytes from the tty — what a * terminal would render. Either may be `null`: `consoleDevice(null, output)` * is a read-only console, such as a boot log. - * - * The multiport control protocol is used even though this device has one - * port. It gives the guest a way to tell the host exactly when the console - * port is open, so input queued during guest setup is not delivered early - * and discarded. */ export function consoleDevice( input: ReadableStream | null, @@ -78,11 +49,9 @@ export function consoleDevice( const config = new ConsoleConfig(config_bytes); config.columns = 80; config.rows = 24; - config.max_nr_ports = 1; let pumping: Promise | undefined; let reader_cancellation: Promise | undefined; let writer_abortion: Promise | undefined; - let stopped = false; // Host input and guest receive buffers arrive independently, so keep both // in JS-side queues and match them up in `flush_input` whenever either @@ -91,109 +60,15 @@ export function consoleDevice( // console port instead of being dropped. const receive_chains: VirtqueueChain[] = []; const pending_input: Uint8Array[] = []; - const control_chains: VirtqueueChain[] = []; - const outbound_control: QueuedControl[] = []; - let device_ready = false; - let port_ready = false; - let guest_connected = false; - - function control_packet(event: number, value: number) { - const bytes = new Uint8Array(ControlMessage.size); - const message = new ControlMessage(bytes); - message.id = PORT_0; - message.event = event; - message.value = value; - return { event, bytes }; - } - - function resize_packet() { - const packet = control_packet(ControlEvents.RESIZE, 0); - const bytes = new Uint8Array(ControlMessage.size + 4); - bytes.set(packet.bytes); - const resized = new DataView( - bytes.buffer, - bytes.byteOffset, - bytes.byteLength, - ); - resized.setUint16(ControlMessage.size, config.columns, true); - resized.setUint16(ControlMessage.size + 2, config.rows, true); - return { event: ControlEvents.RESIZE, bytes }; - } - - function write_control(chain: VirtqueueChain, packet: Uint8Array) { - const buffers = [...chain]; - assert(buffers.length > 0, "empty control receiver"); - assert( - buffers.every(({ writable }) => writable), - "control receiver must be writable", - ); - assert( - buffers.reduce((length, { array }) => length + array.byteLength, 0) >= - packet.byteLength, - "control buffer too small", - ); - - let offset = 0; - for (const { array } of buffers) { - const n = Math.min(array.byteLength, packet.byteLength - offset); - array.set(packet.subarray(offset, offset + n)); - offset += n; - if (offset === packet.byteLength) break; - } - chain.release(packet.byteLength); - } - - function flush_control() { - while (control_chains.length > 0 && outbound_control.length > 0) { - const chain = control_chains.shift()!; - const packet = outbound_control.shift()!; - write_control(chain, packet.bytes); - } - } - - function send_control(event: number, value: number) { - outbound_control.push(control_packet(event, value)); - flush_control(); - } - - function queue_resize() { - const packet = resize_packet(); - const index = outbound_control.findIndex( - ({ event }) => event === ControlEvents.RESIZE, - ); - if (index < 0) { - outbound_control.push(packet); - return; - } - - outbound_control[index]!.bytes = packet.bytes; - for (let i = outbound_control.length - 1; i > index; i--) { - if (outbound_control[i]!.event === ControlEvents.RESIZE) { - outbound_control.splice(i, 1); - } - } - } - - function announce_port_state() { - outbound_control.push(control_packet(ControlEvents.CONSOLE_PORT, 1)); - outbound_control.push(resize_packet()); - outbound_control.push(control_packet(ControlEvents.PORT_OPEN, 1)); - flush_control(); - } function reset() { + // Receive descriptors belong to the old queue, but host input belongs to + // the console and must survive the reset Linux performs during probing. receive_chains.length = 0; - control_chains.length = 0; - outbound_control.length = 0; - device_ready = false; - port_ready = false; - guest_connected = false; } function flush_input() { - while ( - guest_connected && receive_chains.length > 0 && pending_input.length > 0 - ) { + while (receive_chains.length > 0 && pending_input.length > 0) { const chain = receive_chains.shift()!; const chunk = pending_input[0]!; const [desc, trailing] = chain; @@ -236,85 +111,12 @@ export function consoleDevice( } } - function notify_control_receive(queue: Virtqueue) { - for (const chain of queue) control_chains.push(chain); - flush_control(); - } - - function read_control(chain: VirtqueueChain) { - const bytes = new Uint8Array(ControlMessage.size); - let offset = 0; - for (const { array, writable } of chain) { - assert(!writable, "control transmitter must be readable"); - const n = Math.min(array.byteLength, bytes.byteLength - offset); - bytes.set(array.subarray(0, n), offset); - offset += n; - } - assert(offset === bytes.byteLength, "control message too small"); - return new ControlMessage(bytes); - } - - function notify_control_transmit(queue: Virtqueue) { - for (const chain of queue) { - const message = read_control(chain); - - switch (message.event) { - case ControlEvents.DEVICE_READY: - if (message.value === 1) { - if (device_ready) break; - device_ready = true; - send_control(ControlEvents.PORT_ADD, 1); - } else if (message.value === 0) { - device_ready = false; - port_ready = false; - guest_connected = false; - outbound_control.length = 0; - } - break; - case ControlEvents.PORT_READY: - if (message.id !== PORT_0) break; - if (message.value === 1) { - if (port_ready) break; - port_ready = true; - announce_port_state(); - } else if (message.value === 0) { - port_ready = false; - guest_connected = false; - outbound_control.length = 0; - } - break; - case ControlEvents.PORT_OPEN: - if ( - message.id === PORT_0 && port_ready && - (message.value === 0 || message.value === 1) - ) { - guest_connected = message.value === 1; - flush_input(); - } - break; - } - - // This is a device-readable chain; no bytes were written into it. - chain.release(0); - } - } - const controller = new VirtioController( + { deviceId: 3, features: Features.SIZE, config: config_bytes }, { - deviceId: 3, - features: Features.SIZE | Features.MULTIPORT, - config: config_bytes, - }, - { - queues: [ - reader ? notify_input : () => {}, - notify_output, - notify_control_receive, - notify_control_transmit, - ], + queues: [reader ? notify_input : () => {}, notify_output], reset, stop() { - stopped = true; reset(); pending_input.length = 0; reader_cancellation ??= reader?.cancel(); @@ -344,10 +146,6 @@ export function consoleDevice( config.columns = columns; config.rows = rows; controller.updateConfig(config_bytes); - if (!stopped && port_ready) { - queue_resize(); - flush_control(); - } } return controller.expose({ resize }); diff --git a/tools/wasm/test/wasm.test.ts b/tools/wasm/test/wasm.test.ts index 1a58572b218660..6599acd5e88a3e 100644 --- a/tools/wasm/test/wasm.test.ts +++ b/tools/wasm/test/wasm.test.ts @@ -254,291 +254,56 @@ test("console input is held until the guest opens its port", async () => { }, }), ); - const description = virtio_device_description(device); - assert.notEqual(description.features & (1n << 1n), 0n); - assert.equal(new DataView(description.config.buffer).getUint32(4, true), 1); - device.resize(100, 40); + const delivered = Promise.withResolvers(); const imports = virtio_imports({ memory: console_memory, devices: [device], - trigger_irq() {}, + trigger_irq() { + delivered.resolve(); + }, on_error(error) { throw error; }, }); - const RECEIVE_RING = 0; - const CONTROL_RECEIVE_RING = 128; - const CONTROL_TRANSMIT_RING = 512; - const descriptor_at = (ring: number, slot: number) => - new DataView(console_memory.buffer, ring + slot * 16, 16); - const queue_descriptor = ( - ring: number, - slot: number, - address: number, - length: number, - writable: boolean, - next = false, - ) => { - const descriptor = descriptor_at(ring, slot); - descriptor.setBigUint64(0, BigInt(address), true); - descriptor.setUint32(8, length, true); - descriptor.setUint16(12, slot, true); - descriptor.setUint16( - 14, - (1 << 7) | (writable ? 1 << 1 : 0) | (next ? 1 : 0), - true, - ); - }; - - interface QueuedBuffer { - ring_slot: number; - parts: { address: number; length: number }[]; - } - let control_receive_slot = 0; - let control_receive_address = 2048; - const control_buffers: QueuedBuffer[] = []; - const queue_control_buffer = (...lengths: number[]) => { - const buffer: QueuedBuffer = { - ring_slot: control_receive_slot, - parts: [], - }; - for (const [index, length] of lengths.entries()) { - const address = control_receive_address; - control_receive_address += 32; - buffer.parts.push({ address, length }); - queue_descriptor( - CONTROL_RECEIVE_RING, - control_receive_slot++, - address, - length, - true, - index + 1 < lengths.length, - ); - } - control_buffers.push(buffer); - }; - const read_control_buffer = (index: number) => { - const buffer = control_buffers[index]!; - const bytes = new Uint8Array( - buffer.parts.reduce((length, part) => length + part.length, 0), - ); - let offset = 0; - for (const part of buffer.parts) { - bytes.set( - new Uint8Array(console_memory.buffer, part.address, part.length), - offset, - ); - offset += part.length; - } - return new DataView(bytes.buffer); - }; - const assert_control = ( - index: number, - event: number, - value: number, - length = 8, - ) => { - const packet = read_control_buffer(index); - assert.equal(packet.getUint32(0, true), 0, "control packet targets port 0"); - assert.equal(packet.getUint16(4, true), event); - assert.equal(packet.getUint16(6, true), value); - assert.equal( - descriptor_at(CONTROL_RECEIVE_RING, control_buffers[index]!.ring_slot) - .getUint32(8, true), - length, - "used length is the control packet length", - ); - return packet; - }; - - let control_transmit_slot = 0; - let control_transmit_address = 4096; - const send_control = async ( - id: number, - event: number, - value: number, - split_at?: number, - ) => { - const bytes = new Uint8Array(8); - const packet = new DataView(bytes.buffer); - packet.setUint32(0, id, true); - packet.setUint16(4, event, true); - packet.setUint16(6, value, true); - const lengths = split_at === undefined ? [8] : [split_at, 8 - split_at]; - const first_slot = control_transmit_slot; - let offset = 0; - for (const [index, length] of lengths.entries()) { - const address = control_transmit_address; - control_transmit_address += 32; - new Uint8Array(console_memory.buffer, address, length).set( - bytes.subarray(offset, offset + length), - ); - queue_descriptor( - CONTROL_TRANSMIT_RING, - control_transmit_slot++, - address, - length, - false, - index + 1 < lengths.length, - ); - offset += length; - } - imports.notify(0, 3); - for (let i = 0; i < 10; i++) { - if ( - descriptor_at(CONTROL_TRANSMIT_RING, first_slot).getUint16(14, true) & - (1 << 15) - ) break; - await Promise.resolve(); - } - assert.notEqual( - descriptor_at(CONTROL_TRANSMIT_RING, first_slot).getUint16(14, true) & - (1 << 15), - 0, - "guest-to-host control chain was released", - ); - assert.equal( - descriptor_at(CONTROL_TRANSMIT_RING, first_slot).getUint32(8, true), - 0, - "guest-to-host control used length is zero", - ); - }; - - imports.enable_vring(0, 0, 4, RECEIVE_RING, 1); - imports.enable_vring(0, 2, 16, CONTROL_RECEIVE_RING, 2); - imports.enable_vring(0, 3, 16, CONTROL_TRANSMIT_RING, 3); - - queue_descriptor(RECEIVE_RING, 0, 1024, 8, true); + // A packed vring with one receive descriptor (at 64, length 4) that is not + // yet available: the guest console port is not open. + const descriptor = new DataView(console_memory.buffer); + descriptor.setBigUint64(0, 64n, true); + descriptor.setUint32(8, 4, true); + descriptor.setUint16(12, 0, true); + descriptor.setUint16(14, 0, true); + imports.enable_vring(0, 0, 1, 0, 1); imports.notify(0, 0); - // The first host control packet and the first guest control packet are both - // split across descriptors to exercise modern virtqueue framing. - queue_control_buffer(3, 5); - for (let i = 0; i < 10; i++) queue_control_buffer(16); - imports.notify(0, 2); - // The host writes "hi" before the guest opens /dev/hvc0, and the input - // handler holds it despite an available receive descriptor. + // handler runs while the descriptor is still unavailable. input_controller.enqueue(new TextEncoder().encode("hi")); await Promise.resolve(); - await Promise.resolve(); - assert.deepEqual([...new Uint8Array(console_memory.buffer, 1024, 2)], [0, 0]); - - // DEVICE_READY gets only PORT_ADD. The current size was changed before the - // guest became ready, so it is retained for the later RESIZE packet. - await send_control(0xffffffff, 0, 0); - assert.equal( - descriptor_at(CONTROL_RECEIVE_RING, control_buffers[0]!.ring_slot) - .getUint32(8, true), - 3, - "DEVICE_READY failure emits no port state", - ); - await send_control(0xffffffff, 0, 1, 3); - assert_control(0, 1, 1); - assert.deepEqual([...new Uint8Array(console_memory.buffer, 1024, 2)], [0, 0]); - await send_control(0xffffffff, 0, 1); - assert.equal( - descriptor_at(CONTROL_RECEIVE_RING, control_buffers[1]!.ring_slot) - .getUint32(8, true), - 16, - "duplicate DEVICE_READY emits no duplicate PORT_ADD", - ); - // PORT_READY is answered synchronously, without timing gaps, with console - // designation, current size, and host-open state in protocol order. - await send_control(1, 3, 1); - assert.equal( - descriptor_at(CONTROL_RECEIVE_RING, control_buffers[1]!.ring_slot) - .getUint32(8, true), - 16, - "an unknown port emits no console state", - ); - await send_control(0, 3, 1); - assert_control(1, 4, 1); - const resize = assert_control(2, 5, 0, 12); - assert.equal(resize.getUint16(8, true), 100); - assert.equal(resize.getUint16(10, true), 40); - assert_control(3, 6, 1); - assert.deepEqual([...new Uint8Array(console_memory.buffer, 1024, 2)], [0, 0]); - await send_control(0, 3, 1); - assert.equal( - descriptor_at(CONTROL_RECEIVE_RING, control_buffers[4]!.ring_slot) - .getUint32(8, true), - 16, - "duplicate PORT_READY emits no duplicate port state", - ); + // Linux resets the device while probing it. The old descriptor must be + // discarded, while input queued by the host survives for the replacement + // queue that represents the opened console port. + imports.reset(0); + imports.disable_vring(0, 0); + const replacement_ring = 128; + const replacement = new DataView(console_memory.buffer, replacement_ring, 16); + replacement.setBigUint64(0, 256n, true); + replacement.setUint32(8, 4, true); + replacement.setUint16(12, 0, true); + replacement.setUint16(14, (1 << 7) | (1 << 1), true); + imports.enable_vring(0, 0, 1, replacement_ring, 1); + imports.notify(0, 0); + const undelivered = new Promise((resolve) => setTimeout(resolve, 50)); + await Promise.race([delivered.promise, undelivered]); - // Only the guest's PORT_OPEN opens the input gate. - await send_control(0, 6, 1); + assert.deepEqual([...new Uint8Array(console_memory.buffer, 64, 2)], [0, 0]); + const buffer = new Uint8Array(console_memory.buffer, 256, 4); assert.deepEqual( - [...new Uint8Array(console_memory.buffer, 1024, 2)], + [...buffer.slice(0, 2)], [0x68, 0x69], + "input must be held until the console port opens", ); - - // Closing the guest side holds new input. Reset invalidates the old receive - // chain but preserves that host input for the restored console. - await send_control(0, 6, 0); - queue_descriptor(RECEIVE_RING, 1, 1056, 8, true); - imports.notify(0, 0); - input_controller.enqueue(new TextEncoder().encode("!")); - await Promise.resolve(); - await Promise.resolve(); - assert.equal(new Uint8Array(console_memory.buffer, 1056, 1)[0], 0); - - imports.reset(0); - for (let queue = 0; queue < 4; queue++) { - imports.disable_vring(0, queue); - } - new Uint8Array(console_memory.buffer, RECEIVE_RING, 4 * 16).fill(0); - new Uint8Array(console_memory.buffer, CONTROL_RECEIVE_RING, 16 * 16).fill(0); - new Uint8Array(console_memory.buffer, CONTROL_TRANSMIT_RING, 16 * 16).fill(0); - control_receive_slot = 0; - control_transmit_slot = 0; - control_buffers.length = 0; - - imports.enable_vring(0, 0, 4, RECEIVE_RING, 1); - imports.enable_vring(0, 2, 16, CONTROL_RECEIVE_RING, 2); - imports.enable_vring(0, 3, 16, CONTROL_TRANSMIT_RING, 3); - queue_descriptor(RECEIVE_RING, 0, 1088, 8, true); - imports.notify(0, 0); - for (let i = 0; i < 8; i++) queue_control_buffer(16); - imports.notify(0, 2); - - // virtcons_restore sends PORT_READY for its existing port without another - // DEVICE_READY. The host must replay all current port state, then wait for - // the restored guest's PORT_OPEN before delivering the preserved input. - await send_control(0, 3, 1); - assert_control(0, 4, 1); - const restored_resize = assert_control(1, 5, 0, 12); - assert.equal(restored_resize.getUint16(8, true), 100); - assert.equal(restored_resize.getUint16(10, true), 40); - assert_control(2, 6, 1); - assert.equal(new Uint8Array(console_memory.buffer, 1056, 1)[0], 0); - assert.equal(new Uint8Array(console_memory.buffer, 1088, 1)[0], 0); - await send_control(0, 6, 1); - assert.equal(new Uint8Array(console_memory.buffer, 1056, 1)[0], 0); - assert.equal(new Uint8Array(console_memory.buffer, 1088, 1)[0], 0x21); - - // Once ready, resize() uses the multiport RESIZE control event rather than - // relying solely on a config interrupt, which Linux intentionally ignores. - device.resize(120, 50); - const live_resize = assert_control(3, 5, 0, 12); - assert.equal(live_resize.getUint16(8, true), 120); - assert.equal(live_resize.getUint16(10, true), 50); - - // A failed PORT_READY closes the gate and emits no console state. A later - // successful readiness report refreshes the complete state immediately. - await send_control(0, 3, 0); - device.resize(132, 60); - assert.equal(read_control_buffer(4).getUint16(4, true), 0); - await send_control(0, 3, 1); - assert_control(4, 4, 1); - const refreshed_resize = assert_control(5, 5, 0, 12); - assert.equal(refreshed_resize.getUint16(8, true), 132); - assert.equal(refreshed_resize.getUint16(10, true), 60); - assert_control(6, 6, 1); - input_controller.close(); await close_virtio_device(device); });