mirror of
https://github.com/jamesstringer90/appsandbox.git
synced 2026-10-19 01:31:28 -05:00
Implement headless display open/close with agent-reported readiness
Add POST/DELETE/GET /vms/{name}/display plus the open_display/close_display/display_status/display_ready SDK methods, so a script can open and close a VM's display window like the GUI's Connect button.
Readiness is a latched flag, not a frame-channel probe: connecting to the guest frame service is itself becoming the display's single consumer, so polling it would steal the slot and blank the display. The guest agent reports the display driver state over the agent channel (idd_status) and the host latches it as idd_ready; asb_vm_idd_ready = running && agent_online && idd_ready. The Linux agent reports idd_status:ok once the user session is up (Mutter compositing).
The display window is daemon-owned (per-VM table keyed by unique_id), gated on a visible window station, and self-tears-down on the X button; displayOpen is read from the live window state, so it is honest however the window closed.
This commit is contained in:
1 parent
b95913dc1c
commit
889ccbc3a1
12 files changed
+446
-119
No files matched your search
+131
-2
@@ -31,6 +31,7 @@
|
||||
#include "asb_core.h" /* full API incl. internal VmInstance */
|
||||
#include "webview2_bridge.h" /* json_get_string/int/bool for request bodies */
|
||||
#include "prereq.h" /* prereq_check_all -> VirtualMachinePlatform check */
|
||||
#include "vm_display_idd.h" /* IDD display window, opened on demand via the API */
|
||||
|
||||
#pragma comment(lib, "httpapi.lib")
|
||||
|
||||
@@ -50,6 +51,56 @@ static char *g_ev_ring[EV_CAP]; /* recent event JSON (ring) */
|
||||
static LONG g_ev_seq = 0; /* total events ever produced */
|
||||
static volatile LONG g_stop_all = 0; /* signals SSE threads to exit */
|
||||
|
||||
/* ---- IDD display windows (POST/DELETE /v1/vms/{name}/display) ---- */
|
||||
static HINSTANCE g_hinst; /* captured in run_headless; passed to vm_display_idd */
|
||||
|
||||
/* Per-VM display handles, keyed by VM unique_id (survives g_vms[] compaction). The
|
||||
window runs on its own thread inside vm_display_idd, so it never blocks the
|
||||
single-threaded request loop. Sized to ASB_MAX_VMS; concurrent VMs are capped at
|
||||
16 by the agent table, so this never fills. */
|
||||
typedef struct { UINT64 vm_id; VmDisplayIdd *disp; } DisplayEntry;
|
||||
static DisplayEntry g_displays[ASB_MAX_VMS];
|
||||
|
||||
static DisplayEntry *display_find(UINT64 vm_id)
|
||||
{
|
||||
int i;
|
||||
if (!vm_id) return NULL;
|
||||
for (i = 0; i < ASB_MAX_VMS; i++)
|
||||
if (g_displays[i].vm_id == vm_id) return &g_displays[i];
|
||||
return NULL;
|
||||
}
|
||||
static void display_drop(DisplayEntry *e) /* destroy the window + free the slot */
|
||||
{
|
||||
if (!e) return;
|
||||
if (e->disp) vm_display_idd_destroy(e->disp);
|
||||
e->disp = NULL; e->vm_id = 0;
|
||||
}
|
||||
/* TRUE if this VM has a live (not user-closed) display window. */
|
||||
static BOOL display_is_open(UINT64 vm_id)
|
||||
{
|
||||
DisplayEntry *e = display_find(vm_id);
|
||||
return e && e->disp && vm_display_idd_is_open(e->disp);
|
||||
}
|
||||
/* If the user closed the window (X), vm_display_idd has already stopped its threads
|
||||
and freed the vsock frame connection (WM_CLOSE handler); drop our now-dead handle
|
||||
so the slot frees. Called before acting on any display request. */
|
||||
static void display_reap_stale(UINT64 vm_id)
|
||||
{
|
||||
DisplayEntry *e = display_find(vm_id);
|
||||
if (e && e->disp && !vm_display_idd_is_open(e->disp))
|
||||
display_drop(e);
|
||||
}
|
||||
/* A-gate: can this process put a window on a visible desktop? FALSE in a service /
|
||||
non-interactive session, so we refuse instead of spawning an invisible window. */
|
||||
static BOOL host_can_show_window(void)
|
||||
{
|
||||
HWINSTA ws = GetProcessWindowStation();
|
||||
USEROBJECTFLAGS uof; DWORD need = 0;
|
||||
if (ws && GetUserObjectInformationW(ws, UOI_FLAGS, &uof, sizeof(uof), &need))
|
||||
return (uof.dwFlags & WSF_VISIBLE) != 0;
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/* ---- Logging ---- */
|
||||
|
||||
static FILE *g_log;
|
||||
@@ -189,7 +240,8 @@ static int append_vm_json(char *out, int cap, int pos, VmInstance *v)
|
||||
pos += sprintf_s(out + pos, cap - pos,
|
||||
",\"state\":\"%s\",\"running\":%s,\"agentOnline\":%s,\"installComplete\":%s,"
|
||||
"\"building\":%s,\"progress\":%d,\"sshState\":%d,\"sshPort\":%lu,"
|
||||
"\"ramMb\":%lu,\"hddGb\":%lu,\"cpuCores\":%lu,\"gpuMode\":%d,\"networkMode\":%d}",
|
||||
"\"ramMb\":%lu,\"hddGb\":%lu,\"cpuCores\":%lu,\"gpuMode\":%d,\"networkMode\":%d,"
|
||||
"\"displayOpen\":%s}",
|
||||
derive_state(v),
|
||||
v->running ? "true" : "false", v->agent_online ? "true" : "false",
|
||||
v->install_complete ? "true" : "false", v->building_vhdx ? "true" : "false",
|
||||
@@ -197,7 +249,8 @@ static int append_vm_json(char *out, int cap, int pos, VmInstance *v)
|
||||
(v->ssh_key_deployed && v->ssh_state == 2) ? 4 : v->ssh_state, /* 4 = ready + key deployed */
|
||||
(unsigned long)v->ssh_port,
|
||||
(unsigned long)v->ram_mb, (unsigned long)v->hdd_gb, (unsigned long)v->cpu_cores,
|
||||
v->gpu_mode, v->network_mode);
|
||||
v->gpu_mode, v->network_mode,
|
||||
display_is_open(v->unique_id) ? "true" : "false");
|
||||
return pos;
|
||||
}
|
||||
|
||||
@@ -731,6 +784,7 @@ static int handle_request(PHTTP_REQUEST req)
|
||||
"wait for the build to finish, then delete");
|
||||
return 0;
|
||||
}
|
||||
if (dv) display_drop(display_find(dv->unique_id)); /* close its display window first */
|
||||
send_hr(req->RequestId, "delete", nu, asb_vm_delete(vm));
|
||||
return 0;
|
||||
}
|
||||
@@ -751,6 +805,78 @@ static int handle_request(PHTTP_REQUEST req)
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (wcscmp(sub, L"display") == 0) {
|
||||
VmInstance *v = asb_vm_instance(vm);
|
||||
if (!v) { send_err(req->RequestId, 404, "Not Found", "not_found", "no such VM"); return 0; }
|
||||
display_reap_stale(v->unique_id); /* drop a self-closed (X) display first */
|
||||
if (verb == HttpVerbPOST) {
|
||||
DisplayEntry *e;
|
||||
if (!host_can_show_window()) {
|
||||
send_err(req->RequestId, 409, "Conflict", "no_display",
|
||||
"no local interactive desktop is available to show the window "
|
||||
"(the daemon is in a non-interactive/service session)");
|
||||
return 0;
|
||||
}
|
||||
if (!v->running) {
|
||||
send_err(req->RequestId, 409, "Conflict", "not_running",
|
||||
"VM is not running; start it before opening the display");
|
||||
return 0;
|
||||
}
|
||||
/* Gate on display readiness -- a passive read of agent-online + the
|
||||
agent's latched idd_status (display driver up). It never probes the
|
||||
frame channel, so the check itself can't steal the single consumer
|
||||
slot or blank the display the way a connect-test would. */
|
||||
if (!asb_vm_idd_ready(vm)) {
|
||||
send_err(req->RequestId, 409, "Conflict", "display_not_ready",
|
||||
"the VM's virtual display driver is not up yet; retry shortly");
|
||||
return 0;
|
||||
}
|
||||
e = display_find(v->unique_id); /* present => still open (a closed one was reaped above) */
|
||||
if (e) {
|
||||
vm_display_idd_focus(e->disp); /* already open -> bring to front */
|
||||
} else {
|
||||
int slot; for (slot = 0; slot < ASB_MAX_VMS && g_displays[slot].vm_id; slot++) {}
|
||||
if (slot == ASB_MAX_VMS) {
|
||||
send_err(req->RequestId, 503, "Service Unavailable", "too_many",
|
||||
"no free display slot");
|
||||
return 0;
|
||||
}
|
||||
e = &g_displays[slot];
|
||||
e->disp = vm_display_idd_create(v, g_hinst, NULL);
|
||||
if (!e->disp) {
|
||||
e->vm_id = 0;
|
||||
send_err(req->RequestId, 500, "Internal Server Error", "display_failed",
|
||||
"failed to create the display window");
|
||||
return 0;
|
||||
}
|
||||
e->vm_id = v->unique_id;
|
||||
}
|
||||
send_json(req->RequestId, 200, "OK", "{\"ok\":true,\"displayOpen\":true}");
|
||||
return 0;
|
||||
}
|
||||
if (verb == HttpVerbDELETE) {
|
||||
display_drop(display_find(v->unique_id));
|
||||
send_json(req->RequestId, 200, "OK", "{\"ok\":true,\"displayOpen\":false}");
|
||||
return 0;
|
||||
}
|
||||
if (verb == HttpVerbGET) {
|
||||
/* Pollable state -- NO window, and NO frame-channel contact. "ready" is
|
||||
asb_vm_idd_ready: running + agent-online + the agent's latched
|
||||
idd_status flag, so a client can poll this on an interval (it disturbs
|
||||
nothing) and POST to open once ready. */
|
||||
char b[160];
|
||||
BOOL open = display_is_open(v->unique_id);
|
||||
BOOL ready = open || asb_vm_idd_ready(vm);
|
||||
sprintf_s(b, sizeof(b), "{\"open\":%s,\"ready\":%s}",
|
||||
open ? "true" : "false", ready ? "true" : "false");
|
||||
send_json(req->RequestId, 200, "OK", b);
|
||||
return 0;
|
||||
}
|
||||
send_err(req->RequestId, 405, "Method Not Allowed", "method",
|
||||
"use GET to poll state, POST to open the display, DELETE to close it");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* snapshots (+ branches). Tree shape:
|
||||
GET /v1/vms/{n}/snapshots list + current pointer
|
||||
POST /v1/vms/{n}/snapshots take {name?}
|
||||
@@ -1068,6 +1194,7 @@ int run_headless(HINSTANCE hInst, const wchar_t *cmdline)
|
||||
hlog(L"single-instance lock acquired.");
|
||||
|
||||
asb_set_hinstance(hInst);
|
||||
g_hinst = hInst;
|
||||
asb_set_log_callback(core_log_cb, NULL);
|
||||
asb_set_state_callback(state_cb, NULL);
|
||||
asb_set_progress_callback(progress_cb, NULL);
|
||||
@@ -1117,6 +1244,8 @@ int run_headless(HINSTANCE hInst, const wchar_t *cmdline)
|
||||
HttpTerminate(HTTP_INITIALIZE_SERVER, NULL);
|
||||
free(req);
|
||||
|
||||
{ int i; for (i = 0; i < ASB_MAX_VMS; i++) display_drop(&g_displays[i]); } /* close display windows */
|
||||
|
||||
/* The daemon OWNS its VMs: exit terminates them (asb_cleanup stops the
|
||||
per-VM monitor/agent/ssh-proxy threads, hcs_terminate_vm's every running
|
||||
VM, and closes the handles) -- the same teardown the GUI runs on
|
||||
|
||||
@@ -295,6 +295,12 @@ static DWORD WINAPI idd_probe_thread_proc(LPVOID param)
|
||||
|
||||
static void idd_probe_start(VmInstance *vm)
|
||||
{
|
||||
/* The probe exists only to AUTO-OPEN the display in the GUI: it posts
|
||||
WM_VM_IDD_READY to g_idd_probe_hwnd when the VDD comes up. With no hwnd
|
||||
(the headless daemon) there is nothing to notify, and auto-opening a window
|
||||
the CLI caller didn't ask for would be wrong -- so don't run it. Headless
|
||||
opens the display only on an explicit request, gated by asb_vm_idd_ready. */
|
||||
if (!g_idd_probe_hwnd) return;
|
||||
if (vm->idd_probe_thread) return;
|
||||
if (vm->install_complete) return;
|
||||
if (vm->is_template) return;
|
||||
@@ -3314,6 +3320,18 @@ ASB_API BOOL asb_vm_agent_online(AsbVm vm)
|
||||
return inst ? inst->agent_online : FALSE;
|
||||
}
|
||||
|
||||
ASB_API BOOL asb_vm_idd_ready(AsbVm vm)
|
||||
{
|
||||
VmInstance *inst = vm_inst(vm);
|
||||
if (!inst || !inst->running) return FALSE;
|
||||
/* Both inputs come from the guest agent, never from a frame-channel probe:
|
||||
agent_online means the guest is up, and idd_ready is latched from the
|
||||
agent's idd_status report (the display driver is "running"). Connecting to
|
||||
the frame service to "check" would itself become the single consumer and
|
||||
blank the display, so readiness is a passive read of these latched flags. */
|
||||
return inst->agent_online && inst->idd_ready;
|
||||
}
|
||||
|
||||
ASB_API BOOL asb_vm_is_building(AsbVm vm)
|
||||
{
|
||||
VmInstance *inst = vm_inst(vm);
|
||||
|
||||
@@ -154,6 +154,12 @@ ASB_API const wchar_t *asb_vm_name(AsbVm vm);
|
||||
ASB_API const wchar_t *asb_vm_os_type(AsbVm vm);
|
||||
ASB_API BOOL asb_vm_is_running(AsbVm vm);
|
||||
ASB_API BOOL asb_vm_agent_online(AsbVm vm);
|
||||
/* TRUE when the VM is running, the guest agent is online, and the agent has reported
|
||||
the display driver up (idd_status:ok, latched as idd_ready). All inputs are flags
|
||||
read passively -- readiness NEVER probes the frame channel, because connecting to it
|
||||
would itself become the single consumer and blank the display. Poll it on an interval
|
||||
(it disturbs nothing) and gate display-open on it. */
|
||||
ASB_API BOOL asb_vm_idd_ready(AsbVm vm);
|
||||
ASB_API BOOL asb_vm_is_building(AsbVm vm);
|
||||
ASB_API DWORD asb_vm_ram_mb(AsbVm vm);
|
||||
ASB_API DWORD asb_vm_hdd_gb(AsbVm vm);
|
||||
|
||||
@@ -77,6 +77,7 @@ typedef struct {
|
||||
BOOL running;
|
||||
BOOL network_cleaned; /* TRUE after network teardown (idempotent guard) */
|
||||
BOOL agent_online; /* TRUE when persistent agent connection is active */
|
||||
BOOL idd_ready; /* TRUE once the agent reports the display driver is up (idd_status:ok) */
|
||||
BOOL is_template; /* TRUE = template VM (sysprep after install) */
|
||||
BOOL install_complete; /* TRUE after guest agent first comes online */
|
||||
BOOL test_mode; /* TRUE = no Secure Boot (for test-signed drivers) */
|
||||
|
||||
@@ -231,6 +231,7 @@ static int process_async_message(VmInstance *vm, SOCKET s, const char *buf)
|
||||
} else if (strcmp(buf, "os_shutdown") == 0) {
|
||||
ui_log(L"Guest OS shutting down for \"%s\".", vm->name);
|
||||
vm->agent_online = FALSE;
|
||||
vm->idd_ready = FALSE;
|
||||
vm->shutdown_requested = TRUE;
|
||||
vm->shutdown_time = GetTickCount64();
|
||||
notify_agent_status(vm);
|
||||
@@ -251,6 +252,12 @@ static int process_async_message(VmInstance *vm, SOCKET s, const char *buf)
|
||||
} else if (strncmp(buf, "gpu_device_failed:", 18) == 0) {
|
||||
ui_log(L"[%s] GPU device still failing (problem %S).", vm->name, buf + 18);
|
||||
} else if (strncmp(buf, "idd_status:", 11) == 0) {
|
||||
/* Latch display readiness from the guest's own driver-state report
|
||||
("running" via devcon). This is the non-destructive readiness
|
||||
signal -- it never touches the frame channel, so polling it can't
|
||||
steal the single consumer slot or blank the display. asb_vm_idd_ready
|
||||
gates display-open on it. */
|
||||
vm->idd_ready = (strcmp(buf + 11, "ok") == 0);
|
||||
ui_log(L"[%s] IDD driver: %S", vm->name, buf + 11);
|
||||
} else if (strncmp(buf, "hyperv_video:", 13) == 0) {
|
||||
ui_log(L"[%s] Hyper-V Video: %S", vm->name, buf + 13);
|
||||
@@ -380,6 +387,7 @@ static DWORD WINAPI agent_thread_proc(LPVOID param)
|
||||
}
|
||||
|
||||
vm->agent_online = TRUE;
|
||||
vm->idd_ready = FALSE; /* re-evaluated by the agent's idd_status, sent right after hello */
|
||||
vm->shutdown_requested = FALSE;
|
||||
vm->last_heartbeat = GetTickCount64();
|
||||
ui_log(L"Agent online for \"%s\".", vm->name);
|
||||
@@ -515,6 +523,7 @@ static DWORD WINAPI agent_thread_proc(LPVOID param)
|
||||
disconnected:
|
||||
/* Connection lost */
|
||||
vm->agent_online = FALSE;
|
||||
vm->idd_ready = FALSE;
|
||||
/* Atomically claim the socket so we never double-close a handle that
|
||||
vm_agent_stop() may have already closed (and whose value could have
|
||||
been recycled by another socket()/accept()). */
|
||||
@@ -591,6 +600,7 @@ void vm_agent_stop(VmInstance *instance)
|
||||
}
|
||||
|
||||
instance->agent_online = FALSE;
|
||||
instance->idd_ready = FALSE;
|
||||
free_conn(conn);
|
||||
notify_agent_status(instance);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ in create-validation (wrong `osType` → `400`); the host-feature row mirrors
|
||||
| **macOS guest** | ❌ | ✅ (Apple silicon) |
|
||||
| Snapshots & branches | ✅ | ❌ `501` |
|
||||
| Templates (`isTemplate`, create-from-template) | ✅ | ❌ `501` |
|
||||
| Display window (`/vms/{n}/display`) | ✅ | planned ([`display-plan.md`](display-plan.md)) |
|
||||
| GPU, NAT/network modes, SSH server, SSH-key auto-deploy, SSE events | ✅ | ✅ |
|
||||
|
||||
Only `snapshots`/`templates` are advertised in the `capabilities` object; guest
|
||||
@@ -185,7 +186,7 @@ methods return `(http_status, body)` so you can branch on the status code.
|
||||
|
||||
A **status object** has: `name, osType, state, running, agentOnline,
|
||||
installComplete, building, progress, sshState, sshPort, ramMb, hddGb, cpuCores,
|
||||
gpuMode, networkMode`.
|
||||
gpuMode, networkMode, displayOpen`.
|
||||
|
||||
### Lifecycle *(return `(status, body)`)*
|
||||
| Method | Effect |
|
||||
@@ -240,6 +241,46 @@ connection can never make you miss a transition.
|
||||
|
||||
`asb.RUNNING_STATES == {"booting", "online"}` — the powered-on states.
|
||||
|
||||
### Display
|
||||
|
||||
Open the VM's **display window** — the same IDD/Connect view the GUI shows — on
|
||||
the **daemon's local desktop**. It renders on demand; the daemon never auto-opens
|
||||
it (good in the GUI, wrong for a CLI).
|
||||
|
||||
| Method | Returns |
|
||||
|---|---|
|
||||
| `display_status(name)` | `{open, ready}` — poll this; **no window is opened by polling** |
|
||||
| `display_ready(name)` | `bool` — shorthand for `display_status()["ready"]` |
|
||||
| `open_display(name)` | `(status, body)` — open (or focus) the window |
|
||||
| `close_display(name)` | `(status, body)` — close it |
|
||||
|
||||
The pattern is **poll-then-open**:
|
||||
|
||||
```python
|
||||
c.start("dev")
|
||||
while not c.display_ready("dev"): # running + agentOnline + agent says display driver up
|
||||
time.sleep(1)
|
||||
c.open_display("dev") # a window appears on the daemon's desktop
|
||||
...
|
||||
c.close_display("dev") # or the user just closes it with the [X]
|
||||
```
|
||||
|
||||
- **`ready` gates the open.** It is `running && agentOnline && idd_ready`, where
|
||||
`idd_ready` is the **guest agent's own report** that the display driver is up
|
||||
(`idd_status:ok`) — a latched flag, **not** a probe: reading it touches no window and
|
||||
no frame channel, so polling can't disturb the display. (Connecting to the frame
|
||||
channel to "check" would itself become the display's single consumer.) Opening before
|
||||
`ready` would show a black window, so `open_display` returns `409 display_not_ready`.
|
||||
- **`displayOpen`** (in every status object) reflects the *live* window, however
|
||||
it closed — `close_display`, the window's `[X]`, VM delete, and daemon exit all
|
||||
drive it false. `open_display` on an already-open VM just focuses the window.
|
||||
- **Local desktop only.** The window shows on the session the daemon runs in. A
|
||||
non-interactive session (a service/SSH daemon with no visible desktop) can't
|
||||
show one, so `open_display` returns `409 no_display` rather than spawning an
|
||||
invisible window. Multiple VMs' displays can be open at once.
|
||||
- **Windows host only today** (Windows *and* Linux guests both supported); the
|
||||
macOS design is captured in [`display-plan.md`](display-plan.md).
|
||||
|
||||
---
|
||||
|
||||
## Create config & validation
|
||||
@@ -304,7 +345,7 @@ subprocess.run(["ssh", "-i", asb.key_path(), "-p", str(i["port"]),
|
||||
| `401 Unauthorized` | missing/bad bearer token (only `version` is open) |
|
||||
| `404 Not Found` | unknown VM/route |
|
||||
| `405 Method Not Allowed` | wrong verb for the route |
|
||||
| `409 Conflict` | state conflict: edit/snapshot while running (`vm_running`), delete while building (`vm_building`), daemon shutdown with active VMs (`vms_active`, lists them) |
|
||||
| `409 Conflict` | state conflict: edit/snapshot while running (`vm_running`), delete while building (`vm_building`), daemon shutdown with active VMs (`vms_active`, lists them), or open-display refused (`no_display` no interactive desktop · `not_running` · `display_not_ready` agent hasn't reported the display driver up yet) |
|
||||
| `500 Internal Server Error` | the core returned a failure (`error.hr` on Windows, `error.rc` on macOS) |
|
||||
| `501 Not Implemented` | the feature doesn't exist on this host platform (`not_supported`) — snapshots/templates on macOS; mirror of `capabilities` |
|
||||
|
||||
|
||||
@@ -131,6 +131,21 @@ class Client:
|
||||
def templates(self): return self._req("GET", "/templates")[1].get("templates", [])
|
||||
def delete_template(self, name): return self._req("DELETE", "/templates/" + name)
|
||||
def ssh_info(self, name): return self._req("GET", "/vms/%s/sshInfo" % name)[1]
|
||||
def open_display(self, name):
|
||||
"""Open the VM's display window on the daemon's local desktop (the GUI's
|
||||
Connect view). The VM must be running, and the daemon must be in an
|
||||
interactive session that can show a window (a headless/SSH/service daemon
|
||||
returns 409 "no_display"). Returns (status, body); status() reports
|
||||
displayOpen, which also goes false if the user closes the window."""
|
||||
return self._req("POST", "/vms/%s/display" % name)
|
||||
def close_display(self, name): return self._req("DELETE", "/vms/%s/display" % name)
|
||||
def display_status(self, name):
|
||||
"""{'open': bool, 'ready': bool}. 'ready' is the agent's own report that the
|
||||
display driver is up (running + agentOnline + idd_status) -- a passive flag,
|
||||
NOT a probe: polling it touches no window and no frame channel, so it can't
|
||||
disturb the display. Wait for ready, then call open_display()."""
|
||||
return self._req("GET", "/vms/%s/display" % name)[1]
|
||||
def display_ready(self, name): return bool(self.display_status(name).get("ready"))
|
||||
def snapshots(self, name): return self._req("GET", "/vms/%s/snapshots" % name)[1].get("snapshots", [])
|
||||
def snapshots_full(self, name): return self._req("GET", "/vms/%s/snapshots" % name)[1] # incl. "current"
|
||||
def snap_take(self, name, snapname=None):
|
||||
|
||||
+172
-114
@@ -1,44 +1,65 @@
|
||||
# Headless display-open plan (Windows + macOS)
|
||||
# Headless display-open plan (Windows + Linux + macOS)
|
||||
|
||||
Expose "open the VM's display window" through the headless CLI/SDK, so a script can
|
||||
do `c.open_display("vm")` / `c.close_display("vm")` the way the GUI's Connect button
|
||||
does. The user can also close the window with its **X button**, and the daemon must
|
||||
notice and reflect that.
|
||||
does. The user can also close the window with its **X button**, and the daemon
|
||||
reflects that.
|
||||
|
||||
> **STATUS**
|
||||
> - **Windows: implementable and testable now.** This is the target of the first
|
||||
> pass.
|
||||
> - **Windows: implemented and validated.** Open/close/poll endpoints, the latched
|
||||
> readiness flag, and the per-VM display table are in `headless.c` + `asb_core.c`;
|
||||
> the SDK has `open_display`/`close_display`/`display_status`/`display_ready`.
|
||||
> Validated by hand: two VMs opened the instant readiness latched, close + reopen,
|
||||
> then graceful shutdown + remove — the daemon stayed alive through every path.
|
||||
> - **Linux: implemented; validated after push.** Same host-side machinery; the
|
||||
> readiness signal is the Linux guest agent reporting `idd_status` once the user
|
||||
> session is up. The agent is built from the online branch on the guest, so this is
|
||||
> exercised once the branch is pushed.
|
||||
> - **macOS: design only — UNVALIDATED.** We have no Mac to build/run it on. The
|
||||
> macOS section is a worked design plus the one spike that must pass on real
|
||||
> hardware before it can be trusted (it swaps the daemon's run loop). Do not ship
|
||||
> the macOS half on the strength of this document alone.
|
||||
> hardware (it swaps the daemon's run loop). Do not ship the macOS half on the
|
||||
> strength of this document alone.
|
||||
|
||||
---
|
||||
|
||||
## 1. How the display actually works (so the plan is grounded)
|
||||
## 1. How the display works (so the plan is grounded)
|
||||
|
||||
**Windows.** The guest's IDDCX driver (`tools/vdd/vdd.cpp`) creates a virtual
|
||||
monitor; Windows renders the desktop into the IDDCX swap chain, giving the driver
|
||||
RGB/BGRA buffers. The driver is the **listener**: `socket(AF_HYPERV)` →
|
||||
`bind`/`listen(…,1)`/`accept` on the frame service GUID `…0002…`
|
||||
(`vdd.cpp:166-220`). The **host** is the connector: `connect_to_hv_service(&vm->runtime_id,
|
||||
FRAME_SERVICE_GUID, …)` (`vm_display_idd.c:763`) opens an `AF_HYPERV` socket to that
|
||||
vsock using the VM's `runtime_id`, and `idd_recv_thread_proc` reads frames and
|
||||
renders them into a D3D11 window. **The host opening that connection is the act that
|
||||
makes frames flow** ("direct HvSocket frame transport to the host", `vdd.cpp:10`).
|
||||
`vm_display_idd_create(vm, HINSTANCE, HWND main_hwnd)` (`vm_display_idd.h:13`) is
|
||||
**self-contained**: it spawns its own window thread (`:2393`) with its own message
|
||||
pump (`:1994`) and D3D11 renderer; `main_hwnd` is used *only* to `PostMessage`
|
||||
`WM_VM_DISPLAY_CLOSED` on close (`:2129`).
|
||||
The single fact that shapes everything below: **the frame channel is the *consumer*
|
||||
channel — connecting to it *is* becoming the display.** So readiness can never be
|
||||
discovered by "testing" it; readiness comes from the guest agent instead.
|
||||
|
||||
**macOS.** The display is a `VZVirtualMachineView` hosted in an `NSWindow`
|
||||
(`VzDisplayWindow`, `vz_display.m`). The core creates it on the VM's **Running**
|
||||
transition, gated by `g_headless` (`asb_core_mac.m:586-590`), tracks it in
|
||||
`g_display_refs[]`, and closes it on stop/delete (`:567-571`, `:1070-1086`).
|
||||
Crucially, the **`VZVirtualMachine` is in-process** (`vz_handle` points at the
|
||||
in-process `VzVm`), so the view can only be created **inside the daemon process** —
|
||||
it cannot be offloaded to a helper GUI app, unlike Windows where frames arrive over a
|
||||
socket any process could render.
|
||||
**Windows.** The guest's IDDCX driver (`tools/vdd/vdd.cpp`) creates a virtual monitor;
|
||||
Windows composites the desktop into the IDDCX swap chain. The driver's network thread
|
||||
listens on the frame service GUID `…0002…`, but only while a swap chain is assigned —
|
||||
started in `AssignSwapChain` (`vdd.cpp:1253`), torn down in `UnassignSwapChain`, i.e.
|
||||
only while DWM is compositing. The host is the connector: `idd_recv_thread_proc`
|
||||
(`vm_display_idd.c`) opens an `AF_HYPERV` socket to that vsock and renders frames into a
|
||||
D3D11 window. **Connecting to `:0002` adopts you as the single consumer** — the driver
|
||||
takes the socket (`listen(…,1)`, backlog 1), sends a full frame, and only notices a
|
||||
disconnect via a failed send (`vdd.cpp:551-779`). A connection is never free: it starts
|
||||
a frame session the driver must then clean up. The **non-destructive** readiness signal
|
||||
is the guest agent's `idd_status`: the Windows agent runs `devcon status
|
||||
Root\AppSandboxVDD` and sends `idd_status:ok` when the device is "running"
|
||||
(`tools/agent/agent.c`) — a driver-state check over the agent channel, zero
|
||||
frame-channel contact.
|
||||
|
||||
`vm_display_idd_create(vm, HINSTANCE, HWND main_hwnd)` (`vm_display_idd.h`) is
|
||||
**self-contained**: its own window thread, message pump, and D3D11 renderer; `main_hwnd`
|
||||
is used *only* to `PostMessage WM_VM_DISPLAY_CLOSED` on close — the daemon passes `NULL`.
|
||||
|
||||
**Linux.** `asb_drm` (`tools/linux/asb_drm`) is a virtual DRM/KMS driver whose card
|
||||
registers at boot (`asb_drm_drv.c`). `appsandbox-display` (`tools/linux/agent`) is a
|
||||
userspace daemon that listens on vsock `:2` from boot and, on connect, captures the
|
||||
primary-plane framebuffer and ships frames in the same wire format. Same single-consumer
|
||||
shape (`listen(s,1)`, one `capture_loop` at a time), and frames only flow once Mutter is
|
||||
compositing an active framebuffer. Its readiness signal: the agent reports
|
||||
`idd_status:ok` once `is_user_session_ready` (Mutter's XWayland auth cookie exists,
|
||||
`appsandbox-agent.c`) — again over the agent channel, no frame-channel probe.
|
||||
|
||||
**macOS.** The display is a `VZVirtualMachineView` in an `NSWindow` (`VzDisplayWindow`,
|
||||
`vz_display.m`). The `VZVirtualMachine` is **in-process**, so the view can only be
|
||||
created **inside the daemon process** — there is no frame socket and no separate display
|
||||
driver, so readiness is simply `running && agentOnline`.
|
||||
|
||||
---
|
||||
|
||||
@@ -52,13 +73,10 @@ a racy snapshot). So we do **not** try to sniff the caller. The real gates:
|
||||
|
||||
- **Authorization — who may ask the daemon:** the existing **bearer token** in
|
||||
`host.json` (gated by that file's ACL). Unchanged. Not loopback.
|
||||
- **Frames (B) — who can read the VM's pixels:** the `AF_HYPERV` frame connect needs
|
||||
the VM's `runtime_id` + HvSocket access to that partition. The **daemon has both
|
||||
because it owns the compute system**; clients never touch the vsock — they go
|
||||
through the daemon. So B is satisfied *structurally*; we never enforce it against
|
||||
clients. (Whether the OS hard-ACLs the partition endpoint vs. gating it by "you
|
||||
need the runtime_id" is a threat-model footnote on a single-admin box, not a design
|
||||
input.)
|
||||
- **Frames (B) — who can read the VM's pixels:** the frame connect needs the VM's
|
||||
`runtime_id` + HvSocket/vsock access to that partition. The **daemon has both because
|
||||
it owns the compute system**; clients never touch the vsock — they go through the
|
||||
daemon. So B is satisfied *structurally*; we never enforce it against clients.
|
||||
- **Window (A) — can a window actually be shown:** the only runtime gate. A property
|
||||
of the **daemon's own session**, checked directly — no caller introspection:
|
||||
- Windows: visible window station — `GetProcessWindowStation()` +
|
||||
@@ -72,80 +90,109 @@ a racy snapshot). So we do **not** try to sniff the caller. The real gates:
|
||||
**Why this is right for the normal case:** the user launches the daemon in their
|
||||
interactive desktop session and drives it via the loopback script (with the token).
|
||||
The daemon, being in that same session, satisfies A, so the window opens on the user's
|
||||
desktop. No detection, no rejection — it just works. A only bites when the daemon has
|
||||
no desktop (service session / headless SSH), which is exactly when it *should* refuse.
|
||||
desktop. A only bites when the daemon has no desktop (service session / headless SSH),
|
||||
which is exactly when it *should* refuse.
|
||||
|
||||
---
|
||||
|
||||
## 3. Shared API surface
|
||||
|
||||
- `POST /v1/vms/{name}/display` → open (or focus if already open). `409` if the VM
|
||||
isn't running; `409 no_display` if A is false.
|
||||
- `POST /v1/vms/{name}/display` → open (or focus if already open). Gated, in order:
|
||||
`409 no_display` if A is false; `409 not_running` if the VM isn't running;
|
||||
`409 display_not_ready` if the display isn't ready yet (see below).
|
||||
- `DELETE /v1/vms/{name}/display` → close.
|
||||
- `GET /v1/vms/{name}` → add `"displayOpen": true|false`.
|
||||
- SDK (`asb.py`):
|
||||
```python
|
||||
def open_display(self, name): return self._req("POST", "/vms/%s/display" % name)
|
||||
def close_display(self, name): return self._req("DELETE", "/vms/%s/display" % name)
|
||||
# status() already returns the dict; displayOpen rides along
|
||||
```
|
||||
→ `c.open_display("vm")` is the `idd.connect("vm")` shape.
|
||||
- `GET /v1/vms/{name}/display` → `{"open": bool, "ready": bool}`. **Pollable and
|
||||
passive** — it reads flags, opens no window, and never touches the frame channel, so
|
||||
polling on an interval can't disturb the display.
|
||||
- `GET /v1/vms/{name}` → add `"displayOpen": true|false` (rides on every status).
|
||||
- SDK (`asb.py`): `open_display`, `close_display`, `display_status` (→ `{open, ready}`),
|
||||
`display_ready` (→ `bool`). `c.open_display("vm")` is the `idd.connect("vm")` shape.
|
||||
|
||||
Put the **A-gate in the shared endpoint layer** so both platforms enforce it before
|
||||
touching any window code, behind a small `asb_host_can_show_window()` helper.
|
||||
**Readiness is a latched flag, never a probe.** Opening the moment a VM is *running* is
|
||||
too early — the display driver / compositor isn't up, so the window is black. The
|
||||
*wrong* way to detect "ready" is to connect-test the frame channel: connecting **is**
|
||||
becoming the consumer (§1), so each poll steals the single consumer slot, triggers a
|
||||
frame-and-teardown churn, and under load the backlog-1 listener refuses the next connect
|
||||
— the signal flaps and the real open races the probe's own cleanup into a black window.
|
||||
Instead, readiness is sourced from the **guest agent** and latched on the host:
|
||||
|
||||
```
|
||||
asb_vm_idd_ready = running && agentOnline && idd_ready
|
||||
```
|
||||
|
||||
`idd_ready` is set when the agent reports `idd_status:ok` (Windows: VDD device
|
||||
"running"; Linux: user session up) and cleared on agent-offline / stop / reconnect. The
|
||||
host reads these flags passively; the same predicate backs the GET poll's `ready`.
|
||||
(macOS has no frame channel or separate driver, so its predicate is `running &&
|
||||
agentOnline`.)
|
||||
|
||||
Put the **A-gate and the readiness flag in the endpoint layer** so both platforms
|
||||
enforce them before touching any window code (`host_can_show_window()` +
|
||||
`asb_vm_idd_ready()`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Windows implementation (build this first)
|
||||
## 4. Windows + Linux implementation
|
||||
|
||||
`vm_display_idd_create` is self-contained, and the daemon already has what it needs:
|
||||
the `HINSTANCE` (`run_headless` → `asb_set_hinstance`, `headless.c:1070`) and
|
||||
`asb_vm_instance(vm)` to get the `VmInstance*`. The GUI keeps its display table in
|
||||
`ui.c` (`g_idd_displays[]`); move that into the core (or keep a daemon-side table in
|
||||
`headless.c`) so the daemon owns one.
|
||||
`vm_display_idd_create` is self-contained, and the daemon has what it needs: the
|
||||
`HINSTANCE` (`run_headless` → `g_hinst`) and `asb_vm_instance(vm)`. The daemon owns its
|
||||
own table in `headless.c`, **keyed by the VM's stable `unique_id`** (not the array
|
||||
index — it survives `g_vms[]` compaction):
|
||||
|
||||
```c
|
||||
/* headless.c */
|
||||
static VmDisplayIdd *g_displays[ASB_MAX_VMS]; /* per-VM, daemon-owned */
|
||||
typedef struct { UINT64 vm_id; VmDisplayIdd *disp; } DisplayEntry;
|
||||
static DisplayEntry g_displays[ASB_MAX_VMS];
|
||||
|
||||
/* POST /v1/vms/{name}/display */
|
||||
if (!host_window_station_visible()) /* A-gate (WSF_VISIBLE) */
|
||||
-> 409 {"error":"no_display"};
|
||||
VmInstance *v = asb_vm_instance(vm);
|
||||
if (!v || !v->running) -> 409;
|
||||
int idx = vm_index;
|
||||
if (g_displays[idx] && vm_display_idd_is_open(g_displays[idx]))
|
||||
vm_display_idd_focus(g_displays[idx]); /* already open -> focus */
|
||||
else
|
||||
g_displays[idx] = vm_display_idd_create(v, g_hinst, /*main_hwnd*/NULL);
|
||||
/* displayOpen / is-open is computed from the LIVE window state, never a cached flag */
|
||||
static BOOL display_is_open(UINT64 id) {
|
||||
DisplayEntry *e = display_find(id);
|
||||
return e && e->disp && vm_display_idd_is_open(e->disp);
|
||||
}
|
||||
|
||||
/* DELETE /v1/vms/{name}/display */
|
||||
vm_display_idd_destroy(g_displays[idx]); g_displays[idx] = NULL;
|
||||
/* POST /v1/vms/{name}/display -- A-gate, then running, then readiness, then open */
|
||||
display_reap_stale(v->unique_id); /* drop a self-(X)-closed one */
|
||||
if (!host_can_show_window()) -> 409 no_display;
|
||||
if (!v->running) -> 409 not_running;
|
||||
if (!asb_vm_idd_ready(vm)) -> 409 display_not_ready; /* the latched flag (§3) */
|
||||
e = display_find(v->unique_id);
|
||||
if (e) vm_display_idd_focus(e->disp); /* already open -> focus */
|
||||
else e->disp = vm_display_idd_create(v, g_hinst, /*main_hwnd*/NULL);
|
||||
|
||||
/* DELETE -> display_drop (vm_display_idd_destroy + free slot)
|
||||
GET -> {"open": display_is_open(id), "ready": open || asb_vm_idd_ready(vm)} */
|
||||
```
|
||||
|
||||
**X-button close.** Today the display posts `WM_VM_DISPLAY_CLOSED` to `main_hwnd`
|
||||
(`vm_display_idd.c:2129`); with `main_hwnd = NULL` that notification is lost, and the
|
||||
daemon's request loop is single-threaded (`headless.c:12`) so it has no message pump
|
||||
to receive it anyway. Fix: add a **close callback** to `vm_display_idd` — a function
|
||||
pointer (+ user data) the creator registers, invoked from the display's own window
|
||||
thread on `WM_CLOSE`/`WM_DESTROY`:
|
||||
```c
|
||||
/* vm_display_idd.h */
|
||||
typedef void (*VmDisplayIddOnClose)(void *ud);
|
||||
VmDisplayIdd *vm_display_idd_create_cb(VmInstance *vm, HINSTANCE hInstance,
|
||||
VmDisplayIddOnClose on_close, void *ud);
|
||||
/* headless.c: on_close nils g_displays[idx] so displayOpen flips false. */
|
||||
```
|
||||
The GUI can keep its `main_hwnd`/`WM_VM_DISPLAY_CLOSED` path or migrate to the
|
||||
callback; either way no message pump is needed in the daemon.
|
||||
**Readiness flag.** `asb_vm_idd_ready` (`asb_core.c`) = `running && agent_online &&
|
||||
idd_ready`. `idd_ready` is latched in `vm_agent.c` from the agent's `idd_status` line
|
||||
(set on `ok`, cleared on any other value / agent-offline / stop / reconnect). Nothing
|
||||
here connects to the frame channel. The IDD auto-probe is disabled in the daemon
|
||||
(`idd_probe_start` early-returns without a `g_idd_probe_hwnd`), so the daemon never
|
||||
auto-opens a window the caller didn't ask for; it opens only on an explicit POST.
|
||||
|
||||
**Cleanup.** On daemon exit, destroy any open `g_displays[]`. VM stop/delete already
|
||||
tears displays down in the GUI path (`safe_destroy_idd`); ensure the daemon table is
|
||||
nilled on the stop/delete handlers too so `displayOpen` stays honest.
|
||||
**The agent supplies the signal (non-destructive, agent channel).** Windows:
|
||||
`tools/agent/agent.c` already sends `idd_status` on connect (devcon device state).
|
||||
Linux: `tools/linux/agent/appsandbox-agent.c`'s heartbeat thread sends `idd_status:ok`
|
||||
the moment `is_user_session_ready` (Mutter up), `not_found` otherwise.
|
||||
|
||||
**X-button close.** The `WM_CLOSE` handler (`vm_display_idd.c:2073`) self-tears-down on
|
||||
its own window thread — stops the recv/audio threads, releases the vsock frame
|
||||
connection, sets `open = FALSE` — and does **not** free the `VmDisplayIdd` object. So
|
||||
`vm_display_idd_is_open()` returns FALSE after an X-close, and `displayOpen` (computed
|
||||
from `is_open`) reflects the close **however it happened** (X, programmatic, VM gone). A
|
||||
daemon-side **reap** (`display_reap_stale`, at the top of every `/display` request) frees
|
||||
the dead handle; the status serializer reads `is_open` directly, so `displayOpen` is
|
||||
correct even before the reap runs. No callback and no message pump are needed.
|
||||
|
||||
**Cleanup.** Daemon exit destroys every `g_displays[]` entry; the VM-**delete** handler
|
||||
`display_drop`s the VM's display first. VM-**stop** is intentionally *not* hooked: the
|
||||
window persists and its recv thread harmlessly retries (matching how the GUI keeps a
|
||||
display across a guest reboot); `displayOpen` stays honest via `is_open`, and the user
|
||||
(or a `DELETE`) closes it.
|
||||
|
||||
**A-gate helper (Windows):**
|
||||
```c
|
||||
static BOOL host_window_station_visible(void) {
|
||||
static BOOL host_can_show_window(void) {
|
||||
HWINSTA ws = GetProcessWindowStation();
|
||||
USEROBJECTFLAGS f = {0}; DWORD need = 0;
|
||||
if (ws && GetUserObjectInformationW(ws, UOI_FLAGS, &f, sizeof(f), &need))
|
||||
@@ -158,9 +205,6 @@ static BOOL host_window_station_visible(void) {
|
||||
desktop** — visible when launched from the interactive elevated shell (today's model),
|
||||
invisible if ever run as a session-0 service. The A-gate refuses the latter.
|
||||
|
||||
**Effort:** ~self-contained: core open/close/query + the close-callback + endpoint +
|
||||
2 SDK methods. **No agent change, no IDDCX change.**
|
||||
|
||||
---
|
||||
|
||||
## 5. macOS design (DESIGN ONLY — cannot validate here)
|
||||
@@ -169,6 +213,16 @@ Because the `VZVirtualMachine` is in-process, the window must be created **in th
|
||||
daemon**, so the daemon must be GUI-capable, so it must run **inside a GUI login
|
||||
session**. That constraint is immovable (OS reality), but the code delta is contained.
|
||||
|
||||
**Readiness.** macOS has no frame channel and no separate display driver — the
|
||||
`VZVirtualMachineView` binds directly to the in-process VM's framebuffer. So there is
|
||||
nothing to probe and no recv thread to strand; the readiness predicate is simply
|
||||
`running && agentOnline` (the agent being the cross-platform "guest is up" signal).
|
||||
Reject a premature `open_display` with `display_not_ready`, and back the
|
||||
`GET .../display` `ready` field with the same predicate so the poll-then-open flow and
|
||||
the `displayOpen` field are identical across platforms. `displayOpen`/close-tracking is
|
||||
easy: `windowWillClose:` calls back into the core to nil the ref (below), so the same
|
||||
"compute open-state from the live window, not a cached flag" rule applies.
|
||||
|
||||
**A-gate helper (macOS):**
|
||||
```objc
|
||||
#import <CoreGraphics/CoreGraphics.h>
|
||||
@@ -192,7 +246,7 @@ deliberately, so it works over SSH/launchd). Replace it with a startup branch:
|
||||
In a GUI session, run the AppKit loop -- [NSApp run] drains the main dispatch
|
||||
queue exactly like CFRunLoopRun, so every main-queue VZ op is unaffected -- and
|
||||
windows can open on demand. Otherwise keep the bare CFRunLoop: headless, no
|
||||
window (SSH / launchd), unchanged. */
|
||||
window (SSH / launchd). */
|
||||
if (asb_mac_have_gui_session()) {
|
||||
[NSApplication sharedApplication];
|
||||
[NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory];
|
||||
@@ -205,7 +259,7 @@ The quit path must match the loop: in the NSApp branch, replace
|
||||
`CFRunLoopStop(CFRunLoopGetMain())` with `[NSApp stop:nil]` + a dummy event to wake
|
||||
it (or `[NSApp terminate:nil]`).
|
||||
|
||||
**Open / close / query (`asb_core_mac.m`)** — reuses the exact window code the
|
||||
**Open / close / query (`asb_core_mac.m`)** — reuses the window code the
|
||||
Running-transition path already runs (`:586-590`), bypassing `g_headless`, on the
|
||||
main queue (HTTP runs on a worker thread, so `dispatch_sync` to main is safe):
|
||||
```objc
|
||||
@@ -216,6 +270,7 @@ int asb_mac_open_display(const char *name) {
|
||||
int idx = vm_index_of(name);
|
||||
if (idx < 0) { rc = BACKEND_NOT_FOUND; return; }
|
||||
if (!g_vms[idx].running || !g_vms[idx].vz_handle) { rc = BACKEND_NOT_RUNNING; return; }
|
||||
if (!g_vms[idx].agent_online) { rc = BACKEND_NOT_READY; return; } /* readiness (§5) */
|
||||
if (g_display_refs[idx]) { /* already open -> focus */
|
||||
[[(VzDisplayWindow *)g_display_refs[idx] window] makeKeyAndOrderFront:nil];
|
||||
return;
|
||||
@@ -273,34 +328,37 @@ here. **Until that spike passes on a Mac, the macOS half is not done.**
|
||||
|
||||
---
|
||||
|
||||
## 6. Edge cases (both platforms)
|
||||
## 6. Edge cases (all platforms)
|
||||
|
||||
- VM not running → `409`. Open when already open → focus + `200`. Close when already
|
||||
closed → no-op.
|
||||
- VM stops / is deleted while the display is open → existing stop/delete paths close
|
||||
the window; ensure the daemon's table / `g_display_refs` is nilled so `displayOpen`
|
||||
tracks it.
|
||||
- Not openable → `409`: `no_display` (A false), `not_running`, `display_not_ready`
|
||||
(agent not yet reporting the display up). Open when already open → focus + `200`.
|
||||
Close when already closed → no-op.
|
||||
- **VM deleted** while the display is open → the delete handler closes it first
|
||||
(`display_drop`). **VM merely stopped** → the window is left in place on Windows (recv
|
||||
thread harmlessly retries, like the GUI across a guest reboot); `displayOpen` stays
|
||||
honest because it's read from the live window (`is_open`), and the user or a `DELETE`
|
||||
closes it. (macOS: nil `g_display_refs[idx]` on the stop/delete transitions.)
|
||||
- Daemon exit → close all open displays.
|
||||
- Single consumer: the IDDCX driver `listen(…,1)` accepts one host connection, so at
|
||||
most one display per VM — which the per-VM table already enforces.
|
||||
- X-button close → the window self-tears-down; `displayOpen` flips false via `is_open`,
|
||||
and a reap frees the handle on the next `/display` request (Windows) / `windowWillClose:`
|
||||
nils the ref (macOS).
|
||||
- Single consumer: both the Windows IDDCX driver and the Linux display daemon
|
||||
`listen(…,1)`, so at most one display per VM — which the per-VM table enforces.
|
||||
Multiple *different* VMs can have displays open at once.
|
||||
|
||||
---
|
||||
|
||||
## 7. Rollout
|
||||
|
||||
1. **Finish this plan** (this document).
|
||||
2. **Commit current branch state** — the fire-and-forget graceful-shutdown change
|
||||
(`asb_core.c` / `vm_agent.c` / `vm_agent.h` / `vm_display_idd.c`) is uncommitted;
|
||||
reconcile against `origin/headless-api` and commit if changed.
|
||||
3. **Implement the Windows half** (section 4): build, then validate with the test
|
||||
suite + a manual `c.open_display` from an interactive session (window appears,
|
||||
X-close flips `displayOpen`, daemon stays responsive — single-threaded request
|
||||
loop must not be blocked by the display, which the self-contained window thread
|
||||
guarantees).
|
||||
4. **macOS half is deferred** — landed as code behind the A-gate, but marked
|
||||
unvalidated until the section-5 spike runs on a Mac. The A-gate means a macOS
|
||||
daemon without a GUI session simply refuses `open_display`, so shipping the code
|
||||
inert is safe; enabling/trusting it requires the spike.
|
||||
1. **Windows half (§4)** — implemented and validated (open-on-ready, close + reopen,
|
||||
graceful shutdown + remove; daemon stable throughout).
|
||||
2. **Linux half (§4)** — host-side machinery is shared and OS-agnostic; the only
|
||||
Linux-specific code is the guest agent's `idd_status` push. It's implemented but
|
||||
builds on the guest from the online branch, so it is **validated once the branch is
|
||||
pushed** (a fresh Linux VM then auto-builds the new agent).
|
||||
3. **macOS half (§5)** — design only, to land behind the A-gate. A macOS daemon without
|
||||
a GUI session refuses `open_display`, so shipping the code inert is safe; trusting it
|
||||
requires the section-5 run-loop spike on a real Mac.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ install, can each take 20–45 min) and uses real disk and bandwidth.
|
||||
| File | What it covers |
|
||||
| --- | --- |
|
||||
| `run_all.py` | Master harness. Sweeps leftover `brk-*` VMs, runs the static checks once, then one full per-VM lifecycle per spec **concurrently** (Linux + Windows on a Windows host; two macOS guests on a macOS host — one downloading its IPSW, one installing from the cached image), then the Windows template lifecycle (Windows hosts only). Prints a per-thread summary and a coverage table. This is the entry point. |
|
||||
| `vm_lifecycle.py` | The full per-VM sequence, parametrized by a create-spec. Covers create / status / edit / start / graceful shutdown / force-stop / SSE / delete, the during-build guards, **SSH key auto-deploy** (build with `sshDeployKey`, assert `keyDeployed`/`sshState 4`, then a key-only login), and — where `capabilities.snapshots` — snapshots + branches + base branches (asserts 501 on macOS instead). |
|
||||
| `vm_lifecycle.py` | The full per-VM sequence, parametrized by a create-spec. Covers create / status / edit / start / graceful shutdown / force-stop / SSE / delete, the during-build guards, **SSH key auto-deploy** (build with `sshDeployKey`, assert `keyDeployed`/`sshState 4`, then a key-only login), a quick **display open/close** once online (skipped on macOS hosts, on Linux guests until the updated agent is pushed, and in non-interactive daemon sessions), and — where `capabilities.snapshots` — snapshots + branches + base branches (asserts 501 on macOS instead). |
|
||||
| `test_host_and_validation.py` | No-VM checks: host cores/RAM cross-checked against the exact OS calls the daemon makes (Win32 / sysctl), capability gates, and the create-input-validation rejections matching the GUI's JS guards for the host platform. Builds nothing. |
|
||||
| `test_windows_template.py` | Windows-only template lifecycle: create a template (`isTemplate`), wait for sysprep finalization, create a VM from it, then delete the template. Never runs on macOS (`RUN_TEMPLATE = not IS_MAC`). |
|
||||
| `helpers.py` | Shared helpers: per-platform paths (`vm_dir`, disk name), `destroy_vm` (guard-aware teardown), `wait_progress` (stall-based waiting driven by the daemon's live progress reporting — no fixed deadlines on phases that report progress), and `ssh_key_login` (key-only BatchMode SSH login). |
|
||||
|
||||
@@ -76,6 +76,7 @@ COVERAGE = [
|
||||
("delete-during-build guard (409)", "per-VM (each thread, mid-build)"),
|
||||
("GET .../sshInfo", "per-VM"),
|
||||
("SSH key auto-deploy + key-only login", "per-VM (sshEnabled+sshDeployKey, each OS)"),
|
||||
("GET/POST/DELETE .../display open+close", "per-VM when online; skip on macOS / Linux-pre-push / no-desktop"),
|
||||
("snapshots take/rename/delete + GET tree", "per-VM"),
|
||||
("branches: start-from-snapshot + delete", "per-VM"),
|
||||
("GET /events (SSE) per VM", "per-VM (each thread, concurrent SSE)"),
|
||||
|
||||
@@ -160,6 +160,39 @@ def run_vm_lifecycle(spec):
|
||||
son = boot_online("post-install")
|
||||
check("guest installComplete", bool(son) and son["installComplete"], "state=%s" % (son and son["state"]))
|
||||
|
||||
# --- display: a quick programmatic open + close now that the VM is online.
|
||||
# Readiness is the agent's own idd_status report (running + agentOnline +
|
||||
# display driver up), latched on the host -- never a frame-channel probe.
|
||||
# SKIPped (not failed) so CI stays green in three cases:
|
||||
# * macOS host -- the display is design-only there;
|
||||
# * Linux guest whose deployed agent predates the idd_status push, so
|
||||
# readiness never latches -- exercised once this branch is pushed and a
|
||||
# fresh Linux VM auto-builds the updated agent;
|
||||
# * a daemon in a non-interactive session that can't show a window
|
||||
# (409 no_display). ---
|
||||
if sys.platform == "darwin":
|
||||
print(" SKIP [%s] display open/close -- not implemented on this host (macOS planned)" % name, flush=True)
|
||||
else:
|
||||
check("display not open before open", son.get("displayOpen") is False)
|
||||
deadline = time.time() + 90
|
||||
while time.time() < deadline and not c.display_ready(name):
|
||||
time.sleep(2)
|
||||
ready = c.display_ready(name)
|
||||
if not ready and os_type == "Linux":
|
||||
print(" SKIP [%s] display open/close -- Linux guest readiness needs the pushed agent" % name, flush=True)
|
||||
else:
|
||||
check("display ready when online", ready, "display_status=%s" % c.display_status(name))
|
||||
oc, ob = c.open_display(name)
|
||||
if oc == 409 and ob.get("error", {}).get("code") == "no_display":
|
||||
print(" SKIP [%s] display open/close -- no interactive desktop on daemon session" % name, flush=True)
|
||||
else:
|
||||
check("display open -> 200", oc == 200, "%s %s" % (oc, ob))
|
||||
check("displayOpen true after open", bool(st()) and st().get("displayOpen") is True)
|
||||
cc, cb = c.close_display(name)
|
||||
check("display close -> 200", cc == 200, "%s %s" % (cc, cb))
|
||||
check("displayOpen false after close", bool(st()) and st().get("displayOpen") is False)
|
||||
check("daemon alive after display open/close", bool(c.version().get("product")))
|
||||
|
||||
# --- duplicate name rejected ---
|
||||
check("duplicate name -> 400", c._req("POST", "/vms", dict(cfg))[0] == 400)
|
||||
|
||||
|
||||
@@ -451,6 +451,7 @@ static void send_reply(int fd, const char *tag, const char *msg)
|
||||
static void *heartbeat_thread(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
int idd_last = 0; /* last reported display-driver readiness (so we only send on change) */
|
||||
while (!g_stop) {
|
||||
/* Sleep first so the very first heartbeat is at +5s, after hello. */
|
||||
for (int s = 0; s < HEARTBEAT_INTERVAL_SEC && !g_stop; s++)
|
||||
@@ -462,6 +463,20 @@ static void *heartbeat_thread(void *arg)
|
||||
agent_log("heartbeat: send failed, exiting thread");
|
||||
break;
|
||||
}
|
||||
/* Report display readiness to the host (the same idd_status the Windows
|
||||
* agent sends). The display is serveable once the user session is up --
|
||||
* Mutter is compositing, so asb_drm has an active framebuffer for a host
|
||||
* consumer to capture. The host latches this as the display-open gate, so
|
||||
* it never has to probe the frame channel to find out. send_line is
|
||||
* mutex-locked, so this is safe alongside the command thread's replies. */
|
||||
{
|
||||
uid_t uid = find_graphical_session_uid();
|
||||
int ready = (uid != 0 && is_user_session_ready(uid));
|
||||
if (ready != idd_last) {
|
||||
send_line(fd, ready ? "idd_status:ok" : "idd_status:not_found");
|
||||
idd_last = ready;
|
||||
}
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user